diff --git a/.github/workflows/check-formats.yml b/.github/workflows/check-formats.yml index ec47d6c741..092dabe309 100644 --- a/.github/workflows/check-formats.yml +++ b/.github/workflows/check-formats.yml @@ -13,14 +13,14 @@ on: jobs: perltidy: name: Check Perl file formatting with perltidy - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 container: - image: perl:5.34 + image: perl:5.38 steps: - name: Checkout code uses: actions/checkout@v4 - name: Install dependencies - run: cpanm -n Perl::Tidy@20220613 + run: cpanm -n Perl::Tidy@20240903 - name: Run perltidy shell: bash run: | @@ -30,7 +30,7 @@ jobs: prettier: name: Check JavaScript, style, and HTML file formatting with prettier - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.perltidyrc b/.perltidyrc index 4620751c6f..8314b7c173 100644 --- a/.perltidyrc +++ b/.perltidyrc @@ -20,4 +20,3 @@ -nlop # No logical padding (this causes mixed tabs and spaces) -wn # Weld nested containers -xci # Extended continuation indentation --vxl='q' # No vertical alignment of qw quotes diff --git a/Dockerfile b/Dockerfile index 45fe592463..3310d21c51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN echo Cloning branch $PG_BRANCH branch from $PG_GIT_URL \ FROM ubuntu:24.04 ENV WEBWORK_URL=/webwork2 \ - WEBWORK_ROOT_URL=http://localhost::8080 \ + WEBWORK_ROOT_URL=http://localhost:8080 \ WEBWORK_SMTP_SERVER=localhost \ WEBWORK_SMTP_SENDER=webwork@example.com \ WEBWORK_TIMEZONE=America/New_York \ @@ -106,9 +106,6 @@ RUN apt-get update \ libhttp-async-perl \ libiterator-perl \ libiterator-util-perl \ - libjson-maybexs-perl \ - libjson-perl \ - libjson-xs-perl \ liblocale-maketext-lexicon-perl \ libmariadb-dev \ libmath-random-secure-perl \ @@ -190,6 +187,7 @@ RUN cpanm install -n \ DBD::MariaDB \ Perl::Tidy@20220613 \ Archive::Zip::SimpleZip \ + Net::SAML2 \ && rm -fr ./cpanm /root/.cpanm /tmp/* # ================================================================== diff --git a/DockerfileStage1 b/DockerfileStage1 index 2d4f569790..760e46d79e 100644 --- a/DockerfileStage1 +++ b/DockerfileStage1 @@ -68,9 +68,6 @@ RUN apt-get update \ libhttp-async-perl \ libiterator-perl \ libiterator-util-perl \ - libjson-maybexs-perl \ - libjson-perl \ - libjson-xs-perl \ liblocale-maketext-lexicon-perl \ libmariadb-dev \ libmath-random-secure-perl \ @@ -152,6 +149,7 @@ RUN cpanm install -n \ DBD::MariaDB \ Perl::Tidy@20220613 \ Archive::Zip::SimpleZip \ + Net::SAML2 \ && rm -fr ./cpanm /root/.cpanm /tmp/* # ================================================================== diff --git a/DockerfileStage2 b/DockerfileStage2 index 59c6828e48..4706f5bfb5 100644 --- a/DockerfileStage2 +++ b/DockerfileStage2 @@ -33,10 +33,10 @@ RUN echo Cloning branch $PG_BRANCH branch from $PG_GIT_URL \ # We need to change FROM before setting the ENV variables. -FROM webwork-base:forWW219 +FROM webwork-base:forWW220 ENV WEBWORK_URL=/webwork2 \ - WEBWORK_ROOT_URL=http://localhost::8080 \ + WEBWORK_ROOT_URL=http://localhost:8080 \ WEBWORK_SMTP_SERVER=localhost \ WEBWORK_SMTP_SENDER=webwork@example.com \ WEBWORK_TIMEZONE=America/New_York \ diff --git a/LICENSE b/LICENSE index f2efe0ad16..821f53097f 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,7 @@ Online Homework Delivery System Version 2.* - Copyright 2000-2024, The WeBWorK Project + Copyright 2000-2025, The WeBWorK Project All rights reserved. diff --git a/README.md b/README.md index 42a5b2f906..db8e9a149c 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Version 2.* Branch: github.com/openwebwork - https://webwork.maa.org/wiki/Release_notes_for_WeBWorK_2.19 - Copyright 2000-2024, The WeBWorK Project + https://webwork.maa.org/wiki/Release_notes_for_WeBWorK_2.20 + Copyright 2000-2025, The WeBWorK Project https://openwebwork.org/ All rights reserved. @@ -26,8 +26,6 @@ New users interested in getting started with their own WeBWorK server, or instru ## Information for Downloading -* The current version is WeBWorK-2.18 and its companion PG-2.18 - * Installation manuals can be found at https://webwork.maa.org/wiki/Category:Installation_Manuals ## Information For Developers diff --git a/VERSION b/VERSION index 21f58a5e78..450fad3c87 100644 --- a/VERSION +++ b/VERSION @@ -1,4 +1,4 @@ -$WW_VERSION = '2.19'; -$WW_COPYRIGHT_YEARS = '1996-2024'; +$WW_VERSION = '2.20'; +$WW_COPYRIGHT_YEARS = '1996-2025'; 1; diff --git a/assets/pg/PGMLLab/PGML-lab.pg b/assets/pg/PGMLLab/PGML-lab.pg index e04e0f6494..b58c76614c 100644 --- a/assets/pg/PGMLLab/PGML-lab.pg +++ b/assets/pg/PGMLLab/PGML-lab.pg @@ -507,7 +507,7 @@ TEXT(tag( >]{ [ style => 'border: 1px solid black; padding: 1rem;' ] } One useful application is when using the parserMultiAnswer.pl macro with - singleResult answers. Wrap the answers in in a div tag with the + singleResult answers. Wrap the answers in a div tag with the "ww-feedback-container" class to tell PG where to place the feedback button. The feedback button will be placed at the end of the containing div tag. diff --git a/assets/pg/Student_Orientation/setStudent_Orientation.def b/assets/pg/Student_Orientation/setStudent_Orientation.def index 573796a035..37f6c23eaf 100644 --- a/assets/pg/Student_Orientation/setStudent_Orientation.def +++ b/assets/pg/Student_Orientation/setStudent_Orientation.def @@ -1,8 +1,8 @@ assignmentType = default openDate = 01/01/2024 at 12:00am -reducedScoringDate = 12/31/2124 at 11:59pm -dueDate = 12/31/2124 at 11:59pm -answerDate = 12/31/2124 at 11:59pm +reducedScoringDate = 12/31/2045 at 11:59pm +dueDate = 12/31/2045 at 11:59pm +answerDate = 12/31/2045 at 11:59pm enableReducedScoring = N paperHeaderFile = defaultHeader screenHeaderFile = defaultHeader diff --git a/bin/OPL-update-legacy b/bin/OPL-update-legacy index ceda374cb3..3bb4d22335 100755 --- a/bin/OPL-update-legacy +++ b/bin/OPL-update-legacy @@ -28,7 +28,6 @@ use DBI; my $taxo={}; #my $taxsubs = []; - ### Data for creating the database tables my %OPLtables = ( @@ -539,8 +538,6 @@ if($canopenfile) { } #### End of taxonomy/taxonomy2 -use JSON; - #### Save the official taxonomy in json format my $webwork_htdocs = $ce->{webworkDirs}{htdocs}; my $file = "$webwork_htdocs/DATA/tagging-taxonomy.json"; diff --git a/bin/OPLUtils.pm b/bin/OPLUtils.pm index 6d041abe5e..18d4140695 100644 --- a/bin/OPLUtils.pm +++ b/bin/OPLUtils.pm @@ -1,4 +1,3 @@ - package OPLUtils; use base qw(Exporter); @@ -17,7 +16,7 @@ use warnings; use File::Find::Rule; use File::Basename; use open qw/:std :utf8/; -use JSON; +use Mojo::JSON qw(encode_json); our @EXPORT = (); our @EXPORT_OK = @@ -373,7 +372,7 @@ sub build_library_textbook_tree { sub writeJSONtoFile { my ($data, $filename) = @_; - my $json = JSON->new->utf8->encode($data); + my $json = encode_json($data); open my $fh, ">", $filename or die "Cannot open $filename"; print $fh $json; close $fh; diff --git a/bin/addcourse b/bin/addcourse index 81a9587279..42ea11ac5d 100755 --- a/bin/addcourse +++ b/bin/addcourse @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 @@ -32,11 +18,6 @@ be granted professor privileges. =over -=item B<--db-layout>=I - -The specified database layout will be used in place of the default specified in -F. - =item B<--users>=I The users listed in the comma-separated text file I will be added to the @@ -77,30 +58,26 @@ BEGIN { use lib "$ENV{WEBWORK_ROOT}/lib"; use WeBWorK::CourseEnvironment; - -# Grab course environment (by reading webwork2/conf/defaults.config) -my $ce = WeBWorK::CourseEnvironment->new; - -use WeBWorK::DB; use WeBWorK::File::Classlist; use WeBWorK::Utils qw(runtime_use cryptPassword); use WeBWorK::Utils::CourseManagement qw(addCourse); use WeBWorK::File::Classlist qw(parse_classlist); +use WeBWorK::DB::Record::User; +use WeBWorK::DB::Record::Password; +use WeBWorK::DB::Record::PermissionLevel; sub usage_error { warn "@_\n"; warn "usage: $0 [options] COURSEID\n"; warn "Options:\n"; - warn " [--db-layout=LAYOUT]\n"; warn " [--users=FILE [--professors=USERID[,USERID]...] ]\n"; exit; } -my ($dbLayout, $users, $templates_from) = ('', '', ''); +my ($users, $templates_from) = ('', ''); my @professors; GetOptions( - "db-layout=s" => \$dbLayout, "users=s" => \$users, "professors=s" => \@professors, "templates-from=s" => \$templates_from, @@ -110,33 +87,16 @@ my $courseID = shift; usage_error('The COURSEID must be provided.') unless $courseID; -$ce = WeBWorK::CourseEnvironment->new({ courseName => $courseID }); +my $ce = WeBWorK::CourseEnvironment->new({ courseName => $courseID }); die "Aborting addcourse: Course ID cannot exceed $ce->{maxCourseIdLength} characters." if length($courseID) > $ce->{maxCourseIdLength}; -if ($dbLayout) { - die "Database layout $dbLayout does not exist in the course environment.", - " (It must be defined in defaults.config.)\n" - unless exists $ce->{dbLayouts}{$dbLayout}; -} else { - $dbLayout = $ce->{dbLayoutName}; -} - usage_error("Can't specify --professors without also specifying --users.") if @professors && !$users; my @users; if ($users) { - # This is a hack to create records without bringing up a DB object - my $userClass = $ce->{dbLayouts}{$dbLayout}{user}{record}; - my $passwordClass = $ce->{dbLayouts}{$dbLayout}{password}{record}; - my $permissionClass = $ce->{dbLayouts}{$dbLayout}{permission}{record}; - - runtime_use($userClass); - runtime_use($passwordClass); - runtime_use($permissionClass); - my @classlist = parse_classlist($users); for my $record (@classlist) { my %record = %$record; @@ -168,9 +128,9 @@ if ($users) { push @users, [ - $userClass->new(%record), - $record{password} ? $passwordClass->new(user_id => $user_id, password => $record{password}) : undef, - $permissionClass->new( + WeBWorK::DB::Record::User->new(%record), + WeBWorK::DB::Record::Password->new(user_id => $user_id, password => $record{password}), + WeBWorK::DB::Record::PermissionLevel->new( user_id => $user_id, permission => defined $professors{$user_id} ? $ce->{userRoles}{professor} @@ -190,15 +150,7 @@ if ($templates_from) { $optional_arguments{copyTemplatesHtml} = 1; } -eval { - addCourse( - courseID => $courseID, - ce => $ce, - courseOptions => { dbLayoutName => $dbLayout }, - users => \@users, - %optional_arguments, - ); -}; +eval { addCourse(courseID => $courseID, ce => $ce, users => \@users, %optional_arguments,); }; die "$@\n" if $@; diff --git a/bin/change_user_id b/bin/change_user_id index 088ec26a81..2ec44a5034 100755 --- a/bin/change_user_id +++ b/bin/change_user_id @@ -47,7 +47,7 @@ my $ce = WeBWorK::CourseEnvironment->new({ courseName => $courseID }); -my $db = new WeBWorK::DB($ce->{dbLayout}); +my $db = WeBWorK::DB->new($ce); die "Error: $old_user_id does not exist!" unless $db->existsUser($old_user_id); unless($db->existsUser($new_user_id)) { @@ -69,7 +69,6 @@ unless($db->existsPermissionLevel($new_user_id)) { $db->addPermissionLevel($permission); } - my @old_user_sets = $db->listUserSets($old_user_id); foreach(@old_user_sets) { my $set_id = $_; diff --git a/bin/check_database_charsets.pl b/bin/check_database_charsets.pl deleted file mode 100755 index c46feb1475..0000000000 --- a/bin/check_database_charsets.pl +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env perl - -my $host = $ENV{WEBWORK_DB_HOST}; -my $port = $ENV{WEBWORK_DB_PORT}; -my $database_name = $ENV{WEBWORK_DB_NAME}; -my $database_user = $ENV{WEBWORK_DB_USER}; -my $database_password = $ENV{WEBWORK_DB_PASSWORD}; - -print - `mysql -u $database_user -p$database_password $database_name -h $host -e "SHOW VARIABLES WHERE Variable_name LIKE \'character\_set\_%\' OR Variable_name LIKE \'collation%\' or Variable_name LIKE \'init_connect\' "`; diff --git a/bin/check_latex b/bin/check_latex index 6c991c20e2..8c5a6ee567 100755 --- a/bin/check_latex +++ b/bin/check_latex @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 diff --git a/bin/check_latex_article.tex b/bin/check_latex_article.tex index ec36ecc99a..76036857ee 100644 --- a/bin/check_latex_article.tex +++ b/bin/check_latex_article.tex @@ -1,18 +1,3 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% WeBWorK Online Homework Delivery System -% Copyright © 2000-2024 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. -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - \documentclass[10pt]{article} \usepackage{webwork2} diff --git a/bin/check_latex_exam.tex b/bin/check_latex_exam.tex index 645d83ce89..bd48c01ef5 100644 --- a/bin/check_latex_exam.tex +++ b/bin/check_latex_exam.tex @@ -1,18 +1,3 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% WeBWorK Online Homework Delivery System -% Copyright © 2000-2024 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. -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - \documentclass[10pt]{exam} \usepackage{webwork2} diff --git a/bin/check_modules.pl b/bin/check_modules.pl index 428cb83094..3b73cee2bf 100755 --- a/bin/check_modules.pl +++ b/bin/check_modules.pl @@ -1,68 +1,39 @@ #!/usr/bin/env perl -# - -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 -check_modules.pl - check to ensure that all applications and perl modules are installed. +check_modules.pl - Check to ensure that applications and perl modules needed by +webwork2 are installed. =head1 SYNOPSIS check_modules.pl [options] Options: - -m|--modules Lists the perl modules needed to be installed. - -p|--programs Lists the programs/applications that are needed. - -A|--all checks both programs and modules (Default if -m or -p is not selected) + -m|--modules Check that the perl modules needed by webwork2 can be loaded. + -p|--programs Check that the programs needed by webwork2 exist. + +Both programs and modules are checked if no options are given. =head1 DESCRIPTION -Lists all needed applications for webwork as well as a perl modules. +Checks that modules needed by webwork2 can be loaded and are at the sufficient +version, and that applications needed by webwork2 exist. =cut use strict; use warnings; use version; +use feature 'say'; + use Getopt::Long qw(:config bundling); use Pod::Usage; -my @applicationsList = qw( - convert - curl - dvisvgm - mkdir - mv - mysql - node - tar - git - gzip - latex - pandoc - xelatex - dvipng -); - my @modulesList = qw( Archive::Tar Archive::Zip Archive::Zip::SimpleZip - Array::Utils Benchmark Carp Class::Accessor @@ -75,12 +46,10 @@ =head1 DESCRIPTION Date::Format Date::Parse DateTime - DBD::mysql DBI Digest::MD5 Digest::SHA Email::Address::XS - Email::Sender::Simple Email::Sender::Transport::SMTP Email::Stuffer Errno @@ -100,15 +69,10 @@ =head1 DESCRIPTION Getopt::Long Getopt::Std HTML::Entities - HTML::Tagset - HTML::Template HTTP::Async IO::File - IO::Socket::SSL Iterator Iterator::Util - JSON - JSON::MaybeXS Locale::Maketext::Lexicon Locale::Maketext::Simple LWP::Protocol::https @@ -121,29 +85,21 @@ =head1 DESCRIPTION Mojolicious::Plugin::NotYAMLConfig Mojolicious::Plugin::RenderFile Net::IP - Net::LDAPS Net::OAuth - Net::SMTP - Net::SSLeay Opcode - PadWalker Pandoc - Path::Class Perl::Tidy PHP::Serialization Pod::Simple::Search Pod::Simple::XHTML Pod::Usage Pod::WSDL - Safe Scalar::Util SOAP::Lite Socket SQL::Abstract - Statistics::R::IO String::ShellQuote SVG - Template Text::CSV Text::Wrap Tie::IxHash @@ -164,91 +120,146 @@ =head1 DESCRIPTION 'IO::Socket::SSL' => 2.007, 'LWP::Protocol::https' => 6.06, 'Mojolicious' => 9.34, - 'Net::SSLeay' => 1.46, - 'Perl::Tidy' => 20220613, 'SQL::Abstract' => 2.000000 ); -my ($test_programs, $test_modules, $show_help); -my $test_all = 1; +my @programList = qw( + convert + curl + mkdir + mv + mysql + mysqldump + node + npm + tar + git + gzip + latex + latex2pdf + pandoc + dvipng +); + +my ($test_modules, $test_programs, $show_help); GetOptions( 'm|modules' => \$test_modules, 'p|programs' => \$test_programs, - 'A|all' => \$test_all, 'h|help' => \$show_help, ); pod2usage(2) if $show_help; -my @PATH = split(/:/, $ENV{PATH}); - -if ($test_all or $test_programs) { - check_apps(@applicationsList); -} - -if ($test_all or $test_modules) { - check_modules(@modulesList); -} +$test_modules = $test_programs = 1 unless $test_programs || $test_modules; -sub check_apps { - my @applicationsList = @_; - print "\nChecking your \$PATH for executables required by WeBWorK...\n"; - print "\$PATH="; - print join("\n", map(" $_", @PATH)), "\n\n"; - - foreach my $app (@applicationsList) { - my $found = which($app); - if ($found) { - print " $app found at $found\n"; - } else { - print "** $app not found in \$PATH\n"; - } - } - - ## Check that the node version is sufficient. - my $node_version_str = qx/node -v/; - my ($node_version) = $node_version_str =~ m/v(\d+)\./; +my @PATH = split(/:/, $ENV{PATH}); - print "\n\n**The version of node should be at least 16. You have version $node_version" - if ($node_version < 16); -} +check_modules() if $test_modules; +say '' if $test_modules && $test_programs; +check_apps() if $test_programs; sub which { - my $app = shift; - foreach my $path (@PATH) { - return "$path/$app" if -e "$path/$app"; + my $program = shift; + for my $path (@PATH) { + return "$path/$program" if -e "$path/$program"; } + return; } sub check_modules { - my @modulesList = @_; + say "Checking for modules required by WeBWorK..."; - print "\nChecking your \@INC for modules required by WeBWorK...\n"; - my @inc = @INC; - print "\@INC="; - print join("\n", map(" $_", @inc)), "\n\n"; + my $moduleNotFound = 0; - no strict 'refs'; + my $checkModule = sub { + my $module = shift; - foreach my $module (@modulesList) { + no strict 'refs'; eval "use $module"; if ($@) { - my $file = $module; - $file =~ s|::|/|g; - $file .= ".pm"; + $moduleNotFound = 1; + my $file = ($module =~ s|::|/|gr) . '.pm'; if ($@ =~ /Can't locate $file in \@INC/) { - print "** $module not found in \@INC\n"; + say "** $module not found in \@INC"; } else { - print "** $module found, but failed to load: $@"; + say "** $module found, but failed to load: $@"; } } elsif (defined($moduleVersion{$module}) && version->parse(${ $module . '::VERSION' }) < version->parse($moduleVersion{$module})) { - print "** $module found, but not version $moduleVersion{$module} or better\n"; + $moduleNotFound = 1; + say "** $module found, but not version $moduleVersion{$module} or better"; + } else { + say " $module found and loaded"; + } + use strict 'refs'; + }; + + for my $module (@modulesList) { + $checkModule->($module); + } + + if ($moduleNotFound) { + say ''; + say 'Some requred modules were not found, could not be loaded, or were not at the sufficient version.'; + say 'Exiting as this is required to check the database driver and programs.'; + exit 0; + } + + say ''; + say 'Checking for the database driver required by WeBWorK...'; + my $ce = loadCourseEnvironment(); + my $driver = $ce->{database_driver} =~ /^mysql$/i ? 'DBD::mysql' : 'DBD::MariaDB'; + say "Configured to use $driver in site.conf"; + $checkModule->($driver); + + return; +} + +sub check_apps { + my $ce = loadCourseEnvironment(); + + say 'Checking external programs required by WeBWorK...'; + + push(@programList, $ce->{pg}{specialPGEnvironmentVars}{latexImageSVGMethod}); + + for my $program (@programList) { + if ($ce->{externalPrograms}{$program}) { + # Remove command line arguments (for latex and latex2pdf). + my $executable = $ce->{externalPrograms}{$program} =~ s/ .*$//gr; + if (-e $executable) { + say " $executable found for $program"; + } else { + say "** $executable not found for $program"; + } } else { - print " $module found and loaded\n"; + my $found = which($program); + if ($found) { + say " $found found for $program"; + } else { + say "** $program not found in \$PATH"; + } } } + + # Check that the node version is sufficient. + my $node_version_str = qx/node -v/; + my ($node_version) = $node_version_str =~ m/v(\d+)\./; + + say "\n**The version of node should be at least 18. You have version $node_version." + if $node_version < 18; + + return; +} + +sub loadCourseEnvironment { + eval 'require Mojo::File'; + die "Unable to load Mojo::File: $@" if $@; + my $webworkRoot = Mojo::File->curfile->dirname->dirname; + push @INC, "$webworkRoot/lib"; + eval 'require WeBWorK::CourseEnvironment'; + die "Unable to load WeBWorK::CourseEnvironment: $@" if $@; + return WeBWorK::CourseEnvironment->new({ webwork_dir => $webworkRoot }); } 1; diff --git a/bin/crypt_passwords_in_classlist.pl b/bin/crypt_passwords_in_classlist.pl index 88ed9f9fd2..1faa331daa 100755 --- a/bin/crypt_passwords_in_classlist.pl +++ b/bin/crypt_passwords_in_classlist.pl @@ -13,7 +13,7 @@ BEGIN use lib "$ENV{WEBWORK_ROOT}/lib"; -use WeBWorK::Utils qw(cryptPassword); +use WeBWorK::Utils qw(cryptPassword); use WeBWorK::File::Classlist qw(parse_classlist write_classlist); unless (@ARGV == 1) { diff --git a/bin/delcourse b/bin/delcourse index f0485b0a09..a04871be27 100755 --- a/bin/delcourse +++ b/bin/delcourse @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 diff --git a/bin/dev_scripts/PODtoHTML.pm b/bin/dev_scripts/PODtoHTML.pm index 9c7810a55b..a922314011 100644 --- a/bin/dev_scripts/PODtoHTML.pm +++ b/bin/dev_scripts/PODtoHTML.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package PODtoHTML; use strict; @@ -23,19 +8,29 @@ use Pod::Simple::Search; use Mojo::Template; use Mojo::DOM; use Mojo::Collection qw(c); -use File::Path qw(make_path); -use File::Basename qw(dirname); +use File::Path qw(make_path); +use File::Basename qw(dirname); use IO::File; use POSIX qw(strftime); use WeBWorK::Utils::PODParser; our @sections = ( - bin => 'Scripts', - conf => 'Config Files', doc => 'Documentation', + bin => 'Scripts', + macros => 'Macros', lib => 'Libraries', - macros => 'Macros' +); +our %macro_names = ( + answers => 'Answers', + contexts => 'Contexts', + core => 'Core', + deprecated => 'Deprecated', + graph => 'Graph', + math => 'Math', + misc => 'Miscellaneous', + parsers => 'Parsers', + ui => 'User Interface' ); sub new { @@ -52,6 +47,7 @@ sub new { idx => {}, section_hash => $section_hash, section_order => $section_order, + macros_hash => {}, }; return bless $self, $class; } @@ -131,7 +127,14 @@ sub update_index { $subdir =~ s|/.*$||; my $idx = $self->{idx}; my $sections = $self->{section_hash}; - if (exists $sections->{$subdir}) { + if ($subdir eq 'macros') { + $idx->{macros} = []; + if ($pod_name =~ m!^(.+)/(.+)$!) { + push @{ $self->{macros_hash}{$1} }, [ $html_rel_path, $2 ]; + } else { + push @{ $idx->{doc} }, [ $html_rel_path, $pod_name ]; + } + } elsif (exists $sections->{$subdir}) { push @{ $idx->{$subdir} }, [ $html_rel_path, $pod_name ]; } else { warn "no section for subdir '$subdir'\n"; @@ -152,6 +155,9 @@ sub write_index { pod_index => $self->{idx}, sections => $self->{section_hash}, section_order => $self->{section_order}, + macros => $self->{macros_hash}, + macros_order => [ sort keys %{ $self->{macros_hash} } ], + macro_names => \%macro_names, date => strftime('%a %b %e %H:%M:%S %Z %Y', localtime) } ); diff --git a/bin/dev_scripts/generate-ww-pg-pod.pl b/bin/dev_scripts/generate-ww-pg-pod.pl index b0e6e06d99..f1591ae485 100755 --- a/bin/dev_scripts/generate-ww-pg-pod.pl +++ b/bin/dev_scripts/generate-ww-pg-pod.pl @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 @@ -65,9 +51,9 @@ =head1 DESCRIPTION use Mojo::Template; use IO::File; use File::Copy; -use File::Path qw(make_path remove_tree); +use File::Path qw(make_path remove_tree); use File::Basename qw(dirname); -use Cwd qw(abs_path); +use Cwd qw(abs_path); use lib dirname(dirname(dirname(__FILE__))) . '/lib'; use lib dirname(__FILE__); diff --git a/bin/dev_scripts/pod-templates/category-index.mt b/bin/dev_scripts/pod-templates/category-index.mt index 78c9bb3724..a5d980d599 100644 --- a/bin/dev_scripts/pod-templates/category-index.mt +++ b/bin/dev_scripts/pod-templates/category-index.mt @@ -23,18 +23,44 @@ % - % my ($index, $content) = ('', ''); + % my ($index, $macro_index, $content, $macro_content) = ('', '', '', ''); + % for my $macro (@$macros_order) { + % my $new_index = begin + <%= $macro_names->{$macro} // $macro %> + % end + % $macro_index .= $new_index->(); + % my $new_content = begin +

<%= $macro_names->{$macro} // $macro %>

+
+ % for my $file (sort { $a->[1] cmp $b->[1] } @{ $macros->{$macro} }) { + <%= $file->[1] %> + % } +
+ % end + % $macro_content .= $new_content->(); + % } % for my $section (@$section_order) { % next unless defined $pod_index->{$section}; % my $new_index = begin <%= $sections->{$section} %> + % if ($section eq 'macros') { + + % } % end % $index .= $new_index->(); % my $new_content = begin

<%= $sections->{$section} %>

- % for my $file (sort { $a->[1] cmp $b->[1] } @{ $pod_index->{$section} }) { - <%= $file->[1] %> + % if ($section eq 'macros') { + <%= $macro_content =%> + % } else { + % for my $file (sort { $a->[1] cmp $b->[1] } @{ $pod_index->{$section} }) { + + <%= $file->[1] %> + + % } % }
% end diff --git a/bin/dev_scripts/run-perltidy.pl b/bin/dev_scripts/run-perltidy.pl index ebc101f4e8..37c9463353 100755 --- a/bin/dev_scripts/run-perltidy.pl +++ b/bin/dev_scripts/run-perltidy.pl @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 @@ -28,7 +14,7 @@ =head1 DESCRIPTION =head1 OPTIONS -For this script to work the the .perltidyrc file in the webwork2 root directory +For this script to work the .perltidyrc file in the webwork2 root directory must be readable. Note that the webwork2 root directory is automatically detected. @@ -63,9 +49,8 @@ =head1 OPTIONS my $webwork_root = curfile->dirname->dirname->dirname; -die "Version 20220613 or newer of perltidy is required for this script.\n" - . "The installed version is $Perl::Tidy::VERSION.\n" - unless $Perl::Tidy::VERSION >= 20220613; +die "Version 20240903 of perltidy is required for this script.\nThe installed version is $Perl::Tidy::VERSION.\n" + unless $Perl::Tidy::VERSION == 20240903; die "The .perltidyrc file in the webwork root directory is not readable.\n" unless -r "$webwork_root/.perltidyrc"; diff --git a/bin/dev_scripts/update-copyright b/bin/dev_scripts/update-copyright deleted file mode 100755 index 8684561214..0000000000 --- a/bin/dev_scripts/update-copyright +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash - -YEAR=$(date +%Y) - -function replace_license -{ - perl -i -0pe 'BEGIN{ undef $/; } -s{([#%/*]*) WeBWorK Online Homework Delivery System\s* -.*? -[ #%/*]* This program is free software; you can redistribute it and/or modify it under\s* -[ #%/*]* the terms of either: \(a\) the GNU General Public License as published by the\s* -[ #%/*]* Free Software Foundation; either version 2, or \(at your option\) any later\s* -[ #%/*]* version, or \(b\) the "Artistic License" which comes with this package.\s* -[ #%/*]*\s* -[ #%/*]* This program is distributed in the hope that it will be useful, but WITHOUT\s* -[ #%/*]* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS\s* -[ #%/*]* FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the\s* -([ #%/*]*) Artistic License for more details.\s* -}{$1 WeBWorK Online Homework Delivery System -$2 Copyright © 2000-'$YEAR' The WeBWorK Project, https://github.com/openwebwork -$2 -$2 This program is free software; you can redistribute it and/or modify it under -$2 the terms of either: (a) the GNU General Public License as published by the -$2 Free Software Foundation; either version 2, or (at your option) any later -$2 version, or (b) the "Artistic License" which comes with this package. -$2 -$2 This program is distributed in the hope that it will be useful, but WITHOUT -$2 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -$2 FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the -$2 Artistic License for more details. -}ms' $1 -} - -shopt -s extglob globstar nullglob - -for FILE in ./**/* -do - if [[ ! -L $FILE && -f $FILE && -w $FILE ]] - then - replace_license $FILE - fi -done diff --git a/bin/dev_scripts/webwork2-morbo b/bin/dev_scripts/webwork2-morbo index 21d7701cf4..dde0b82708 100755 --- a/bin/dev_scripts/webwork2-morbo +++ b/bin/dev_scripts/webwork2-morbo @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ =encoding utf8 diff --git a/bin/download-OPL-metadata-release.pl b/bin/download-OPL-metadata-release.pl index 2c6f3238bf..5d91a0187e 100755 --- a/bin/download-OPL-metadata-release.pl +++ b/bin/download-OPL-metadata-release.pl @@ -11,11 +11,11 @@ use File::Path; use Archive::Tar; use Mojo::File; -use JSON; +use Mojo::JSON qw(decode_json); BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } @@ -36,7 +36,7 @@ BEGIN File::Fetch->new(uri => 'https://api.github.com/repos/openwebwork/webwork-open-problem-library/releases/latest'); my $file = $releaseDataFF->fetch(to => $ce->{webworkDirs}{tmp}) or die $releaseDataFF->error; my $path = Mojo::File->new($file); -my $releaseData = JSON->new->utf8->decode($path->slurp); +my $releaseData = decode_json($path->slurp); $path->remove; my $releaseTag = $releaseData->{tag_name}; diff --git a/bin/dump-OPL-tables.pl b/bin/dump-OPL-tables.pl index 27ba37a4af..fa30a4c2ed 100755 --- a/bin/dump-OPL-tables.pl +++ b/bin/dump-OPL-tables.pl @@ -1,20 +1,5 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - # This script dumps the OPL library tables to a dump file. use strict; @@ -22,7 +7,7 @@ BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } diff --git a/bin/dump-past-answers.pl b/bin/dump-past-answers.pl index a386421e62..1a1edb739f 100755 --- a/bin/dump-past-answers.pl +++ b/bin/dump-past-answers.pl @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 @@ -98,7 +84,7 @@ =head1 DESCRIPTION BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } @@ -137,7 +123,7 @@ sub write_past_answers_csv { next if $courseID eq ($minimal_ce->{admin_course_id} // 'admin') || $courseID eq 'modelCourse'; my $ce = WeBWorK::CourseEnvironment->new({ webwork_dir => $ENV{WEBWORK_ROOT}, courseName => $courseID }); - my $db = WeBWorK::DB->new($ce->{dbLayout}); + my $db = WeBWorK::DB->new($ce); my %permissionLabels = reverse %{ $ce->{userRoles} }; diff --git a/bin/generate-OPL-set-def-lists.pl b/bin/generate-OPL-set-def-lists.pl index dbfaac2b4b..b0a8bb9b2d 100755 --- a/bin/generate-OPL-set-def-lists.pl +++ b/bin/generate-OPL-set-def-lists.pl @@ -26,7 +26,7 @@ =head1 DESCRIPTION BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } diff --git a/bin/importClassList.pl b/bin/importClassList.pl index d1bc2e5da8..636ea25421 100755 --- a/bin/importClassList.pl +++ b/bin/importClassList.pl @@ -1,25 +1,11 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ use strict; use warnings; BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } @@ -29,9 +15,9 @@ BEGIN use WeBWorK::CourseEnvironment; -use WeBWorK::DB qw(check_user_id); +use WeBWorK::DB; use WeBWorK::File::Classlist; -use WeBWorK::Utils qw(cryptPassword); +use WeBWorK::Utils qw(cryptPassword); use WeBWorK::File::Classlist qw(parse_classlist); if ((scalar(@ARGV) != 2)) { @@ -49,7 +35,7 @@ BEGIN courseName => $courseID }); -my $db = WeBWorK::DB->new($ce->{dbLayout}); +my $db = WeBWorK::DB->new($ce); my $createNew = 1; # Always set to true, so add new users my $replaceExisting = "none"; # Always set to "none" so no existing accounts are changed diff --git a/bin/load-OPL-global-statistics.pl b/bin/load-OPL-global-statistics.pl index e4da44ad0f..e2090af59d 100755 --- a/bin/load-OPL-global-statistics.pl +++ b/bin/load-OPL-global-statistics.pl @@ -1,27 +1,12 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - # This script loads the OPL global statistics, which is often done by bin/update-OPL-statistics but may need to be done # outside of that setting. use strict; BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } diff --git a/bin/newpassword b/bin/newpassword index c627161c75..2611690ae7 100755 --- a/bin/newpassword +++ b/bin/newpassword @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 @@ -94,7 +80,7 @@ my $ce = WeBWorK::CourseEnvironment->new({ courseName => $courseID }); -my $db = new WeBWorK::DB($ce->{dbLayout}); +my $db = WeBWorK::DB->new($ce); dopasswd($db, $user, $newP); print "Changed password for $user in $courseID\n"; diff --git a/bin/old_scripts/timing b/bin/old_scripts/timing deleted file mode 100755 index 6f877e8a12..0000000000 --- a/bin/old_scripts/timing +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - -use strict; -use warnings; - -use constant ID => 0; -use constant TIME => 1; -use constant TASK => 2; -use constant DATA => 3; - -my %processes; - -while (<>) { - - my ($pid, $id, $time, $diff, $task, $data) = - m/^TIMING\s+(\d+)\s+(\d+)\s+([\d\.]+)\s+(\([\d\.]+\))\s+(.*)\s*:\s*(.*)$/; - push @{$processes{$pid}}, [$id, $time, $diff, $task, $data] if $pid; - -} - -foreach my $pid (keys %processes) { - my $indent = -1; - print "Timing data for PID $pid\n\n"; - my @events = sort { $a->[TIME] <=> $b->[TIME] } @{$processes{$pid}}; - foreach my $event (@events) { - $indent++ if $event->[DATA] eq "START"; - - print " "x$indent, join(" \t",@$event), "\n"; - $indent-- if $event->[DATA] eq "FINISH"; - - } - print "\n"; - -} diff --git a/bin/old_scripts/ww-update-config b/bin/old_scripts/ww-update-config deleted file mode 100755 index 9bb414417c..0000000000 --- a/bin/old_scripts/ww-update-config +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env perl - -use strict; -use warnings; - -my $cvs_header_line = '\$' . 'CVSHeader'; - -foreach my $arg (@ARGV) { - my ($conf_file, $dist_file); - - if ($arg =~ /^(.*)\.dist$/) { - $conf_file = $1; - $dist_file = $arg; - } else { - $conf_file = $arg; - $dist_file = "$arg.dist"; - } - - my $conf_version = cvs_version($conf_file) - or die "couldn't find CVS version in $conf_file\n"; - my $dist_version = cvs_version($dist_file) - or die "couldn't find CVS version in $dist_file\n"; - - if ($conf_version eq $dist_version) { - print "$conf_file is up-to-date at version $conf_version.\n"; - next; - } - - #print "conf_version=$conf_version dist_version=$dist_version\n"; - system "cvs diff -r '$conf_version' -r '$dist_version' '$dist_file'" - . "| patch '$conf_file'"; -} - -sub cvs_version { - my ($file) = @_; - open my $fh, "<", $file or die "couldn't open $file for reading: $!\n"; - my $line; - while (my $line = <$fh>) { - if ($line =~ /$cvs_header_line.*?(1(?:\.\d+)+)/) { - return $1; - } - } -} diff --git a/bin/old_scripts/wwaddindexing b/bin/old_scripts/wwaddindexing deleted file mode 100755 index 2eeae8063d..0000000000 --- a/bin/old_scripts/wwaddindexing +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 - -wwaddindexing - add indices to an existing sql_single course. - -=head1 SYNOPSIS - - wwaddindexing COURSEID - -=head1 DESCRIPTION - -Adds indices to the course named COURSEID. The course must use the sql_single -database layout. - -=cut - -BEGIN { - # hide arguments (there could be passwords there!) - $0 = "$0"; -} - -use strict; -use warnings; -use DBI; - -my $pg_dir; -BEGIN { - die "WEBWORK_ROOT not found in environment.\n" unless exists $ENV{WEBWORK_ROOT}; - $pg_dir = $ENV{PG_ROOT} // "$ENV{WEBWORK_ROOT}/../pg"; - die "The pg directory must be defined in PG_ROOT" unless (-e $pg_dir); -} - -use lib "$ENV{WEBWORK_ROOT}/lib"; -use lib "$pg_dir/lib"; -use WeBWorK::CourseEnvironment; -use WeBWorK::DB; -use WeBWorK::Utils qw/runtime_use/; -use WeBWorK::Utils::CourseManagement qw/dbLayoutSQLSources/; - -sub usage { - print STDERR "usage: $0 COURSEID \n"; - exit; -} - -sub usage_error { - print STDERR "$0: @_\n"; - usage(); -} - -# get command-line options -my ($courseID) = @ARGV; - -# perform sanity check -usage_error("must specify COURSEID.") unless $courseID and $courseID ne ""; - -# bring up a minimal course environment -my $ce = WeBWorK::CourseEnvironment->new({ - webwork_dir => $ENV{WEBWORK_ROOT}, - courseName => $courseID, -}); - -# make sure the course actually uses the 'sql_single' layout -usage_error("$courseID: does not use 'sql_single' database layout.") - unless $ce->{dbLayoutName} eq "sql_single"; - -# get database layout source data -my %sources = dbLayoutSQLSources($ce->{dbLayout}); - -foreach my $source (keys %sources) { - my %source = %{$sources{$source}}; - my @tables = @{$source{tables}}; - my $username = $source{username}; - my $password = $source{password}; - - my $dbh = DBI->connect($source, $username, $password); - - foreach my $table (@tables) { - # this stuff straight out of sql_single.pm - my %table = %{ $ce->{dbLayout}{$table} }; - my %params = %{ $table{params} }; - - my $source = $table{source}; - my $tableOverride = $params{tableOverride}; - my $recordClass = $table{record}; - - runtime_use($recordClass); - my @fields = $recordClass->FIELDS; - my @keyfields = $recordClass->KEYFIELDS; - - if (exists $params{fieldOverride}) { - my %fieldOverride = %{ $params{fieldOverride} }; - foreach my $field (@fields) { - $field = $fieldOverride{$field} if exists $fieldOverride{$field}; - } - } - - my @fieldList; - foreach my $start (0 .. $#keyfields) { - my $line = "ADD INDEX ( "; - $line .= join(", ", map { "`$_`(16)" } @keyfields[$start .. $#keyfields]); - $line .= " )"; - push @fieldList, $line; - } - my $fieldString = join(", ", @fieldList); - - my $tableName = $tableOverride || $table; - my $stmt = "ALTER TABLE `$tableName` $fieldString;"; - - unless ($dbh->do($stmt)) { - die "An error occurred while trying to modify the course database.\n", - "It is possible that the course database is in an inconsistent state.\n", - "The DBI error message was:\n\n", - $dbh->errstr, "\n"; - } - } - - $dbh->disconnect; -} - -=head1 AUTHOR - -Written by Sam Hathaway, hathaway at users.sourceforge.net. - -=cut diff --git a/bin/old_scripts/wwdb_addgw b/bin/old_scripts/wwdb_addgw deleted file mode 100755 index 137a8e466b..0000000000 --- a/bin/old_scripts/wwdb_addgw +++ /dev/null @@ -1,394 +0,0 @@ -#!/usr/bin/env perl -w -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ -# -# wwdb_addgw -# update webwork database tables to add fields for the gateway module -# -# by Gavin LaRose -# -=head1 NAME - -wwdb_addgw - convert SQL databases for WeBWorK 2 to add gateway fields. - -=head1 SYNOPSIS - - wwdb_addgw [-h] [sql|sql_single] - -=head1 DESCRIPTION - -Adds fields to the set and set_user tables in the WeBWorK mysql databases -that are required for the gateway module. The script prompts for which -courses to modify. Adding gateway database fields to existing courses -should have no effect on those courses, even if they are running under a -non-gateway aware version of the WeBWorK system. - -If C<-h> is provided, the script hides the mysql admin password. - -C or C gives the default WeBWorK database format. If -omitted, the script assumes sql_single and prompts to be sure. - -=cut - -use strict; -use DBI; - -# this is necessary on some systems -system("stty erase "); - -my $source = 'DBI:mysql'; - -# fields to add to the set and set_user tables -my %addFields = ( 'assignment_type' => 'text', - 'attempts_per_version' => 'integer', - 'time_interval' => 'integer', - 'versions_per_interval' => 'integer', - 'version_time_limit' => 'integer', - 'version_creation_time' => 'bigint', - 'problem_randorder' => 'integer', - 'version_last_attempt_time' => 'bigint', ); - -# process input data -my $hidepw = 0; -my $dbtype = 'sql_single'; -while ( $_ = shift(@ARGV) ) { - if ( /^-h$/ ) { - $hidepw = 1; - } elsif ( /^-/ ) { - die("Unknown input flag $_.\nUsage: wwdb_addgw [-h] sql|sql_single\n"); - } else { - if ( $_ eq 'sql' || $_ eq 'sql_single' ) { - $dbtype = $_; - } else { - die("Unknown argument $_.\nUsage: wwdb_addgw [-h] " . - "sql|sql_single\n"); - } - } -} - -printHdr( $dbtype ); - -# get database information -my ( $admin, $adminpw ); -( $admin, $adminpw, $dbtype ) = getDBInfo( $hidepw, $dbtype ); - -# connect to database, if we're in sql_single mode; this lets us easily -# get a list of courses to work with. in sql mode, it's harder b/c I can't -# get DBI->data_sources('mysql') to work on my system, so we prompt for -# those separately. if we're in sql single mode, $dbh is a place holder, -# because we have to do the database connects in the subroutines to connect -# to each different database -my $dbh = ''; -if ( $dbtype eq 'sql_single' ) { - $dbh = DBI->connect("$source:webwork", $admin, $adminpw) or - die( $DBI::errstr ); -} - -# get courses list -my @courses = getCourses( $dbtype, $dbh ); - -# now $course{coursename} = format (sql or sql_single) - -# do update -my ( $doneRef, $skipRef ) = updateCourses( $dbtype, $dbh, \@courses, - $admin, $adminpw ); -$dbh->disconnect() if ( $dbh ); - -# all done -confirmUpdate( $dbtype, $doneRef, $skipRef ); - -# end of main -#------------------------------------------------------------------------------- -# subroutines - -sub printHdr { - print < "; - my $admin = ; - chomp( $admin ); - $admin = 'root' if ( ! $admin ); - - print "mySQL login password for $admin > "; - system("stty -echo") if ( $hide ); - my $passwd = ; - if ( $hide ) { system("stty echo"); print "\n"; } - chomp( $passwd ); - die("Error: no password provided\n") if ( ! $passwd ); - - print "WeBWorK database type (sql or sql_single) [$type] > "; - my $dbtype = ; - chomp( $dbtype ); - $dbtype = $type if ( ! $dbtype ); - - return( $admin, $passwd, $dbtype ); -} - -sub getCourses { - my ( $dbtype, $dbh ) = @_; - - my %courses = (); - -# get a course list - if ( $dbtype eq 'sql' ) { - print "courses to update (enter comma separated) > "; - my $crslist = ; - chomp($crslist); - my @crslist = split(/,\s*/, $crslist); - die("Error: no courses specified\n") if ( ! @crslist ); - foreach ( @crslist ) { $courses{$_} = 1; } - - } else { - my $cmd = 'show tables'; - my $st = $dbh->prepare( $cmd ) or die( $dbh->errstr() ); - $st->execute() or die( $st->errstr() ); - my $rowRef = $st->fetchall_arrayref(); - foreach my $r ( @$rowRef ) { - $_ = $r->[0]; - #my ($crs, $tbl) = ( /^([^_]+)_(.*)$/ ); # this fails on courses with underscores in their names - my ($crs) = (/^(.*)_key$/); # match the key table - $courses{$crs} = 1 if ( defined( $crs ) ); - } - die("Error: found now sql_single WeBWorK courses\n") if ( ! %courses ); - } - -# confirm this is correct - print "\nList of courses to update:\n"; - my %nummap = orderedList( %courses ); - printclist( sort keys( %courses ) ); - print "Enter # to edit name, d# to delete from update list, or [cr] to " . - "continue.\n > "; - my $resp = ; - chomp($resp); - while ( $resp ) { - if ( $resp =~ /^\d+$/ ) { - print " old course name $nummap{$resp}; new > "; - delete( $courses{$nummap{$resp}} ); - my $newname = ; - chomp($newname); - $courses{ $newname } = 1; - } elsif ( $resp =~ /^d(\d+)$/ ) { - $resp = $1; - delete( $courses{$nummap{$resp}} ); - } else { - print "unrecognized response: $resp.\n"; - } - %nummap = orderedList( %courses ); - print "Current list of courses to update:\n"; - printclist( sort keys( %courses ) ); - print "Enter #, d# or [cr] > "; - chomp( $resp = ); - } - - my @courses = sort( keys %courses ); - if ( @courses ) { - return @courses; - } else { - die("Error: no courses left to update.\n"); - } -} - -sub orderedList { - my %hash = @_; - my $i=1; - my %nummap = (); - foreach ( sort( keys( %hash ) ) ) { - $nummap{ $i } = $_; - $i++; - } - return %nummap; -} - -sub printclist { - my @list = @_; - -# assumes a 75 column screen - - my $i = 1; - if ( @list <= 3 ) { - foreach ( @list ) { print " $i. $_\n"; $i++ } - } else { - while ( @list >= $i ) { - printf(" %2d. %-19s", $i, $list[$i-1]); - printf(" %2d. %-19s", ($i+1), $list[$i]) if ( @list >= ($i+1) ); - printf(" %2d. %-19s", ($i+2), $list[$i+1]) if ( @list >= ($i+2) ); - print "\n"; - $i+=3; - } - } - return 1; -} - -sub updateCourses { - my ( $dbtype, $dbh, $crsRef, $admin, $adminpw ) = @_; - - my @done = (); - my @skipped = (); - -# give some sense of progress - select STDOUT; $| = 1; # unbuffer output - print "doing update for $dbtype databases.\n"; - -# list of added fields to check for classes that don't need updating - my @newFields = keys( %addFields ); - - foreach my $crs ( @$crsRef ) { - print "updating $crs.\n"; - my $colRef; - - if ( $dbtype eq 'sql' ) { - # we need to get a database handle first - $dbh = DBI->connect("$source:webwork_$crs", $admin, $adminpw) or - die( $DBI::errstr ); - - # now get a list of columns from the set table to check to see if - # we need an update - my $cmd = "show columns from set_not_a_keyword"; - my $st = $dbh->prepare( $cmd ) or die( $dbh->errstr() ); - $st->execute(); - $colRef = $st->fetchall_arrayref(); - - } else { - # for sql_single we already have a database handle; get the set table - # columns and proceed - my $cmd = "show columns from `${crs}_set`"; - print "$cmd\n"; - my $st = $dbh->prepare( $cmd ) or die( $dbh->errstr() ); - $st->execute(); - $colRef = $st->fetchall_arrayref(); - } - - # now, do we have the columns we need already? - my $doneAlready = 0; - foreach my $cols ( @$colRef ) { - if ( inList( $cols->[0], @newFields ) ) { - $doneAlready = 1; - last; - } - } - if ( $doneAlready ) { - push( @skipped, $crs ); - next; - } else { - - # do update for course - my ( $cmd1, $cmd2 ); - if ( $dbtype eq 'sql' ) { - $cmd1 = 'alter table set_not_a_keyword add column'; - $cmd2 = 'alter table set_user add column'; - } else { - $cmd1 = "alter table `${crs}_set` add column"; - $cmd2 = "alter table `${crs}_set_user` add column"; - } - - foreach my $f ( keys %addFields ) { - print "$cmd1 $f $addFields{$f}\n"; - my $st = $dbh->prepare( "$cmd1 $f $addFields{$f}" ) or - die( $dbh->errstr() ); - $st->execute() or die( $st->errstr() ); - } - - foreach my $f ( keys %addFields ) { - print "$cmd2 $f $addFields{$f}\n"; - my $st = $dbh->prepare( "$cmd2 $f $addFields{$f}" ) or - die( $dbh->errstr() ); - $st->execute() or die( $st->errstr() ); - } - - push( @done, $crs ); - } - # if we're doing sql databases, disconnect from this courses' database - $dbh->disconnect() if ( $dbtype eq 'sql' ); - - } # end loop through courses - print "\n"; - - return( \@done, \@skipped ); -} - -sub inList { - my $v = shift(); - foreach ( @_ ) { return 1 if ( $v eq $_ ); } - return 0; -} - -sub confirmUpdate { - my ( $dbtype, $doneRef, $skipRef ) = @_; - - my $s1 = "updated $dbtype courses: "; - my $s2 = "courses not needing updates were skipped: "; - my $l1 = length($s1); - my $l2 = length($s2); - - my $crsList= (@$doneRef) ? join(', ', @$doneRef) : ''; - my $skpList= (@$skipRef) ? join(', ', @$skipRef) : ''; - my $crsString = ( $crsList ) ? - $s1 . hangIndent( $l1, 75, $l1, "$crsList.") . "\n" : ''; - my $skpString = ( $skpList ) ? - $s2 . hangIndent( $l1, 75, $l2, "$skpList." ) : ''; - - print <= $width ) { - $htext .= $line . "\n$ldr"; - $line = "$_ "; - $indent = $hang; - } else { - $line .= "$_ "; - } - } - $htext .= $line if ( $line ); - } - $htext =~ s/\n$ldr$//; - return $htext; -} - -# end of script -#------------------------------------------------------------------------------- diff --git a/bin/old_scripts/wwdb_check b/bin/old_scripts/wwdb_check deleted file mode 100755 index 67215fa767..0000000000 --- a/bin/old_scripts/wwdb_check +++ /dev/null @@ -1,1025 +0,0 @@ -#!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 - -wwdb_check - check the schema of an existing WeBWorK database - -=head1 SYNOPSIS - - wwdb_check [-nv] [ COURSE ... ] - -=head1 DESCRIPTION - -Scans an existing WeBWorK database to verify that its structure is correct for -version 0 of the database structure. Version 0 refers to the last version before -automatic database upgrading was added to WeBWorK. This utility should be run -once after upgrading webwork from version 2.2.x to version 2.3.0. - -Once any inconsistencies are fixed using this utility, F should be -run to affect automatic database upgrades to the database version appropriate -for the current version of WeBWorK. - -If no courses are listed on the command line, all courses are checked. Checks -for the following: - -=over - -=item * - -Make sure that the appropriate tables exist for each course. - -=item * - -Make sure that the proper columns exist in each table. - -=item * - -Verify that the proper column type is in use for each column. - -=back - -=head1 OPTIONS - -=over - -=item -n - -Don't offer to fix problems, just report them. - -=item -v - -Verbose output. - -=back - -=cut - -use strict; -use warnings; -use Getopt::Std; -use DBI; -use Data::Dumper; - -my $pg_dir; -BEGIN { - die "WEBWORK_ROOT not found in environment.\n" unless exists $ENV{WEBWORK_ROOT}; - $pg_dir = $ENV{PG_ROOT} // "$ENV{WEBWORK_ROOT}/../pg"; - die "The pg directory must be defined in PG_ROOT" unless (-e $pg_dir); -} - -use lib "$ENV{WEBWORK_ROOT}/lib"; -use lib "$pg_dir/lib"; -use WeBWorK::CourseEnvironment; -use WeBWorK::Utils qw/runtime_use/; -use WeBWorK::Utils::CourseManagement qw/listCourses/; - -our ($opt_n, $opt_v); -getopts("nv"); - -my $noop = sub {}; - -if ($opt_n) { - *maybe_add_table = $noop; - *maybe_add_field = $noop; - *maybe_change_field = $noop; -} else { - *maybe_add_table = \&ask_add_table; - *maybe_add_field = \&ask_add_field; - *maybe_change_field = \&ask_change_field; -} - -if ($opt_v) { - $| = 1; - *verbose = sub { print STDERR @_ }; -} else { - *verbose = $noop; -} - -use constant DB_VERSION => 0; - -# a random coursename we can grab back out later -#my @chars = ('A'..'Z','a'..'z','0'..'9'); -#my $random_courseID = join("", map { $chars[rand(@chars)] } 1..16); -# fixed courseID for "version zero table data" -my $random_courseID = "6SC36NukknC3IT3M"; - -my $ce = WeBWorK::CourseEnvironment->new({ - webwork_dir => $ENV{WEBWORK_ROOT}, - courseName => $random_courseID, -}); - -my $dbh = DBI->connect( - $ce->{database_dsn}, - $ce->{database_username}, - $ce->{database_password}, - { - PrintError => 0, - RaiseError => 1, - }, -); - -=for comment - - %ww_table_data = ( - $table => { - sql_name => "SQL name for this field, probably contains $random_courseID", - field_order => [ ... ], - keyfield_order => [ ... ], - fields => { - $field => { - sql_name => "SQL name for this field, possibly overridden", - sql_type => "type for this field, from SQL_TYPES in record class", - is_keyfield => "boolean, whether or not this field is a keyfield", - }, - ... - }, - }, - ... - ); - -=cut - -# get table data for the current version of webwork -#my %ww_table_data = get_ww_table_data(); -#$Data::Dumper::Indent = 1; -#print Dumper(\%ww_table_data); -#exit; -# get static table data for version zero of the database -my %ww_table_data = get_version_zero_ww_table_data(); - -my %sql_tables = get_sql_tables(); - -if (exists $sql_tables{dbupgrade}) { - print "A 'dbupgrade' table exists in this database. This suggests that this database may already be upgraded beyond db_version 0. If this is the case, running this utility is not necessary. This utility is only needed to make sure that databases are set up correctly to enter into the automatic upgrade regimen.\n"; - exit unless ask_permission("Go ahead with table checks?", 0); - delete $sql_tables{dbupgrade}; -} - -my @ww_courses = @ARGV; -@ww_courses = listCourses($ce) if not @ww_courses; - -foreach my $ww_course_name (@ww_courses) { - my $ce2 = WeBWorK::CourseEnvironment->new({ - webwork_dir => $ENV{WEBWORK_ROOT}, - courseName => $ww_course_name, - }); - - my @diffs = compare_dbLayouts($ce, $ce2); - if (@diffs) { - print "\nThe database layout for course '$ww_course_name' differs from the generic database layout in global.conf. Here's how:\n\n"; - print map("* $_\n", @diffs), "\n"; - next unless ask_permission("Check course '$ww_course_name'?", 0); - } - - print "\nChecking tables for course '$ww_course_name'\n"; - - foreach my $ww_table_name (keys %ww_table_data) { - if ($ce2->{dbLayout}{$ww_table_name}{params}{non_native}) { - verbose("skipping table $ww_table_name for course $ww_course_name -- not a native table.\n"); - } else { - check_table($ww_course_name, $ww_table_name); - } - } -} - -my $qualifier = @ARGV ? " selected" : ""; -print "\nDone checking course tables.\n"; -print "The following tables exist in the database but are not associated with any$qualifier course:\n\n"; -print join("\n", sort keys %sql_tables), "\n\n"; - -exit; - -################################################################################ - -sub get_ww_table_data { - my %result; - - foreach my $table (keys %{$ce->{dbLayout}}) { - my $record_class = $ce->{dbLayout}{$table}{record}; - runtime_use $record_class; - - my @fields = $record_class->FIELDS; - my @types = $record_class->SQL_TYPES; - my @keyfields = $record_class->KEYFIELDS; - my %keyfields; @keyfields{@keyfields} = (); - - my %field_data; - - foreach my $i (0..$#fields) { - my $field = $fields[$i]; - my $field_sql = $ce->{dbLayout}{$table}{params}{fieldOverride}{$field}; - $field_data{$field}{sql_name} = $field_sql || $field; - - my $type = $types[$i]; - $field_data{$field}{sql_type} = $type; - - $field_data{$field}{is_keyfield} = exists $keyfields{$field}; - } - - $result{$table}{fields} = \%field_data; - $result{$table}{field_order} = \@fields; - $result{$table}{keyfield_order} = \@keyfields; - - my $table_sql = $ce->{dbLayout}{$table}{params}{tableOverride}; - $result{$table}{sql_name} = $table_sql || $table; - } - - return %result; -} - -sub get_sql_tables { - my $sql_tables_ref = $dbh->selectcol_arrayref("SHOW TABLES"); - my %sql_tables; @sql_tables{@$sql_tables_ref} = (); - - return %sql_tables; -} - -################################################################################ - -sub check_table { - my ($ww_course_name, $ww_table_name) = @_; - my $sql_table_name = get_sql_table_name($ww_table_data{$ww_table_name}{sql_name}, $ww_course_name); - - verbose("\nChecking '$ww_table_name' table (SQL table '$sql_table_name')\n"); - - if (exists $sql_tables{$sql_table_name}) { - check_fields($ww_course_name, $ww_table_name, $sql_table_name); - delete $sql_tables{$sql_table_name}; - } else { - print "$sql_table_name: table missing\n"; - my $ww_table_rec = $ww_table_data{$ww_table_name}; - if (maybe_add_table($ww_course_name, $ww_table_name)) { - check_fields($ww_course_name, $ww_table_name, $sql_table_name); - delete $sql_tables{$sql_table_name}; - } - } -} - -sub ask_add_table { - my ($ww_course_name, $ww_table_name) = @_; - my $ww_table_rec = $ww_table_data{$ww_table_name}; - my $sql_table_name = get_sql_table_name($ww_table_rec->{sql_name}, $ww_course_name); - - my $stmt = create_table_stmt($ww_table_rec, $sql_table_name); - - print "\nI can add this table to the database with the following SQL statement:\n"; - print "$stmt\n\n"; - print "If this is an upgraded installation, it is possible that '$ww_course_name' is an old GDBM course. If this is the case, you should probably not add this table, as it won't be used.\n"; - return 0 unless ask_permission("Add table '$sql_table_name'?"); - - return unless do_handle_error($dbh, $stmt); - print "Added table '$sql_table_name'.\n\n"; - - return 1; -} - -sub create_table_stmt { - my ($ww_table_rec, $sql_table_name) = @_; - - #print Dumper($ww_table_rec); - - my @field_list; - - # generate a column specification for each field - my @fields = @{$ww_table_rec->{field_order}}; - foreach my $field (@fields) { - my $ww_field_rec = $ww_table_rec->{fields}{$field}; - my $sql_field_name = $ww_field_rec->{sql_name}; - my $sql_field_type = $ww_field_rec->{sql_type}; - - push @field_list, "`$sql_field_name` $sql_field_type"; - } - - # generate an INDEX specification for each all possible sets of keyfields (i.e. 0+1+2, 1+2, 2) - my @keyfields = @{$ww_table_rec->{keyfield_order}}; - foreach my $start (0 .. $#keyfields) { - my @index_components; - - foreach my $component (@keyfields[$start .. $#keyfields]) { - my $ww_field_rec = $ww_table_rec->{fields}{$component}; - my $sql_field_name = $ww_field_rec->{sql_name}; - my $sql_field_type = $ww_field_rec->{sql_type}; - my $length_specifier = ($sql_field_type =~ /int/i) ? "" : "(16)"; - push @index_components, "`$sql_field_name`$length_specifier"; - } - - my $index_string = join(", ", @index_components); - push @field_list, "INDEX ( $index_string )"; - } - - my $field_string = join(", ", @field_list); - my $create_stmt = "CREATE TABLE `$sql_table_name` ( $field_string )"; - - return $create_stmt; -} - -################################################################################ - -sub check_fields { - my ($ww_course_name, $ww_table_name, $sql_table_name) = @_; - - my $describe_data = $dbh->selectall_hashref("DESCRIBE `$sql_table_name`", 1); - - foreach my $ww_field_name (@{$ww_table_data{$ww_table_name}{field_order}}) { - my $ww_field_rec = $ww_table_data{$ww_table_name}{fields}{$ww_field_name}; - my $sql_field_name = $ww_field_rec->{sql_name}; - my $sql_field_rec = $describe_data->{$sql_field_name}; - - verbose("Checking '$ww_field_name' field (SQL field '$sql_table_name.$sql_field_name')\n"); - - #print "$sql_table_name.$sql_field_name:\n"; - #print Dumper($ww_field_rec); - #print Dumper($sql_field_rec); - - if (defined $sql_field_rec) { - my ($sql_base_type) = $sql_field_rec->{Type} =~ /^([^(]*)/; - #print $sql_field_rec->{Type}, " => $sql_base_type\n"; - - my $needs_fixing = 0; - if ($ww_field_name eq "psvn") { - - unless ("int" eq lc($sql_base_type)) { - $needs_fixing = 1; - print "$sql_table_name.$sql_field_name: type should be 'int' but appears to be '", - lc($sql_base_type), "'\n"; - } - - unless (lc($sql_field_rec->{Extra}) =~ /\bauto_increment\b/) { - $needs_fixing = 1; - print "$sql_table_name.$sql_field_name: extra should contain 'auto_increment' but appears to be '", - lc($sql_field_rec->{Extra}), "'\n"; - } - - # FIXME instead of checking this, figure out how to use "SHOW INDEXES FROM `$sql_table_name`" - #unless ("pri" eq lc($sql_field_rec->{Key})) { - # $needs_fixing = 1; - # print "$sql_table_name.$sql_field_name: key should be 'pri' but appears to be '", - # lc($sql_field_rec->{Key}), "'\n"; - #} - - } else { - - unless (lc($ww_field_rec->{sql_type}) eq lc($sql_base_type)) { - $needs_fixing = 1; - print "$sql_table_name.$sql_field_name: type should be '", lc($ww_field_rec->{sql_type}), - "' but appears to be '", lc($sql_base_type), "'\n"; - } - - # FIXME instead of checking this, figure out how to use "SHOW INDEXES FROM `$sql_table_name`" - #unless ( $ww_field_rec->{is_keyfield} == (lc($sql_field_rec->{Key}) eq "mul") ) { - # $needs_fixing = 1; - # print "$sql_table_name.$sql_field_name: key should be '", - # ($ww_field_rec->{is_keyfield} ? "mul" : ""), "' but appears to be '", - # lc($sql_field_rec->{Key}), "'\n"; - #} - } - - $needs_fixing and maybe_change_field($ww_course_name, $ww_table_name, $ww_field_name, $sql_base_type); - - } else { - print "$sql_table_name.$sql_field_name: field missing\n"; - maybe_add_field($ww_course_name, $ww_table_name, $ww_field_name); - } - } -} - -sub ask_add_field { - my ($ww_course_name, $ww_table_name, $ww_field_name) = @_; - my $ww_table_rec = $ww_table_data{$ww_table_name}; - my $sql_table_name = get_sql_table_name($ww_table_rec->{sql_name}, $ww_course_name); - my $sql_field_name = $ww_table_rec->{fields}{$ww_field_name}{sql_name}; - - my $stmt = add_field_stmt($ww_table_rec, $ww_field_name, $sql_table_name); - - print "\nI can add this field to the database with the following SQL statement:\n"; - print "$stmt\n\n"; - return 0 unless ask_permission("Add field '$sql_table_name.$sql_field_name'?"); - - return unless do_handle_error($dbh, $stmt); - print "Added field '$sql_field_name'.\n\n"; - - return 0; -} - -sub add_field_stmt { - my ($ww_table_rec, $ww_field_name, $sql_table_name) = @_; - my $sql_field_name = $ww_table_rec->{fields}{$ww_field_name}{sql_name}; - my $sql_field_type = $ww_table_rec->{fields}{$ww_field_name}{sql_type}; - my $location_modifier = get_location_modifier($ww_table_rec, $ww_field_name); - - return "ALTER TABLE `$sql_table_name` ADD COLUMN `$sql_field_name` $sql_field_type $location_modifier"; -} - -sub get_location_modifier { - my ($ww_table_rec, $ww_field_name) = @_; - - my $field_index = -1; - - for (my $i = 0; $i < @{$ww_table_rec->{field_order}}; $i++) { - if ($ww_table_rec->{field_order}[$i] eq $ww_field_name) { - $field_index = $i; - last; - } - } - - if ($field_index < 0) { - die "field '$ww_field_name' not found in field_order (shouldn't happen!)"; - } elsif ($field_index > 0) { - my $ww_prev_field_name = $ww_table_rec->{field_order}[$field_index-1]; - my $sql_prev_field_name = $ww_table_rec->{fields}{$ww_prev_field_name}{sql_name}; - return "AFTER `$sql_prev_field_name`"; - } else { - return "FIRST"; - } -} - -sub ask_change_field { - my ($ww_course_name, $ww_table_name, $ww_field_name, $sql_curr_base_type) = @_; - my $ww_table_rec = $ww_table_data{$ww_table_name}; - my $sql_table_name = get_sql_table_name($ww_table_rec->{sql_name}, $ww_course_name); - my $sql_field_name = $ww_table_rec->{fields}{$ww_field_name}{sql_name}; - - my @stmts = change_field_stmts($ww_table_rec, $ww_field_name, $sql_table_name, $sql_curr_base_type); - - my $pl = @stmts == 1 ? "" : "s"; - print "\nI can change this field with the following SQL statement$pl:\n"; - print map("$_\n", @stmts), "\n"; - return 0 unless ask_permission("Change field '$sql_table_name.$sql_field_name'?"); - - foreach my $stmt (@stmts) { - return unless do_handle_error($dbh, $stmt); - } - print "Changed field '$sql_field_name'.\n\n"; - - return 0; -} - -sub change_field_stmts { - my ($ww_table_rec, $ww_field_name, $sql_table_name, $sql_curr_base_type) = @_; - my $sql_field_name = $ww_table_rec->{fields}{$ww_field_name}{sql_name}; - my $sql_field_type = $ww_table_rec->{fields}{$ww_field_name}{sql_type}; - - if ($sql_curr_base_type =~ /text/i and $sql_field_type =~ /int/i) { - return ( - "ALTER TABLE `$sql_table_name` CHANGE COLUMN `$sql_field_name` `$sql_field_name` VARCHAR(255)", - "ALTER TABLE `$sql_table_name` CHANGE COLUMN `$sql_field_name` `$sql_field_name` $sql_field_type", - ); - } else { - return "ALTER TABLE `$sql_table_name` CHANGE COLUMN `$sql_field_name` `$sql_field_name` $sql_field_type"; - } -} - -################################################################################ - -sub get_sql_table_name { - my ($template, $course_name) = @_; - - $template =~ s/$random_courseID/$course_name/g; - return $template; -} - -sub ask_permission { - my ($prompt, $default) = @_; - - $default = 1 if not defined $default; - my $options = $default ? "[Y/n]" : "[y/N]"; - - while (1) { - print "$prompt $options "; - my $resp = ; - chomp $resp; - return $default if $resp eq ""; - return 1 if lc $resp eq "y"; - return 0 if lc $resp eq "n"; - $prompt = 'Please enter "y" or "n".'; - } -} - -# no error => returns true -# error, user says continue => returns false -# error, user says don't continue => returns undef -# error, user says exit => exits -sub do_handle_error { - my ($dbh, $stmt) = @_; - - eval { $dbh->do($stmt) }; - if ($@) { - print "SQL statment failed. Here is the error message: $@\n"; - return ask_permission("Continue?", 1); - } else { - return 1; - } -} - -sub compare_dbLayouts { - my ($ce1, $ce2) = @_; - - my $dbLayout1 = $ce1->{dbLayoutName}; - my $dbLayout2 = $ce2->{dbLayoutName}; - #warn "Generic: '$dbLayout1' this course: '$dbLayout2'.\n"; - - # simplisic check for now - if ($dbLayout1 ne $dbLayout2) { - return "\$dbLayoutName differs. Generic: '$dbLayout1' this course: '$dbLayout2'. (If you've created" - . " a modified version of the '$dbLayout1' database layout for use with this course, it's probably" - . " OK to check this course anyway. Just be sure that any fixes this program proposes are" - . " appropriate given your modifications.)"; - } - - return (); -} - -################################################################################ - -sub get_version_zero_ww_table_data { - return ( - 'problem_user' => { - 'fields' => { - 'problem_seed' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'problem_seed' - }, - 'status' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'status' - }, - 'max_attempts' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'max_attempts' - }, - 'value' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'value' - }, - 'last_answer' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'last_answer' - }, - 'source_file' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'source_file' - }, - 'set_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'set_id' - }, - 'problem_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'INT', - 'sql_name' => 'problem_id' - }, - 'num_incorrect' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'num_incorrect' - }, - 'num_correct' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'num_correct' - }, - 'attempted' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'attempted' - }, - 'user_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'user_id' - } - }, - 'keyfield_order' => [ - 'user_id', - 'set_id', - 'problem_id' - ], - 'field_order' => [ - 'user_id', - 'set_id', - 'problem_id', - 'source_file', - 'value', - 'max_attempts', - 'problem_seed', - 'status', - 'attempted', - 'last_answer', - 'num_correct', - 'num_incorrect' - ], - 'sql_name' => '6SC36NukknC3IT3M_problem_user' - }, - 'permission' => { - 'fields' => { - 'permission' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'permission' - }, - 'user_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'user_id' - } - }, - 'keyfield_order' => [ - 'user_id' - ], - 'field_order' => [ - 'user_id', - 'permission' - ], - 'sql_name' => '6SC36NukknC3IT3M_permission' - }, - 'key' => { - 'fields' => { - 'timestamp' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'timestamp' - }, - 'user_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'user_id' - }, - 'key' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'key_not_a_keyword' - } - }, - 'keyfield_order' => [ - 'user_id' - ], - 'field_order' => [ - 'user_id', - 'key', - 'timestamp' - ], - 'sql_name' => '6SC36NukknC3IT3M_key' - }, - 'password' => { - 'fields' => { - 'password' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'password' - }, - 'user_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'user_id' - } - }, - 'keyfield_order' => [ - 'user_id' - ], - 'field_order' => [ - 'user_id', - 'password' - ], - 'sql_name' => '6SC36NukknC3IT3M_password' - }, - 'problem' => { - 'fields' => { - 'problem_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'INT', - 'sql_name' => 'problem_id' - }, - 'max_attempts' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'max_attempts' - }, - 'value' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'value' - }, - 'source_file' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'source_file' - }, - 'set_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'set_id' - } - }, - 'keyfield_order' => [ - 'set_id', - 'problem_id' - ], - 'field_order' => [ - 'set_id', - 'problem_id', - 'source_file', - 'value', - 'max_attempts' - ], - 'sql_name' => '6SC36NukknC3IT3M_problem' - }, - 'user' => { - 'fields' => { - 'email_address' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'email_address' - }, - 'student_id' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'student_id' - }, - 'comment' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'comment' - }, - 'status' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'status' - }, - 'recitation' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'recitation' - }, - 'section' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'section' - }, - 'user_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'user_id' - }, - 'last_name' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'last_name' - }, - 'first_name' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'first_name' - } - }, - 'keyfield_order' => [ - 'user_id' - ], - 'field_order' => [ - 'user_id', - 'first_name', - 'last_name', - 'email_address', - 'student_id', - 'status', - 'section', - 'recitation', - 'comment' - ], - 'sql_name' => '6SC36NukknC3IT3M_user' - }, - 'set_user' => { - 'fields' => { - 'version_time_limit' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'version_time_limit' - }, - 'set_header' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'set_header' - }, - 'psvn' => { - 'is_keyfield' => '', - 'sql_type' => 'INT NOT NULL PRIMARY KEY AUTO_INCREMENT', - 'sql_name' => 'psvn' - }, - 'hardcopy_header' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'hardcopy_header' - }, - 'version_creation_time' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'version_creation_time' - }, - 'open_date' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'open_date' - }, - 'problem_randorder' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'problem_randorder' - }, - 'versions_per_interval' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'versions_per_interval' - }, - 'version_last_attempt_time' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'version_last_attempt_time' - }, - 'time_interval' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'time_interval' - }, - 'set_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'set_id' - }, - 'visible' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'visible' - }, - 'assignment_type' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'assignment_type' - }, - 'due_date' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'due_date' - }, - 'answer_date' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'answer_date' - }, - 'user_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'user_id' - }, - 'attempts_per_version' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'attempts_per_version' - } - }, - 'keyfield_order' => [ - 'user_id', - 'set_id' - ], - 'field_order' => [ - 'user_id', - 'set_id', - 'psvn', - 'set_header', - 'hardcopy_header', - 'open_date', - 'due_date', - 'answer_date', - 'visible', - 'assignment_type', - 'attempts_per_version', - 'time_interval', - 'versions_per_interval', - 'version_time_limit', - 'version_creation_time', - 'problem_randorder', - 'version_last_attempt_time' - ], - 'sql_name' => '6SC36NukknC3IT3M_set_user' - }, - 'set' => { - 'fields' => { - 'version_last_attempt_time' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'version_last_attempt_time' - }, - 'version_time_limit' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'version_time_limit' - }, - 'versions_per_interval' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'versions_per_interval' - }, - 'time_interval' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'time_interval' - }, - 'set_header' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'set_header' - }, - 'set_id' => { - 'is_keyfield' => 1, - 'sql_type' => 'BLOB', - 'sql_name' => 'set_id' - }, - 'hardcopy_header' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'hardcopy_header' - }, - 'visible' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'visible' - }, - 'version_creation_time' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'version_creation_time' - }, - 'due_date' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'due_date' - }, - 'assignment_type' => { - 'is_keyfield' => '', - 'sql_type' => 'TEXT', - 'sql_name' => 'assignment_type' - }, - 'open_date' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'open_date' - }, - 'answer_date' => { - 'is_keyfield' => '', - 'sql_type' => 'BIGINT', - 'sql_name' => 'answer_date' - }, - 'attempts_per_version' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'attempts_per_version' - }, - 'problem_randorder' => { - 'is_keyfield' => '', - 'sql_type' => 'INT', - 'sql_name' => 'problem_randorder' - } - }, - 'keyfield_order' => [ - 'set_id' - ], - 'field_order' => [ - 'set_id', - 'set_header', - 'hardcopy_header', - 'open_date', - 'due_date', - 'answer_date', - 'visible', - 'assignment_type', - 'attempts_per_version', - 'time_interval', - 'versions_per_interval', - 'version_time_limit', - 'version_creation_time', - 'problem_randorder', - 'version_last_attempt_time' - ], - 'sql_name' => '6SC36NukknC3IT3M_set' - } - ); -} diff --git a/bin/old_scripts/wwdb_init b/bin/old_scripts/wwdb_init deleted file mode 100755 index 2cb08b5992..0000000000 --- a/bin/old_scripts/wwdb_init +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - -use strict; -use warnings; -use Getopt::Std; -use DBI; -use Data::Dumper; - -my $pg_dir; -BEGIN { - die "WEBWORK_ROOT not found in environment.\n" unless exists $ENV{WEBWORK_ROOT}; - $pg_dir = $ENV{PG_ROOT} // "$ENV{WEBWORK_ROOT}/../pg"; - die "The pg directory must be defined in PG_ROOT" unless (-e $pg_dir); -} -use lib "$ENV{WEBWORK_ROOT}/lib"; -use lib "$pg_dir/lib"; -use WeBWorK::CourseEnvironment; -use WeBWorK::Utils qw/runtime_use/; - - -our ($opt_v); -getopts("v"); - -if ($opt_v) { - $| = 1; - *verbose = sub { print STDERR @_ }; -} else { - *verbose = sub {}; -} - -# global variables, hah hah. -my ($dbh, %sql_tables); - -################################################################################ - -my $i = -1; -our @DB_VERSIONS; - -$DB_VERSIONS[++$i]{desc} = "is the initial version of database, identical to database structure in WeBWorK 2.2.x."; - -$DB_VERSIONS[++$i]{desc} = "adds dbupgrade table to facilitate automatic database upgrades."; -$DB_VERSIONS[ $i]{global_code} = sub { - $dbh->do("CREATE TABLE `dbupgrade` (`name` VARCHAR(255) NOT NULL PRIMARY KEY, `value` TEXT)"); - $dbh->do("INSERT INTO `dbupgrade` (`name`, `value`) VALUES (?, ?)", {}, "db_version", 1); - $sql_tables{dbupgrade} = (); -}; - - -$DB_VERSIONS[++$i]{desc} = "adds depths table to keep track of dvipng depth information."; -$DB_VERSIONS[ $i]{global_code} = sub { - $dbh->do("CREATE TABLE depths (md5 CHAR(33) NOT NULL, depth SMALLINT, PRIMARY KEY (md5))"); - $sql_tables{depths} = (); -}; - -$DB_VERSIONS[++$i]{desc} = "adds locations, location_addresses, set_locations and set_locations_user tables to database, and add restrict_ip to set and set_user."; -$DB_VERSIONS[ $i]{global_code} = sub { - $dbh->do("CREATE TABLE locations (location_id TINYBLOB NOT NULL, description TEXT, PRIMARY KEY (location_id(1000)))"); - $dbh->do("CREATE TABLE location_addresses (location_id TINYBLOB NOT NULL, ip_mask TINYBLOB NOT NULL, PRIMARY KEY (location_id(500),ip_mask(500)))"); -}; - -our $THIS_DB_VERSION = $i; - -################################################################################ - -my $ce = WeBWorK::CourseEnvironment->new({ - webwork_dir => $ENV{WEBWORK_ROOT}, -}); - -$dbh = DBI->connect( - $ce->{database_dsn}, - $ce->{database_username}, - $ce->{database_password}, - { - PrintError => 0, - RaiseError => 1, - }, -); - -{ - verbose("Obtaining dbupgrade lock...\n"); - my ($lock_status) = $dbh->selectrow_array("SELECT GET_LOCK('dbupgrade', 10)"); - if (not defined $lock_status) { - print "Couldn't obtain lock because an error occurred.\n"; - exit 2; - } - if ($lock_status) { - verbose("Got lock.\n"); - } else { - print "Timed out while waiting for lock.\n"; - exit 2; - } -} - -%sql_tables = get_sql_tables(); - -my $db_version = 0; - - -verbose("Initial db_version is $db_version\n"); - -if ($db_version > $THIS_DB_VERSION) { - print "db_version is $db_version, but the current database version is only $THIS_DB_VERSION. This database was probably used with a newer version of WeBWorK.\n"; - exit; -} - -while ($db_version < $THIS_DB_VERSION) { - $db_version++; - unless (upgrade_to_version($db_version)) { - print "\nUpgrading from version ".($db_version-1)." to $db_version failed.\n\n"; - unless (ask_permission("Ignore this error and go on to the next version?", 0)) { - exit 3; - } - } - set_db_version($db_version); -} - -print "\nDatabase is up-to-date at version $db_version.\n"; - -END { - verbose("Releasing dbupgrade lock...\n"); - my ($lock_status) = $dbh->selectrow_array("SELECT RELEASE_LOCK('dbupgrade')"); - if (not defined $lock_status) { - print "Couldn't release lock because the lock does not exist.\n"; - exit 2; - } - if ($lock_status) { - verbose("Released lock.\n"); - } else { - print "Couldn't release lock because the lock is not held by this thread.\n"; - exit 2; - } -} - -################################################################################ - -sub get_sql_tables { - my $sql_tables_ref = $dbh->selectcol_arrayref("SHOW TABLES"); - my %sql_tables; @sql_tables{@$sql_tables_ref} = (); - - return %sql_tables; -} - -sub set_db_version { - my $vers = shift; - $dbh->do("UPDATE `dbupgrade` SET `value`=? WHERE `name`='db_version'", {}, $vers); -} - -sub upgrade_to_version { - my $vers = shift; - my %info = %{$DB_VERSIONS[$vers]}; - - print "\nUpgrading database from version " . ($vers-1) . " to $vers...\n"; - my $desc = $info{desc} || "has no description."; - print "(Version $vers $desc)\n"; - - if (exists $info{global_code}) { - eval { $info{global_code}->() }; - if ($@) { - print "\nAn error occurred while running the system upgrade code for version $vers:\n"; - print "$@"; - return 0 unless ask_permission("Ignore this error and keep going?", 0); - } - } - print "Done.\n"; - return 1; -} - -################################################################################ - -sub ask_permission { - my ($prompt, $default) = @_; - - $default = 1 if not defined $default; - my $options = $default ? "[Y/n]" : "[y/N]"; - - while (1) { - print "$prompt $options "; - my $resp = ; - chomp $resp; - return $default if $resp eq ""; - return 1 if lc $resp eq "y"; - return 0 if lc $resp eq "n"; - $prompt = 'Please enter "y" or "n".'; - } -} diff --git a/bin/old_scripts/wwdb_upgrade b/bin/old_scripts/wwdb_upgrade deleted file mode 100755 index ad779a2771..0000000000 --- a/bin/old_scripts/wwdb_upgrade +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - -use strict; -use warnings; -use Getopt::Std; -use Data::Dumper; - -my $pg_dir; -BEGIN { - die "WEBWORK_ROOT not found in environment.\n" unless exists $ENV{WEBWORK_ROOT}; - $pg_dir = $ENV{PG_ROOT} // "$ENV{WEBWORK_ROOT}/../pg"; - die "The pg directory must be defined in PG_ROOT" unless (-e $pg_dir); -} -use lib "$ENV{WEBWORK_ROOT}/lib"; -use lib "$pg_dir/lib"; -use WeBWorK::CourseEnvironment; -use WeBWorK::Utils::DBUpgrade; - -our ($opt_v); -getopts("v"); - -if ($opt_v) { - $WeBWorK::Debug::Enabled = 1; -} else { - $WeBWorK::Debug::Enabled = 0; -} - -my $ce = new WeBWorK::CourseEnvironment({webwork_dir=>$ENV{WEBWORK_ROOT}}); - -my $upgrader = new WeBWorK::Utils::DBUpgrade( - ce => $ce, - verbose_sub => sub { print STDERR @_ }, -); - -$upgrader->do_upgrade; - diff --git a/bin/remove_stale_images b/bin/remove_stale_images index 0369d85386..b5a7064e17 100755 --- a/bin/remove_stale_images +++ b/bin/remove_stale_images @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 diff --git a/bin/restore-OPL-tables.pl b/bin/restore-OPL-tables.pl index ecbe022774..c99babd39d 100755 --- a/bin/restore-OPL-tables.pl +++ b/bin/restore-OPL-tables.pl @@ -1,20 +1,5 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - # This script restores the OPL library tables from a dump file. use strict; @@ -22,7 +7,7 @@ BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } diff --git a/bin/setfilepermissions b/bin/setfilepermissions index c9c3eb4c54..9b428cb6c1 100755 --- a/bin/setfilepermissions +++ b/bin/setfilepermissions @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 diff --git a/bin/test_library_build.pl b/bin/test_library_build.pl index 7952b810dd..b7b7dc9f96 100755 --- a/bin/test_library_build.pl +++ b/bin/test_library_build.pl @@ -2,7 +2,7 @@ BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } diff --git a/bin/update-OPL-statistics.pl b/bin/update-OPL-statistics.pl index 1342477597..b772b155b5 100755 --- a/bin/update-OPL-statistics.pl +++ b/bin/update-OPL-statistics.pl @@ -1,25 +1,10 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - use strict; BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } @@ -30,7 +15,6 @@ BEGIN use String::ShellQuote; use DBI; -use WeBWorK::Utils::CourseIntegrityCheck; use WeBWorK::Utils::CourseManagement qw/listCourses/; my $time = time(); diff --git a/bin/updateOPLextras.pl b/bin/updateOPLextras.pl index 93d74cc976..31151eb904 100755 --- a/bin/updateOPLextras.pl +++ b/bin/updateOPLextras.pl @@ -71,7 +71,7 @@ =head1 DESCRIPTION BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } diff --git a/bin/upgrade-database-to-utf8mb4.pl b/bin/upgrade-database-to-utf8mb4.pl index 24240fbfb5..2d99c84ea6 100755 --- a/bin/upgrade-database-to-utf8mb4.pl +++ b/bin/upgrade-database-to-utf8mb4.pl @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 @@ -126,7 +112,7 @@ =head1 OPTIONS BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } @@ -165,7 +151,7 @@ BEGIN my $dbuser = shell_quote($ce->{database_username}); my $dbpass = $ce->{database_password}; -$ENV{'MYSQL_PWD'} = $dbpass; +local $ENV{MYSQL_PWD} = $dbpass; if (!$no_backup) { # Backup the database @@ -212,7 +198,7 @@ BEGIN }, ); -my $db = new WeBWorK::DB($ce->{dbLayouts}{ $ce->{dbLayoutName} }); +my $db = WeBWorK::DB->new($ce); my @table_types = sort(grep { !$db->{$_}{params}{non_native} } keys %$db); sub checkAndUpdateTableColumnTypes { @@ -222,29 +208,30 @@ sub checkAndUpdateTableColumnTypes { print "\tChecking '$table' (pass $pass)\n" if $verbose; my $schema_field_data = $db->{$table_type}{record}->FIELD_DATA; - for my $field (keys %$schema_field_data) { - my $field_name = $db->{$table_type}{params}{fieldOverride}{$field} || $field; - my @name_type = @{ + for my $field_name (keys %$schema_field_data) { + my @name_type = @{ $dbh->selectall_arrayref( "SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS " . "WHERE TABLE_SCHEMA='$dbname' AND TABLE_NAME='$table' AND COLUMN_NAME='$field_name';" ) }; - print("\t\tThe '$field_name' column is missing from '$table'.\n" - . "\t\tYou should upgrade the course via course administration to fix this.\n" - . "\t\tYou may need to run this script again after doing that.\n"), next - if !exists($name_type[0][0]); + if (!exists($name_type[0][0])) { + print("\t\tThe '$field_name' column is missing from '$table'.\n" + . "\t\tYou should upgrade the course via course administration to fix this.\n" + . "\t\tYou may need to run this script again after doing that.\n"); + next; + } my $data_type = $name_type[0][0]; next if !$data_type; $data_type =~ s/\(\d*\)$// if $data_type =~ /^(big|small)?int\(\d*\)$/; $data_type = lc($data_type); - my $schema_data_type = lc($schema_field_data->{$field}{type} =~ s/ .*$//r); + my $schema_data_type = lc($schema_field_data->{$field_name}{type} =~ s/ .*$//r); if ($data_type ne $schema_data_type) { print "\t\tUpdating data type for column '$field_name' in table '$table'\n" if $verbose; print "\t\t\t$data_type -> $schema_data_type\n" if $verbose; - eval { $dbh->do("ALTER TABLE `$table` MODIFY $field_name $schema_field_data->{$field}{type};"); }; + eval { $dbh->do("ALTER TABLE `$table` MODIFY $field_name $schema_field_data->{$field_name}{type};"); }; my $indent = $verbose ? "\t\t" : ""; die("${indent}Failed to modify '$field_name' in '$table' from '$data_type' to '$schema_data_type.\n" . "${indent}It is recommended that you restore a database backup. Make note of the\n" @@ -286,8 +273,10 @@ sub checkAndChangeTableCharacterSet { my $error = 0; for my $course (@courses) { - print("The course '$course' does not exist on the server\n"), next - if !grep($course eq $_, @server_courses); + if (!grep { $course eq $_ } @server_courses) { + print("The course '$course' does not exist on the server\n"); + next; + } print "Checking tables for '$course'\n" if $verbose; for my $table_type (@table_types) { diff --git a/bin/upgrade_admin_db.pl b/bin/upgrade_admin_db.pl index 470a6f4306..505a24e2f4 100755 --- a/bin/upgrade_admin_db.pl +++ b/bin/upgrade_admin_db.pl @@ -1,22 +1,8 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } @@ -24,40 +10,31 @@ BEGIN use lib "$ENV{WEBWORK_ROOT}/lib"; use WeBWorK::CourseEnvironment; - use WeBWorK::DB; -use WeBWorK::Utils::CourseIntegrityCheck; +use WeBWorK::Utils::CourseDBIntegrityCheck; -########################## -# update admin course -########################## +# Update admin course my $ce = WeBWorK::CourseEnvironment->new({ webwork_dir => $ENV{WEBWORK_ROOT} }); my $upgrade_courseID = $ce->{admin_course_id}; $ce = WeBWorK::CourseEnvironment->new({ webwork_dir => $ENV{WEBWORK_ROOT}, courseName => $upgrade_courseID, }); -#warn "do_upgrade_course: updating |$upgrade_courseID| from" , join("|",@upgrade_courseIDs); -############################################################################# -# Create integrity checker -############################################################################# +# Create integrity checker my @update_report; -my $CIchecker = new WeBWorK::Utils::CourseIntegrityCheck(ce => $ce); +my $CIchecker = new WeBWorK::Utils::CourseDBIntegrityCheck($ce); -############################################################################# # Add missing tables and missing fields to existing tables -############################################################################# - my ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($upgrade_courseID); my @schema_table_names = keys %$dbStatus; # update tables missing from database; my @tables_to_create = - grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_A() } @schema_table_names; + grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_A() } @schema_table_names; my @tables_to_alter = - grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseIntegrityCheck::DIFFER_IN_A_AND_B() } @schema_table_names; + grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseDBIntegrityCheck::DIFFER_IN_A_AND_B() } @schema_table_names; push(@update_report, $CIchecker->updateCourseTables($upgrade_courseID, [@tables_to_create])); -foreach my $table_name (@tables_to_alter) -{ #warn "do_upgrade_course: adding new fields to table $table_name in course $upgrade_courseID"; + +for my $table_name (@tables_to_alter) { push(@update_report, $CIchecker->updateTableFields($upgrade_courseID, $table_name)); } diff --git a/bin/upload-OPL-statistics.pl b/bin/upload-OPL-statistics.pl index 51cd9a7f94..928b040682 100755 --- a/bin/upload-OPL-statistics.pl +++ b/bin/upload-OPL-statistics.pl @@ -1,25 +1,10 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - # This script dumps the local OPL statistics table and uploads it. BEGIN { use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); + use Env qw(WEBWORK_ROOT); $WEBWORK_ROOT = curfile->dirname->dirname; } diff --git a/bin/webwork2 b/bin/webwork2 index 5ce0c60d93..48ba93bd16 100755 --- a/bin/webwork2 +++ b/bin/webwork2 @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ use strict; use warnings; diff --git a/bin/ww_purge_old_nonces b/bin/ww_purge_old_nonces index 7b99b14dc6..d8ee043bd7 100755 --- a/bin/ww_purge_old_nonces +++ b/bin/ww_purge_old_nonces @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 @@ -72,7 +58,7 @@ my $ce = WeBWorK::CourseEnvironment->new({ courseName => $course, }); -my $db = WeBWorK::DB->new($ce->{dbLayout}); +my $db = WeBWorK::DB->new($ce); my @errors; diff --git a/bin/wwdb b/bin/wwdb deleted file mode 100755 index a739890798..0000000000 --- a/bin/wwdb +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 - -wwdb - export and import webwork databases. - -=head1 SYNOPSIS - - wwdb [-f] course { import | export } file [table ...] - -=head1 DESCRIPTION - -Exports data from a course database to an XML file, or imports data from an XML -file to a course database. Optionally restrict which tables are imported or -exported and specify a duplicate policy. - -=head1 OPTIONS - -=over - -=item -f - -Overwite duplicate records. - -=item course - -Course to use for import or export. - -=item { import | export } - -Specify action -- export or import data. - -=item file - -XML file to write to (in the case of export) or read from (in the case of -import). - -=item [table ...] - -If specified, only the listed tables will be imported or exported. - -=back - -=cut - -use strict; -use warnings; -use Getopt::Std; - -BEGIN { - use Mojo::File qw(curfile); - use Env qw(WEBWORK_ROOT); - - $WEBWORK_ROOT = curfile->dirname->dirname; -} - -use lib "$ENV{WEBWORK_ROOT}/lib"; - -use WeBWorK::CourseEnvironment; -use WeBWorK::DB; -use WeBWorK::Utils::DBImportExport qw/listTables dbExport dbImport/; - -sub usage { - print STDERR "usage: $0 [-f] course { import | export } file [table ...]\n"; - print STDERR "tables: ", join(" ", listTables()), "\n"; - exit 1; -} - -our $opt_f; -getopts("f"); - -my ($course, $command, $file, @tables) = @ARGV; - -usage() unless $course and $command and $file; - -my $ce = WeBWorK::CourseEnvironment->new({ - webwork_dir => $ENV{WEBWORK_ROOT}, - courseName => $course, -}); - -my $db = WeBWorK::DB->new($ce->{dbLayout}); - -my @errors; - -if ($command eq "export") { - my $fh; - if ($file eq "-") { - $fh = *STDOUT; - } else { - open $fh, ">", $file or die "failed to open file '$file' for writing: $!\n"; - } - @errors = dbExport( - db => $db, - xml => $fh, - tables => \@tables, - ); - close $fh; -} elsif ($command eq "import") { - my $conflict = ($opt_f ? "replace" : "skip"); - open my $fh, "<", $file or die "failed to open file '$file' for writing: $!\n"; - @errors = dbImport( - db => $db, - xml => $fh, - tables => \@tables, - conflict => $conflict, - ); - close $fh; -} else { - die "$command: unrecognized command.\n"; -} - -if (@errors) { - warn "The following errors occurred:\n", map { "* $_\n" } @errors; - exit 1; -} diff --git a/bin/wwsh b/bin/wwsh index b5d4eb32d2..e55e969305 100755 --- a/bin/wwsh +++ b/bin/wwsh @@ -1,18 +1,4 @@ #!/usr/bin/env perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 @@ -54,7 +40,7 @@ $ce = WeBWorK::CourseEnvironment->new({ }); -$db = WeBWorK::DB->new($ce->{dbLayout}); +$db = WeBWorK::DB->new($ce); print <<'EOF'; wwsh - The WeBWorK Shell diff --git a/conf/README.md b/conf/README.md index 4ef53a321d..45ebac0def 100644 --- a/conf/README.md +++ b/conf/README.md @@ -16,8 +16,6 @@ Basic webwork2 configuration files. - `localOverrides.conf.dist` should be copied to `localOverrides.conf`. `localOverrides.conf` will be read after the `defaults.config` file is processed and will overwrite configurations in `defaults.config`. Use this file to make changes to the settings in `defaults.config`. -- `database.conf.dist` contains database configuration parameters. It is included by `defaults.config`. This file - should not be copied or modified unless you really know what you are doing. Configuration extension files. diff --git a/conf/authen_CAS.conf.dist b/conf/authen_CAS.conf.dist index 9c968d0717..4095283e96 100644 --- a/conf/authen_CAS.conf.dist +++ b/conf/authen_CAS.conf.dist @@ -8,9 +8,7 @@ ######################################################################################## # Set CAS as the authentication module to use. -$authen{user_module} = { - "*" => "WeBWorK::Authen::CAS", -}; +$authen{user_module} = 'WeBWorK::Authen::CAS'; # List of authentication modules that may be used to enter the admin course. # This is used instead of $authen{user_module} when logging into the admin course. diff --git a/conf/authen_LTI.conf.dist b/conf/authen_LTI.conf.dist index 8e901ece14..20196f4f49 100644 --- a/conf/authen_LTI.conf.dist +++ b/conf/authen_LTI.conf.dist @@ -40,9 +40,9 @@ $debug_lti_grade_passback = 0; # the LTIAdvantage will be used. If you know a site will not use one or the other, it can be # commented out. Failover to Basic_TheLastOption is necessary to authenticate with cookie keys. $authen{user_module} = [ - { '*' => 'WeBWorK::Authen::LTIAdvantage' }, # first try LTI 1.3 - { '*' => 'WeBWorK::Authen::LTIAdvanced' }, # next try LTI 1.1 - { '*' => 'WeBWorK::Authen::Basic_TheLastOption' } # fallback authorization method + 'WeBWorK::Authen::LTIAdvantage', # first try LTI 1.3 + 'WeBWorK::Authen::LTIAdvanced', # next try LTI 1.1 + 'WeBWorK::Authen::Basic_TheLastOption' # fallback authorization method ]; # List of authentication modules that may be used to enter the admin course. @@ -135,19 +135,84 @@ $LTIGradeMode = ''; #$LTIGradeMode = 'course'; #$LTIGradeMode = 'homework'; -# When set this variable sends grades back to the LMS every time a user submits an answer. This -# keeps students grades up to date but can be a drain on the server. -$LTIGradeOnSubmit = 1; +# There are several controls for when to report scores to the LMS. Sometimes these controls +# interact with each other, and the details of how they work may depend on whether $LTIGradeMode +# is set to 'course' or 'homework'. So it is recommended to understand all of them and then +# decide how to set them. + +# If $LTICheckPrior is 1, then any time WeBWorK is about to send a score to the LMS, it will +# first request from the LMS what that score currently is. Then if there is no significant +# difference between the LMS score and the WeBWorK score, WeBWorK will not follow through with +# updating the LMS score. This is to avoid frequent insignificant updates to a student's scores +# in the LMS. With some LMSs, students may receive notifications each time a score is updated, +# and setting this variable will prevent too many notifications for them. This does create a +# two-phase process, first querying the current score from the LMS and then actually updating +# the score (if there is a significant difference). -# If $LTICheckPrior is set to 1 then the current LMS grade will be checked first, and if the grade -# has not changed then the grade will not be updated. This is intended to reduce changes to LMS -# records when no real grade change occurred. It requires a 2 round process, first querying the -# current grade from the LMS and then when needed making the grade submission. +# Additional details: +# - If the LMS score is not 100%, but the WeBWorK score is, then even if the LMS score is only +# insignificantly less than 100%, it will be updated anyway. +# - If the LMS score is null and the WeBWorK score is 0, this is considered an insignificant +# difference and the LMS score will not be updated to 0. However if it is after the +# $LTISendScoresAfterDate (described below), then the null score will be updated to 0 anyway. +# - "Significant" means an absolute difference of 0.001, or 0.1%. At this time this is not +# configurable. $LTICheckPrior = 0; -# The system periodically updates student grades on the LMS. This variable controls how often -# that happens. Set to -1 to disable. -$LTIMassUpdateInterval = 86400; #in seconds +# If $LTIGradeOnSubmit is set to 1, then each time a user submits an answer or scores a test, +# that will trigger WeBWorK possibly reporting a score to the LMS. See $LTICheckPrior for one +# reason that WeBWorK might not ultimately send a score. But there are other reasons too. +# WeBWorK will send the score (the assignment's score if $LTIGradeMode is 'homework' or the +# overall course score if $LTIGradeMode is 'course') to the LMS only if either the assignment's +# $LTISendGradesEarlyThreshold (described below) has been met or if it is past that assignment's +# $LTISendScoresAfterDate (also described below). +$LTIGradeOnSubmit = 1; + +# In addition to scores possibly being sent to the LMS upon submission, they can be sent by an +# instructor or admin user using the LTI Grades Update Tool. And thirdly, the system can +# periodically update student scores on the LMS on its own. For all three possible triggers for +# scores to be passed to the LMS, $LTISendScoresAfterDate and $LTISendGradesEarlyThreshold can +# affect what is sent. $LTISendScoresAfterDate can be 'open_date', 'reduced_scoring_date', +# 'due_date', 'answer_date', or 'never'. For a given assignment, if it is after the +# $LTISendScoresAfterDate, then WeBWorK will send scores. If $LTISendScoresAfterDate is 'never', +# then there is no date after which WeBWorK is guaranteed to send scores. In that case, scores +# are only sent when a set's $LTISendGradesEarlyThreshold is met (see below). +# - For 'course' grade passback mode, the assignment will be included in the overall course +# grade calculation. +# - For 'homework' grade passback mode, the assignment's score will be sent. + +# If $LTISendScoresAfterDate is 'reduced_scoring_date' and an assignment has no reduced scoring +# date or reduced scoring is disabled for that assignment, the fallback is to use the due date. + +# For a given assignment, if $LTISendScoresAfterDate is 'never' or if it is before the date +# specified by $LTISendScoresAfterDate, WeBWorK may send a score to the LMS depending on the +# value of $LTISendGradesEarlyThreshold. This variable can either be the string 'attempted' or a +# number from 0 to 1. If this variable is 'attempted', a given set must have been attempted for +# the threshold to have been met, and then the score can be used even if it is before the +# $LTISendScoresAfterDate. For a non-test set, 'attempted' just means that some exercise in the +# set was attempted using the Submit button. For a test, 'attempted' means that either there is +# one version with a graded submission, or there are at least two versions. + +# If $LTISendGradesEarlyThreshold is a number from 0 to 1, the score for an assignment needs to +# have reached that number for the threshold to be met, and then the score can be used even if +# it is before the $LTISendScoresAfterDate. + +#$LTISendScoresAfterDate = 'open_date'; +$LTISendScoresAfterDate = 'reduced_scoring_date'; +#$LTISendScoresAfterDate = 'due_date'; +#$LTISendScoresAfterDate = 'answer_date'; +#$LTISendScoresAfterDate = 'never'; + +$LTISendGradesEarlyThreshold = 'attempted'; +#$LTISendGradesEarlyThreshold = 0; +#$LTISendGradesEarlyThreshold = 0.7; +#$LTISendGradesEarlyThreshold = 1; + +# The system periodically updates student scores on the LMS. If it has been at least this many +# seconds since the last mass passback event and someone in the course does anything to load a +# page, then a new mass passback job will begin. Set this to -1 to disable mass passback. +$LTIMassUpdateInterval = 86400; + ################################################################################################ # Add an 'LTI' tab to the Course Configuration page @@ -170,7 +235,10 @@ $LTIMassUpdateInterval = 86400; #in seconds #'LTI{v1p3}{LMS_url}', #'external_auth', #'LTIGradeMode', + #'LTICheckPrior', #'LTIGradeOnSubmit', + #'LTISendScoresAfterDate', + #'LTISendGradesEarlyThreshold', #'LTIMassUpdateInterval', #'LMSManageUserData', #'LTI{v1p1}{BasicConsumerSecret}', diff --git a/conf/authen_LTI_1_1.conf.dist b/conf/authen_LTI_1_1.conf.dist index e260eae69d..c2c8bb7f62 100644 --- a/conf/authen_LTI_1_1.conf.dist +++ b/conf/authen_LTI_1_1.conf.dist @@ -129,7 +129,7 @@ $LTI{v1p1}{BasicConsumerSecret} = ''; # The consumer key is entered in the LMS request form, and needs to match the entry here if this # is set. This is only used for content item selection requests from an LMS, and this does not # even need to be set for that unless there are multiple courses from different LMS's that have -# the same LMS course id and both use a course on this webwork2 server. In that case each LMS +# the same LMS context id and both use a course on this webwork2 server. In that case each LMS # must use a different consumer key, and the correct consumer keys should be set in the # course.conf file for each course. If this server is a tool provider for multiple LMS's, then # it is recommended that this be set. Usually it is not useful to set this here. However, if @@ -227,7 +227,4 @@ $LTI{v1p1}{LMSrolesToWeBWorKroles} = { # $userSet->answer_date($niceAnswerTime); #}; -# Do not change this. -$LTI{v1p1}{grader} = 'WeBWorK::Authen::LTIAdvanced::SubmitGrade'; - 1; # final line of the file to reassure perl that it was read properly. diff --git a/conf/authen_LTI_1_3.conf.dist b/conf/authen_LTI_1_3.conf.dist index 60e2cf0b31..823155e1cc 100644 --- a/conf/authen_LTI_1_3.conf.dist +++ b/conf/authen_LTI_1_3.conf.dist @@ -144,6 +144,12 @@ $LTI{v1p3}{LMSrolesToWeBWorKroles} = { 'Grader' => 'ta', }; +# The LMS reports roles context (or membership), instititution, and system +# roles. WeBWorK always ignores system roles, and also ignores institution +# roles by default. In some cases you may also want to consider institution +# roles. In that case set the following to 1. +$LTI{v1p3}{AllowInstitutionRoles} = 0; + ################################################################################################ # Local routine to modify users ################################################################################################ @@ -189,7 +195,18 @@ $LTI{v1p3}{LMSrolesToWeBWorKroles} = { # $userSet->answer_date($niceAnswerTime); #}; -# Do not change this. -$LTI{v1p3}{grader} = 'WeBWorK::Authen::LTIAdvantage::SubmitGrade'; +################################################################################################ +# Miscellaneous +################################################################################################ + +# When grade passback mode is 'homework', someone must use a set-specific link from the LMS in +# order for grade passback to begin happening for that set. Use of the set-specific link lets +# WeBWorK store the set's "sourced_ID". So if there is no sourced_ID, the default behavior is +# that a user in WeBWorK sees the sets as disabled and there is a message about needing to +# access the set from the LMS. The following option can be set to allow users to work on the set +# anyway. There will be no grade passback until some later time when an LMS user clicks the +# set-specific link. In some LMSs, it is possible for the instructor to activate the link. + +$LTI{v1p3}{ignoreMissingSourcedID} = 0; 1; # final line of the file to reassure perl that it was read properly. diff --git a/conf/authen_ldap.conf.dist b/conf/authen_ldap.conf.dist index 6dbb1f2895..45cc46179a 100644 --- a/conf/authen_ldap.conf.dist +++ b/conf/authen_ldap.conf.dist @@ -8,7 +8,7 @@ ######################################################################################## # Set LDAP as the authentication module to use. -$authen{user_module} = { "*" => "WeBWorK::Authen::LDAP" }; +$authen{user_module} = 'WeBWorK::Authen::LDAP'; # List of authentication modules that may be used to enter the admin course. # This is used instead of $authen{user_module} when logging into the admin course. diff --git a/conf/authen_saml2.conf.dist b/conf/authen_saml2.conf.dist new file mode 100644 index 0000000000..a4403134a1 --- /dev/null +++ b/conf/authen_saml2.conf.dist @@ -0,0 +1,137 @@ +#!perl +################################################################################ +# Configuration for using Saml2 authentication. +# To enable Saml2 authentication, copy this file to conf/authen_saml2.conf +# and uncomment the appropriate lines in localOverrides.conf. The Saml2 +# authentication module uses the Net::SAML2 library. The library claims to be +# compatible with a wide range of SAML2 implementations, including Shibboleth. +################################################################################ + +# Set Saml2 as the authentication module to use. +# Comment out 'WeBWorK::Authen::Basic_TheLastOption' if bypassing Saml2 +# authentication is not allowed (see $saml2{bypass_query} below). +$authen{user_module} = [ + 'WeBWorK::Authen::Saml2', + 'WeBWorK::Authen::Basic_TheLastOption' +]; + +# List of authentication modules that may be used to enter the admin course. +# This is used instead of $authen{user_module} when logging into the admin +# course. Since the admin course provides overall power to add/delete courses, +# access to this course should be protected by the best possible authentication +# you have available to you. +$authen{admin_module} = [ + 'WeBWorK::Authen::Saml2' +]; + +# This URL query parameter can be added to the end of a course url to skip the +# saml2 authentication module and go to the next one, for example, +# http://your.school.edu/webwork2/courseID?bypassSaml2=1. Comment out the next +# line to disable this feature. +$saml2{bypass_query} = 'bypassSaml2'; + +# Note that Saml2 authentication can be used in conjunction with webwork's two +# factor authentication. If the identity provider does not provide two factor +# authentication, then it is recommended that you DO use webwork's two factor +# authentication. If the identity provider does provide two factor +# authentication, then you would not want your users need to perform two factor +# authentication twice, so you should disable webwork's two factor +# authentication. The two factor authentication settings are set in +# localOverrides.conf. + +# As noted above, if the identity provider offers two factor authentication, +# then you would not want webwork2's two factor authentication to be used at the +# same time. However, if the bypass parameter is allowed, you should still +# enable two factor authentication in that case. If this is the case, then set +# $saml2{twoFAOnlyWithBypass} to 1. This will skip webwork2's two factor +# authentication for users signing in via the identity provider, but still +# require it for users signing in with a username/password. If this is set to 0, +# then webwork2's two factor authentication will always be required. +$saml2{twoFAOnlyWithBypass} = 0; + +# If $external_auth is 1, and the authentication sequence reaches +# Basic_TheLastOption, then the webwork login screen will show a message +# directing the user to use the external authentication system to login. This +# prevents users from attempting to login in to WeBWorK directly. +$external_auth = 0; + +# The $saml2{idps} hash contains names of identity proviers and their SAML2 +# metadata URLs that are used by this server. Webwork will request the identity +# provider's metadata from the URL of the $saml2{active_idp} during the +# authentication process. Additional identity providers can also be added for a +# particular course by adding, for example, $saml2{idps}{other_idp} = '...' to +# the course.conf file of the course. Note that the names of the identity +# providers in this hash are used for a directory name in which the metadata and +# certificate for the identity provider are saved. So the names should only +# contain alpha numeric characters and underscores. +$saml2{idps} = { + default => 'http://idp/simplesaml/module.php/saml/idp/metadata', + # Add additional identity providers used by this server below. + #other_idp => 'http://other.idp.server/metadata', +}; + +# The $saml2{active_idp} is the identity provider in the $saml2{idps} hash that +# will be used. If different identity providers are used for different courses, +# then set $saml2{active_idp} = 'other_idp' in the course.conf file of each +# course. +$saml2{active_idp} = 'default'; + +# This the id for the webwork2 service provider. This is usually the application +# root URL plus the base path to the service provider. +$saml2{sp}{entity_id} = 'http://localhost:8080/webwork2/saml2'; + +# This is the organization metadata information for the webwork2 service +# provider. The Saml2 authentication module will generate xml metadata that can +# be obtained by the identity provider for configuration from the URL +# https://webwork.yourschool.edu/webwork2/saml2/metadata if Saml2 authentication +# is enabled site wide. The URL needs to have the courseID URL parameter added +# if Saml2 authentication is not enabled site wide, but is enabled for some +# courses in those course's course.conf files. So for example if one course is +# myTestCourse, then the metadata URL would be +# https://webwork.yourschool.edu/webwork2/saml2/metadata?courseID=myTestCourse +# Further note that if multiple courses use that same identity provider then +# just pick any one of the courses to use in the metadata URL. All of the other +# courses share the same metedata. +$saml2{sp}{org} = { + contact => 'webwork@example.edu', + name => 'webwork', + url => 'https://localhost:8080/', + display_name => 'WeBWorK' +}; + +# The following list of attributes will be checked in the given order for a +# matching user in the webwork2 course. If no attributes are given, then +# webwork2 will default to the NameID. It is recommended that you use the +# attribute's OID. +$saml2{sp}{attributes} = [ + 'urn:oid:0.9.2342.19200300.100.1.1' +]; + +# The following settings are the locations of the files that contain the +# certificate and private key for the webwork2 service provider. A certificate +# and private key can be generated using openssl. For example, +# openssl req -newkey rsa:3072 -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.pem +# The files saml.crt and saml.pem that are generated contain the public +# "certificate" and the "private_key", respectively. +# Note that if the files are placed within the root webwork2 app directory, then +# the paths may be given relative to the root webwork2 app directory. Otherwise +# the absolute path must be given. Make sure that the webwork2 app has read +# permissions for those files. +$saml2{sp}{certificate_file} = 'docker-config/idp/certs/saml.crt'; +$saml2{sp}{private_key_file} = 'docker-config/idp/certs/saml.pem'; + +############################################################################## +# SECURITY WARNING +# For production, you MUST provide your own unique 'certificate' and +# 'private_key' files. The files referred to in the default settings above are +# only intended to be used in development, and are publicly exposed. Hence, they +# provide NO SECURITY. +############################################################################## + +# If this is set to 1, then service provider initiated logout from the identity +# provider is enabled. This means that when the user clicks the webwork2 "Log +# Out" button, a request is sent to the identity provider that also ends the +# session for the user with the identity provider. +$saml2{sp}{enable_sp_initiated_logout} = 0; + +1; diff --git a/conf/database.conf.dist b/conf/database.conf.dist deleted file mode 100644 index eeda93d23e..0000000000 --- a/conf/database.conf.dist +++ /dev/null @@ -1,358 +0,0 @@ -#!perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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 - -database.conf - define standard database layouts - -=head1 SYNOPSIS - -In defaults.config: - - include "conf/database.conf"; - *dbLayout = $dbLayouts{layoutName}; - -=head1 DESCRIPTION - -This file contains definitions for the commonly-used database layouts. Database -layouts consist of all the information necessary to describe how to access data -used by WeBWorK. For more information on the format of a database layout, -consult the documentation for the WeBWorK::DB module. - -A database layout is selected from the list of possible layouts by adding a -line like the one below to the F or F file. - - $dbLayoutName = "layoutName"; - *dbLayout = $dbLayouts{$dbLayoutName}; - -=head2 THE SQL_SINGLE DATABASE LAYOUT - -The C layout is similar to the C layout, except that it uses a -single database for all courses. This is accomplished by prefixing each table -name with the name of the course. The names and passwords of these accounts are -given as parameters to each table in the layout. - - username the username to use when connecting to the database - password the password to use when connecting to the database - -Be default, username is "webworkRead" and password is "". It is not recommended -that you use only a non-empty password to secure database access. Most RDBMSs -allow IP-based authorization as well. As the system administrator, IT IS YOUR -RESPONSIBILITY TO SECURE DATABASE ACCESS. - -Don't confuse the account information above with the accounts of the users of a -course. This is a system-wide account which allow WeBWorK to talk to the -database server. - -Other parameters that can be given are as follows: - - tableOverride an alternate name to use when referring to the table (used - when a table name is a reserved word) - debug if true, SQL statements are printed before being executed - -=cut - -# params common to all tables - -my %sqlParams = ( - username => $database_username, - password => $database_password, - debug => $database_debug, - # kinda hacky, but needed for table dumping - mysql_path => $externalPrograms{mysql}, - mysqldump_path => $externalPrograms{mysqldump}, -); - -if ($ce->{database_driver} =~ /^mysql$/i) { - # The extra UTF8 connection setting is ONLY needed for older DBD:mysql driver - # and forbidden by the newer DBD::MariaDB driver - if ($ENABLE_UTF8MB4) { - $sqlParams{mysql_enable_utf8mb4} = 1; # Full 4-bit UTF-8 - } else { - $sqlParams{mysql_enable_utf8} = 1; # Only the partial 3-bit mySQL UTF-8 - } -} - -%dbLayouts = (); # layouts are added to this hash below - -$dbLayouts{sql_single} = { - locations => { - record => "WeBWorK::DB::Record::Locations", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, non_native => 1 }, - }, - location_addresses => { - record => "WeBWorK::DB::Record::LocationAddresses", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, non_native => 1 }, - }, - depths => { - record => "WeBWorK::DB::Record::Depths", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - params => { %sqlParams, non_native => 1 }, - }, - lti_launch_data => { - record => "WeBWorK::DB::Record::LTILaunchData", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, non_native => 1 }, - }, - lti_course_map => { - record => "WeBWorK::DB::Record::LTICourseMap", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, non_native => 1 }, - }, - password => { - record => "WeBWorK::DB::Record::Password", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_password" }, - }, - permission => { - record => "WeBWorK::DB::Record::PermissionLevel", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_permission" }, - }, - key => { - record => "WeBWorK::DB::Record::Key", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_key" }, - }, - user => { - record => "WeBWorK::DB::Record::User", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_user" }, - }, - set => { - record => "WeBWorK::DB::Record::Set", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_set" }, - }, - set_user => { - record => "WeBWorK::DB::Record::UserSet", - schema => "WeBWorK::DB::Schema::NewSQL::NonVersioned", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_set_user" }, - }, - set_merged => { - record => "WeBWorK::DB::Record::UserSet", - schema => "WeBWorK::DB::Schema::NewSQL::Merge", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - depend => [qw/set_user set/], - params => { - %sqlParams, - non_native => 1, - merge => [qw/set_user set/], - }, - }, - set_version => { - record => "WeBWorK::DB::Record::SetVersion", - schema => "WeBWorK::DB::Schema::NewSQL::Versioned", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - params => { - %sqlParams, - non_native => 1, - tableOverride => "${courseName}_set_user", - - }, - }, - set_version_merged => { - record => "WeBWorK::DB::Record::SetVersion", - schema => "WeBWorK::DB::Schema::NewSQL::Merge", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - depend => [qw/set_version set_user set/], - params => { - %sqlParams, - non_native => 1, - merge => [qw/set_version set_user set/], - }, - }, - set_locations => { - record => "WeBWorK::DB::Record::SetLocations", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_set_locations" }, - }, - set_locations_user => { - record => "WeBWorK::DB::Record::UserSetLocations", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_set_locations_user" }, - }, - problem => { - record => "WeBWorK::DB::Record::Problem", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_problem" }, - }, - problem_user => { - record => "WeBWorK::DB::Record::UserProblem", - schema => "WeBWorK::DB::Schema::NewSQL::NonVersioned", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_problem_user" }, - }, - problem_merged => { - record => "WeBWorK::DB::Record::UserProblem", - schema => "WeBWorK::DB::Schema::NewSQL::Merge", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - depend => [qw/problem_user problem/], - params => { - %sqlParams, - non_native => 1, - merge => [qw/problem_user problem/], - }, - }, - problem_version => { - record => "WeBWorK::DB::Record::ProblemVersion", - schema => "WeBWorK::DB::Schema::NewSQL::Versioned", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { - %sqlParams, - non_native => 1, - tableOverride => "${courseName}_problem_user", - }, - }, - problem_version_merged => { - record => "WeBWorK::DB::Record::ProblemVersion", - schema => "WeBWorK::DB::Schema::NewSQL::Merge", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - depend => [qw/problem_version problem_user problem/], - params => { - %sqlParams, - non_native => 1, - merge => [qw/problem_version problem_user problem/], - }, - }, - setting => { - record => "WeBWorK::DB::Record::Setting", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_setting" }, - }, - achievement => { - record => "WeBWorK::DB::Record::Achievement", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_achievement" }, - }, - past_answer => { - record => "WeBWorK::DB::Record::PastAnswer", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_past_answer" }, - }, - - achievement_user => { - record => "WeBWorK::DB::Record::UserAchievement", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_achievement_user" }, - }, - global_user_achievement => { - record => "WeBWorK::DB::Record::GlobalUserAchievement", - schema => "WeBWorK::DB::Schema::NewSQL::Std", - driver => "WeBWorK::DB::Driver::SQL", - source => $database_dsn, - engine => $database_storage_engine, - character_set => $database_character_set, - params => { %sqlParams, tableOverride => "${courseName}_global_user_achievement" }, - }, -}; - -# include ("conf/database.conf"); # uncomment to provide local overrides - -1; diff --git a/conf/defaults.config b/conf/defaults.config index 3ab6a8c796..9702d25ba2 100644 --- a/conf/defaults.config +++ b/conf/defaults.config @@ -1,18 +1,4 @@ #!perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ # This file is used to set up the default WeBWorK course environment for all # requests. Values may be overwritten by the course.conf for a specific course. @@ -156,10 +142,6 @@ $achievementPointsPerProblemReduced = 3; $achievementPreambleFile = "preamble.at"; $achievementExcludeSet = []; $mail{achievementEmailFrom} = ''; - -################################################################################ -# Achievements -################################################################################ $showCourseHomeworkTotals = 1; ################################################################################ @@ -599,34 +581,14 @@ $default_status = "Enrolled"; # Database options ################################################################################ -# Database schemas are defined in the file conf/database.conf and stored in the -# hash %dbLayouts. The standard schema is called "sql_single"; - -include( "./conf/database.conf.dist"); # always include database.conf.dist - - # in the rare case where you want local overrides - # you can place include("conf/database.conf") in - # the database.conf.dist file -# this change is meant to help alleviate the common mistake of forgetting to update the -# database.conf file when changing WW versions. - -# Select the default database layout. This can be overridden in the course.conf -# file of a particular course. The only database layout supported in WW 2.1.4 -# and up is "sql_single". -$dbLayoutName = "sql_single"; - -# This sets the symbol "dbLayout" as an alias for the selected database layout. -*dbLayout = $dbLayouts{$dbLayoutName}; - # This sets the max course id length. It might need to be changed depending # on what database tables are present. Mysql allows a max table length of 64 # characters. With the ${course_id}_global_user_achievement table that means # the max ${course_id} is exactly 40 characters. +# Reference: https://dev.mysql.com/doc/refman/8.0/en/identifier-length.html $maxCourseIdLength = 40; -# Reference: https://dev.mysql.com/doc/refman/8.0/en/identifier-length.html - ################################################################################ # Problem library options ################################################################################ @@ -720,25 +682,14 @@ $modelCoursesForCopy = [ "modelCourse" ]; # Authentication system ################################################################################ -# FIXME This mechanism is a little awkward and probably should be merged with -# the dblayout selection system somehow. - # Select the authentication module to use for normal logins. -# -# If this value is a string, the given authentication module will be used -# regardless of the database layout. -# -# If it is a hash, the database layout name will be looked up in the hash and -# the resulting value will be used as the authentication module. The special -# hash key "*" is used if no entry for the current database layout is found. -# -# If this value is a sequence of strings or hashes, then each string or hash in -# the sequence will be successively tested to see if it provides a module that -# can handle the authentication request by calling the module's sub -# request_has_data_for_this_verification_module(). The first module that +# If this value is a string, then that authentication module will be used. If +# this value is a reference to an array of strings, then each string in the +# array will be successively tested to see if it provides a module that can +# handle the authentication request (by calling that module's +# request_has_data_for_this_verification_module method). The first module that # responds affirmatively will be used. - -$authen{user_module} = {"*" => "WeBWorK::Authen::Basic_TheLastOption"}; +$authen{user_module} = 'WeBWorK::Authen::Basic_TheLastOption'; # Select the authentication module to use for proctor logins. # A string or a hash is accepted, as above. @@ -784,6 +735,7 @@ $authen{admin_module} = ['WeBWorK::Authen::Basic_TheLastOption']; use_two_factor_auth => "student", report_bugs => "ta", submit_feedback => "student", + change_name => "student", change_password => "student", change_email_address => "student", change_pg_display_settings => "student", @@ -798,6 +750,8 @@ $authen{admin_module} = ['WeBWorK::Authen::Basic_TheLastOption']; view_hidden_sets => "ta", view_answers => "ta", view_ip_restricted_sets => "ta", + view_leaderboard => "professor", + view_leaderboard_usernames => "professor", become_student => "professor", access_instructor_tools => "ta", @@ -980,8 +934,7 @@ $session_management_via = "session_cookie"; $CookieSameSite = "Lax"; # Set the value of the secure cookie attribute. -# The default is 0, as 1 will not work without https. -$CookieSecure = 0; +$CookieSecure = 1; # If $useSessionCookie is set to 1, then a "session" cookie will be used. This # means that the cookie will be deleted when the browser session ends. @@ -1349,6 +1302,7 @@ ${pg}{modules} = [ [qw(Matrix)], [qw(Multiple)], [qw(PGrandom)], + [qw(Plots::Plot Plots::Axes Plots::Data Plots::Tikz Plots::JSXGraph Plots::GD)], [qw(Regression)], [qw(Select)], [qw(Units)], @@ -1361,7 +1315,7 @@ ${pg}{modules} = [ [qw(PGcore PGalias PGresource PGloadfiles PGanswergroup PGresponsegroup Tie::IxHash)], [qw(Locale::Maketext)], [qw(WeBWorK::PG::Localize)], - [qw(JSON)], + [qw(Mojo::JSON)], [qw(Rserve Class::Tiny IO::Handle)], [qw(DragNDrop)], [qw(Types::Serialiser)], @@ -1483,6 +1437,14 @@ $pg{assignOpenPriorToDue} = 10080; # set for answers to be made available; $pg{answersOpenAfterDueDate} = 2880; +############################################################################### +# Default Test settings +############################################################################### + +# Cap for the number of problems that can be used on a page. +# If 0, there is no cap. Otherwise, should be a positive integer. +$test{maxProblemsPerPage} = 0; + ############################################################################### # Progress Bar switch ############################################################################### diff --git a/conf/localOverrides.conf.dist b/conf/localOverrides.conf.dist index 555f2f0f91..78ea012bba 100644 --- a/conf/localOverrides.conf.dist +++ b/conf/localOverrides.conf.dist @@ -1,18 +1,4 @@ #!perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ # This file should be used to override any of the default settings in defaults.config. # The most commonly changed settings are provided as examples below, but any directive @@ -26,14 +12,12 @@ # localOverrides.conf contains the local modifications commonly made # when installing WeBWorK on a new site. The configurations in defaults.config -# and in database.conf can usually remain untouched. +# should not be changed. # # localOverride.conf is the appropriate place to override permission settings, # paths to macros and other customizations that are specific to your # WeBWorK site - - ################################################################################ # Additional mail settings in defaults.config can be overridden here ################################################################################ @@ -250,6 +234,24 @@ $mail{feedbackRecipients} = [ #$permissionLevels{record_set_version_answers_when_acting_as_student} = "professor"; #$permissionLevels{record_answers_when_acting_as_student} = "professor"; +################################################################################ +# Initial password fallback +################################################################################ + +# This sets the default fallback source to use for a user's password when a user +# account is created in a course. That is the source that will be shown by +# default in a select menu in the user interface, and the source that will be +# used by the importClassList.pl and addcourse scripts. It can be one of +# 'user_id', 'first_name', 'last_name', or 'student_id', or can be set to '' (or +# anything not listed before). If a user is created and no password is +# explicitly provided, then this source will used for the password (assuming +# that source value is also set). If this is not one of the allowed sources, +# and if a password is not explicitly provided, then the user will be created +# without a password and will not be able to sign in with username and password. +# Note that this is only used at the time that a user is initially created in a +# course, and not when editing passwords at a later time. +#$fallback_password_source = 'student_id'; + ################################################################################ # Default settings for the problem editor pages ################################################################################ @@ -460,24 +462,16 @@ $mail{feedbackRecipients} = [ # methods of authentication. # Select the authentication module to use for normal logins. -# -# If this value is a string, the given authentication module will be used -# regardless of the database layout. -# -# If it is a hash, the database layout name will be looked up in the hash and -# the resulting value will be used as the authentication module. The special -# hash key "*" is used if no entry for the current database layout is found. -# -# If this value is a sequence of strings or hashes, then each string or hash in -# the sequence will be successively tested to see if it provides a module that -# can handle the authentication request by calling the module's sub -# request_has_data_for_this_verification_module(). The first module that +# If this value is a string, then that authentication module will be used. If +# this value is a reference to an array of strings, then each string in the +# array will be successively tested to see if it provides a module that can +# handle the authentication request (by calling that module's +# request_has_data_for_this_verification_module method). The first module that # responds affirmatively will be used. - -#$authen{user_module} = { -# "*" => "WeBWorK::Authen::LDAP" -# "*" => "WeBWorK::Authen::Basic_TheLastOption" -#}; +#$authen{user_module} = [ +# "WeBWorK::Authen::LDAP", +# "WeBWorK::Authen::Basic_TheLastOption" +#]; # Select the authentication module to use for proctor logins. # A string or a hash is accepted, as above. @@ -537,6 +531,16 @@ $mail{feedbackRecipients} = [ #include("conf/authen_shibboleth.conf"); +################################################################################ +# Saml2 Authentication +################################################################################ +# Uncomment the following line to enable authentication via a Saml2 identity +# provider. You will also need to copy the file authen_saml2.conf.dist to +# authen_saml2.conf, and then edit that file to fill in the settings for your +# installation. + +#include("conf/authen_saml2.conf"); + ################################################################################ # Session Management ################################################################################ @@ -586,8 +590,8 @@ $mail{feedbackRecipients} = [ #$CookieSameSite = "Lax"; # Set the value of the secure cookie attribute. -# The default is 0, as 1 will not work without https. -#$CookieSecure = 1; +# The default is 1, so if you are serving without https then set this to 0. +#$CookieSecure = 0; # If $useSessionCookie is set to 1, then a "session" cookie will be used. This # means that the cookie will be deleted when the browser session ends. @@ -757,4 +761,12 @@ $mail{feedbackRecipients} = [ # }; # }; +############################################################################### +# Test settings +############################################################################### + +# Cap for the number of problems that can be used on a page. +# If 0, there is no cap. Otherwise, should be a positive integer. +#$test{maxProblemsPerPage} = 1; + 1; #final line of the file to reassure perl that it was read properly. diff --git a/conf/site.conf.dist b/conf/site.conf.dist index 4336898f6e..9c6b7ddfae 100644 --- a/conf/site.conf.dist +++ b/conf/site.conf.dist @@ -1,18 +1,4 @@ #!perl -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ # This file is used to set up the default WeBWorK course environment for all # requests. Values may be overwritten by the course.conf for a specific course. diff --git a/courses.dist/modelCourse/course.conf b/courses.dist/modelCourse/course.conf index fb80c242a5..ff74987a9a 100644 --- a/courses.dist/modelCourse/course.conf +++ b/courses.dist/modelCourse/course.conf @@ -2,17 +2,6 @@ # This file is used to override the global WeBWorK course environment for this course. -# Database Layout (global value typically defined in global.conf) -# Several database are defined in the file conf/database.conf and stored in the -# hash %dbLayouts. -# The database layout is always set here, since one should be able to change the -# default value in global.conf without disrupting existing courses. -# global.conf values: -# $dbLayoutName = 'sql_single'; -# *dbLayout = $dbLayouts{$dbLayoutName}; -$dbLayoutName = 'sql_single'; -*dbLayout = $dbLayouts{$dbLayoutName}; - # Users for whom to label problems with the PG file name # For users in this list, PG will display the source file name when rendering a problem. #$pg{specialPGEnvironmentVars}{PRINT_FILE_NAMES_FOR} = ['user_id1']; diff --git a/courses.dist/modelCourse/html/achievements/chipping_away.png b/courses.dist/modelCourse/html/achievements/chipping_away.png index 6ffadc7b5c..cfdd040c6c 100644 Binary files a/courses.dist/modelCourse/html/achievements/chipping_away.png and b/courses.dist/modelCourse/html/achievements/chipping_away.png differ diff --git a/courses.dist/modelCourse/html/achievements/complete_100_problems.png b/courses.dist/modelCourse/html/achievements/complete_100_problems.png index 12d3ec390b..fc7926bca9 100644 Binary files a/courses.dist/modelCourse/html/achievements/complete_100_problems.png and b/courses.dist/modelCourse/html/achievements/complete_100_problems.png differ diff --git a/courses.dist/modelCourse/html/achievements/complete_10_problems.png b/courses.dist/modelCourse/html/achievements/complete_10_problems.png index 9ea2f4da3e..6bf73f552b 100644 Binary files a/courses.dist/modelCourse/html/achievements/complete_10_problems.png and b/courses.dist/modelCourse/html/achievements/complete_10_problems.png differ diff --git a/courses.dist/modelCourse/html/achievements/complete_150_problems.png b/courses.dist/modelCourse/html/achievements/complete_150_problems.png index eb29ce22fe..554addb60a 100644 Binary files a/courses.dist/modelCourse/html/achievements/complete_150_problems.png and b/courses.dist/modelCourse/html/achievements/complete_150_problems.png differ diff --git a/courses.dist/modelCourse/html/achievements/complete_25_problems.png b/courses.dist/modelCourse/html/achievements/complete_25_problems.png index 7500c99f70..6db76378bb 100644 Binary files a/courses.dist/modelCourse/html/achievements/complete_25_problems.png and b/courses.dist/modelCourse/html/achievements/complete_25_problems.png differ diff --git a/courses.dist/modelCourse/html/achievements/complete_50_problems.png b/courses.dist/modelCourse/html/achievements/complete_50_problems.png index 4457a77bfd..0937c5ef81 100644 Binary files a/courses.dist/modelCourse/html/achievements/complete_50_problems.png and b/courses.dist/modelCourse/html/achievements/complete_50_problems.png differ diff --git a/courses.dist/modelCourse/html/achievements/complete_one_problem.png b/courses.dist/modelCourse/html/achievements/complete_one_problem.png index d5adff139f..9ffab1b887 100644 Binary files a/courses.dist/modelCourse/html/achievements/complete_one_problem.png and b/courses.dist/modelCourse/html/achievements/complete_one_problem.png differ diff --git a/courses.dist/modelCourse/html/achievements/crack_o_dawn.png b/courses.dist/modelCourse/html/achievements/crack_o_dawn.png index fa72be7bb8..62557a8ecd 100644 Binary files a/courses.dist/modelCourse/html/achievements/crack_o_dawn.png and b/courses.dist/modelCourse/html/achievements/crack_o_dawn.png differ diff --git a/courses.dist/modelCourse/html/achievements/last_minute.png b/courses.dist/modelCourse/html/achievements/last_minute.png index 505604d5ba..6bdc01449d 100644 Binary files a/courses.dist/modelCourse/html/achievements/last_minute.png and b/courses.dist/modelCourse/html/achievements/last_minute.png differ diff --git a/courses.dist/modelCourse/html/achievements/level_eight.png b/courses.dist/modelCourse/html/achievements/level_eight.png index b42683d69d..c5e793ffca 100644 Binary files a/courses.dist/modelCourse/html/achievements/level_eight.png and b/courses.dist/modelCourse/html/achievements/level_eight.png differ diff --git a/courses.dist/modelCourse/html/achievements/level_five.png b/courses.dist/modelCourse/html/achievements/level_five.png index 6c18cc9713..598adc678d 100644 Binary files a/courses.dist/modelCourse/html/achievements/level_five.png and b/courses.dist/modelCourse/html/achievements/level_five.png differ diff --git a/courses.dist/modelCourse/html/achievements/level_four.png b/courses.dist/modelCourse/html/achievements/level_four.png index 6590f4a4dd..e027fabb23 100644 Binary files a/courses.dist/modelCourse/html/achievements/level_four.png and b/courses.dist/modelCourse/html/achievements/level_four.png differ diff --git a/courses.dist/modelCourse/html/achievements/level_one.png b/courses.dist/modelCourse/html/achievements/level_one.png index 5cc62e863d..552010f472 100644 Binary files a/courses.dist/modelCourse/html/achievements/level_one.png and b/courses.dist/modelCourse/html/achievements/level_one.png differ diff --git a/courses.dist/modelCourse/html/achievements/level_seven.png b/courses.dist/modelCourse/html/achievements/level_seven.png index 937337cd51..3f9b1d9cd2 100644 Binary files a/courses.dist/modelCourse/html/achievements/level_seven.png and b/courses.dist/modelCourse/html/achievements/level_seven.png differ diff --git a/courses.dist/modelCourse/html/achievements/level_two.png b/courses.dist/modelCourse/html/achievements/level_two.png index 8f38e89884..b86afcc186 100644 Binary files a/courses.dist/modelCourse/html/achievements/level_two.png and b/courses.dist/modelCourse/html/achievements/level_two.png differ diff --git a/courses.dist/modelCourse/html/achievements/on_fire.png b/courses.dist/modelCourse/html/achievements/on_fire.png index 05cc600513..e66b33af9c 100644 Binary files a/courses.dist/modelCourse/html/achievements/on_fire.png and b/courses.dist/modelCourse/html/achievements/on_fire.png differ diff --git a/courses.dist/modelCourse/html/achievements/on_one_hand.png b/courses.dist/modelCourse/html/achievements/on_one_hand.png index 22fa5e82ca..6b034c3846 100644 Binary files a/courses.dist/modelCourse/html/achievements/on_one_hand.png and b/courses.dist/modelCourse/html/achievements/on_one_hand.png differ diff --git a/courses.dist/modelCourse/html/achievements/one_click.png b/courses.dist/modelCourse/html/achievements/one_click.png index e239cc84fe..dc0e8e7626 100644 Binary files a/courses.dist/modelCourse/html/achievements/one_click.png and b/courses.dist/modelCourse/html/achievements/one_click.png differ diff --git a/courses.dist/modelCourse/html/achievements/pattern_recognition.png b/courses.dist/modelCourse/html/achievements/pattern_recognition.png index 89c6e3af64..fde237c45a 100644 Binary files a/courses.dist/modelCourse/html/achievements/pattern_recognition.png and b/courses.dist/modelCourse/html/achievements/pattern_recognition.png differ diff --git a/courses.dist/modelCourse/html/achievements/persistance.png b/courses.dist/modelCourse/html/achievements/persistance.png index 16f3ea2076..b6a2938bbb 100644 Binary files a/courses.dist/modelCourse/html/achievements/persistance.png and b/courses.dist/modelCourse/html/achievements/persistance.png differ diff --git a/courses.dist/modelCourse/html/achievements/reaching_a_limit.png b/courses.dist/modelCourse/html/achievements/reaching_a_limit.png index e5f74fddfb..152b817c9a 100644 Binary files a/courses.dist/modelCourse/html/achievements/reaching_a_limit.png and b/courses.dist/modelCourse/html/achievements/reaching_a_limit.png differ diff --git a/courses.dist/modelCourse/html/achievements/speed_mather.png b/courses.dist/modelCourse/html/achievements/speed_mather.png index cc2806f499..d882f45de9 100644 Binary files a/courses.dist/modelCourse/html/achievements/speed_mather.png and b/courses.dist/modelCourse/html/achievements/speed_mather.png differ diff --git a/courses.dist/modelCourse/html/achievements/super_persistance.png b/courses.dist/modelCourse/html/achievements/super_persistance.png index f37d80896e..c238015aa8 100644 Binary files a/courses.dist/modelCourse/html/achievements/super_persistance.png and b/courses.dist/modelCourse/html/achievements/super_persistance.png differ diff --git a/courses.dist/modelCourse/html/achievements/super_speed_math.png b/courses.dist/modelCourse/html/achievements/super_speed_math.png index 3c4942580b..02296da682 100644 Binary files a/courses.dist/modelCourse/html/achievements/super_speed_math.png and b/courses.dist/modelCourse/html/achievements/super_speed_math.png differ diff --git a/courses.dist/modelCourse/html/achievements/the_lhopital.png b/courses.dist/modelCourse/html/achievements/the_lhopital.png index ce67b858d6..d27873c276 100644 Binary files a/courses.dist/modelCourse/html/achievements/the_lhopital.png and b/courses.dist/modelCourse/html/achievements/the_lhopital.png differ diff --git a/courses.dist/modelCourse/html/achievements/three_in_a_row.png b/courses.dist/modelCourse/html/achievements/three_in_a_row.png index ef315760c6..8043f11727 100644 Binary files a/courses.dist/modelCourse/html/achievements/three_in_a_row.png and b/courses.dist/modelCourse/html/achievements/three_in_a_row.png differ diff --git a/courses.dist/modelCourse/html/achievements/to_infinity.png b/courses.dist/modelCourse/html/achievements/to_infinity.png index fb4b700567..5b036ae083 100644 Binary files a/courses.dist/modelCourse/html/achievements/to_infinity.png and b/courses.dist/modelCourse/html/achievements/to_infinity.png differ diff --git a/courses.dist/modelCourse/templates/achievements/default_achievements.axp b/courses.dist/modelCourse/templates/achievements/default_achievements.axp index 77cbb9ac97..08ac002f62 100644 --- a/courses.dist/modelCourse/templates/achievements/default_achievements.axp +++ b/courses.dist/modelCourse/templates/achievements/default_achievements.axp @@ -3,8 +3,8 @@ crack_o_dawn,"Crack O' Dawn",102,secret,default,"Finish a homework set between 5 on_the_hour,"Watching the Clock",103,secret,"default,jitar","Finish a problem at the top of the hour.",10,,on_the_hour.at,on_the_hour.png last_minute,"Last Minute Math",104,secret,default,"Complete a homework within 30 minutes of the due date.",10,,last_minute.at,last_minute.png still_not_right,"It's Still Not Right",105,secret,"default,jitar","Input the exact same (incorrect) answer 10 times in a row.",5,,still_not_right.at,still_not_right.png -persistance,"Persistence is not Futile",106,secret,"default,jitar","Solve a problem after 20 incorrect submissions.",10,,persistance.at,persistance.png -super_persistance,"Green Never Looked So Good",107,secret,"default,jitar","Solve a problem after 100 incorrect submissions.",10,,super_persistance.at,super_persistance.png +persistence,"Persistence is not Futile",106,secret,"default,jitar","Solve a problem after 20 incorrect submissions.",10,,persistence.at,persistence.png +super_persistence,"Green Never Looked So Good",107,secret,"default,jitar","Solve a problem after 100 incorrect submissions.",10,,super_persistence.at,super_persistence.png super_speed_math,"Careful Planning and Quick Fingers",108,secret,default,"Spend less than 10 minutes entering answers to a homework set.",20,,super_speed_math.at,super_speed_math.png hows_your_finger,"Hows Your Finger?",109,secret,"default,jitar","Have more than 250 submissions on a homework problem. ",10,,hows_your_finger.at,hows_your_finger.png third_time,"Third Times the Charm",110,secret,"default,jitar","Solve a problem on the third submission",10,,third_time.at,third_time.png diff --git a/courses.dist/modelCourse/templates/achievements/persistance.at b/courses.dist/modelCourse/templates/achievements/persistence.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/persistance.at rename to courses.dist/modelCourse/templates/achievements/persistence.at diff --git a/courses.dist/modelCourse/templates/achievements/super_persistance.at b/courses.dist/modelCourse/templates/achievements/super_persistence.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/super_persistance.at rename to courses.dist/modelCourse/templates/achievements/super_persistence.at diff --git a/doc/devel/DBglue.notes b/doc/devel/DBglue.notes deleted file mode 100644 index 3402a13a8d..0000000000 --- a/doc/devel/DBglue.notes +++ /dev/null @@ -1,75 +0,0 @@ -############## -# webwork_DB # -############## - -PSVN => - => - - (Generated during problem set build process:) - - stlg StudentLogin the login name of the student who owns this PSVN - stnm SetNumber the "number" (name) of the problem set associated with this PSVN - pse# ProblemSeed(#) the problem seed, a random integer between 0 and 5000 - - (Taken from set definition file:) - - shfn SetHeaderFileName the file name of the set header file (shown when selecting problem?) - phfn ProbHeaderFileName the file name of the problem header file (shown when viewing a problem) - opdt OpenDate the date that the problem set "opens" - dudt DueDate the date after which no credit can be recieved - andt AnswerDate the date when answers can be shown - pfn# ProblemFileName(#) the file name of the problem - pva# ProblemValue(#) the number of points that the problem is worth - pmia# ProblemMaxNumOfIncorrectAttempts(#) the number of times a student is allowed to answer incorrectly - - (Added when student works on problem set:) - - pst# ProblemStatus(#) the "correctness" of the problem: [0,1] - pat# ProblemAttempted(#) boolean, whether the problem has been attempted (answer has been submitted) - pan# ProblemStudentAnswer(#) the student's last answer (attempt) for a problem - pca# ProblemNumOfCorrectAns(#) number of correct answers (there can be more than one ANS per problem) - pia# ProblemNumOfIncorrectAns(#) number of incorrect answers - -LOGIN => - SetNumber => PSVN - ... - -SET_NUMBER => - StudentLogin => PSVN - ... - -################ -# classlist_DB # -################ - -LOGIN => - stln StudentLastName - stfn StudentFirstName - stea StudentEmailAddress - stid StudentID - stst StudentStatus - clsn ClassSection - clrc ClassRecitation - comt Comment - -######## -# keys # -######## - -LOGIN => - KEY the last key associated with the user - TIMESTAMP the time that this key was last used - -############### -# password_DB # -############### - -LOGIN => - PASSWORD the password set for this user - -################## -# permissions_DB # -################## - -LOGIN => - PERMISSIONS an integer representing the permissions allowed to the user (i.e. 10=prof, 5=ta) diff --git a/doc/devel/URL-notes b/doc/devel/URL-notes deleted file mode 100644 index 61c5cc672a..0000000000 --- a/doc/devel/URL-notes +++ /dev/null @@ -1,92 +0,0 @@ -THE CURRENT STRATEGY - -The URL strategy we've been basically using is to put the nouns in the path info and the verbs in the query string. The heirarchy of the path info mirrors the heirarchy of objects in the system. It works like this in the Problem.pm content generator (I think): - - /webwork/$courseID/$setID/$problemID?submitAnswer=Submit+Answer - -WeBWorK contains courses, courses contain sets, sets contain problems. This is reflected in the path info. The presence of a parameter named submitAnswer in the query string causes Problem.pm to record the answers given later in the query string. The set and problem to which the answers are recorded is given in the path info. Each content generator has a default action that is performed if no verbs are given in the query string. These default actions are non-destructive, i.e. "view" or "list". - -ADVANTAGES - -This scheme has the advantage of allowing a user (usually a professor) to easily reference a "location" within the system, without needing to worry about the relativly complicated syntax of the query string. For example, a professor could, after making a change to a problem, instruct students via email to "visit http://courses.webwork.rochester.edu/mth161/4/7/ to view the updated problem". This is certainly more manageable than a scheme in which the verb is in the path info and the nouns are in the query string (as in "http://courses.webwork.edu/viewProblem?course=mth161&set=4&problem=7"). - -It also allows us to, as we strive to reduce the amount of state that is passed through each request, pare the URL down so that very little query string remains. For example, the session key could be kept in a cookie, and the rest of the session data could be kept in the key table of the database. The only thing that would be left in the query string would be the verb and (I suppose) various "adverbs" and "adjectives". - -PROBLEMS - -Currently the dispatch() function uses only the path info to decide which content generator to invoke. This means that unless we wish to have the problem viewer and the problem editor be implemented in the same content generator, it would be impossible to have these two URIs: - - /webwork/$courseID/$setID/$problemID/?action=view - /webwork/$courseID/$setID/$problemID/?action=edit - -This, I think, can be solved by allowing the dispatch() function to inspect the query string in a limited fashion. If it were allowed to check for an "action" parameter, and a standard set of actions were defined, the combination of an action (in the query string) and a type of object (in the path info) could be used to select a content generator. - -A table would be needed to noun/action pairs to content generators and vice versa. For example: - - Problem + view <=> WeBWorK::ContentGenerator::Problem - Problem + edit <=> WeBWorK::ContentGenerator::Instructor::PGProblemEditor - -However, there are some tricky implementation problems with this approach. The value of a parameter triggered by a submit button (generated with the INPUT element) is the same as the text that is displayed on the button. This puts the desire for good UI at odds with having easily manageable action identifiers. We'd like to have a button named "Download Hardcopy for Selected Sets", but have the action identifier be "makeHardcopy" or something. The BUTTON element promises to solve this, but browser support is very broken for both the presentation and behavior of this element. - -Another problem with this scheme is that sometimes the nouns needed are not known when the form action is decided at page generation time. For example, the URI for creating a new set might be something like: - - /webwork/$courseID/instructor/sets/$setID?action=create - -The set name ($setID) would have be supplied by a text field on the form, and therefore would not be known at the time that the form was generated. One solution to this would be to name the "slots" in the path info, and have the dispatcher substitute in values from the query string before handing the request off to the content generator. A redirect to the URI with the values substituted in could fix the user experience somewhat. For example, the dispatcher could convert the URI: - - /webwork/mth161/instructor/sets/?action=create&setID=newIntegrals - -to: - - /webwork/mth161/instructor/sets/newIntegrals/?action=create - -In the example, the value of the parameter named "setID" was placed in the "setID" slot in the path info, and the parameter was deleted. - --------------------------------------------------------------------------------- - -(+) = list - -THE CURRENT URI HEIRARCHY - -$webworkURIRoot - $courseID - test - $setID - $problemID - hardcopy - $setID - login - logout - options - feedback - instructor - [some crap] - -PROPOSED URI HEIRARCHY - -$webworkURIRoot - $courseID -- lists sets - test -- prints debugging information - $setID -- lists problems in a set - $problemID -- displays problem (interactive mode) - hardcopy -- prompts user for which sets/users to generate - $setID (+) -- generates hardcopy for specified sets - login -- prompts for login information - logout -- deletes session information, displays logout message - options -- allows user to change email address and password - feedback -- allows user to send feedback to professor - instructor -- gives user a choice between user list and set list - sets -- lists/edits (global) sets - $setID -- displays/edits (global) set data - problems -- lists (global) problems for a set - $problemID -- displays/edits (global) set data - users -- lists/edits assigned users - $userID (+) -- displays/edits user-specific set data - problems -- lists/edits assigned problems - $problemID -- displays/edits user-specific problem data - users -- lists/edits users - $userID -- displays/edits user data - sets -- lists/edits assigned sets - $setID -- displays/edits user-specific set data - problems -- lists/edits assigned problems - $problemID -- displays/edits user-specific problem data diff --git a/doc/devel/cg-refactor-notes b/doc/devel/cg-refactor-notes deleted file mode 100644 index b9e0f4e35c..0000000000 --- a/doc/devel/cg-refactor-notes +++ /dev/null @@ -1,300 +0,0 @@ --------------------------------------------------------------------------------- -currently, ContentGenerators do several things: --------------------------------------------------------------------------------- - -display lists of objects and object details ("displays") -prompt for list filtering ("display filters") -prompt for creating, editing, and deleting objects ("action triggers") -create, edit, and delete objects ("actions") - --------------------------------------------------------------------------------- -goals: --------------------------------------------------------------------------------- - -have a chunk of code that takes care of each "action" -avoid duplication of code -preserve and "purify" noun-based URL path-info -have the noun-based path refer to the object or list being viewed - --------------------------------------------------------------------------------- -changes to request format: --------------------------------------------------------------------------------- - -Define some special parameters that are recognized by the dispatcher: - - "u" specifies the current user (i.e. the actual human using the system) - "p" specifies the password of the current user - "k" specifies the session key of the current user - "a" specifies an action to perform (like "a=deleteUser") - "o" specifies objects on which to perform an action (like "o=sh002i&o=dl001i") - -We should reserve all single-letter parameter names. -(UPDATE: I changed my mind. Single-letter param names are unneccesarily -obfuscated and annnoying.) - -We can't use - -An action trigger that would be put onto a user detail page: - - - --------------------------------------------------------------------------------- -development plan --------------------------------------------------------------------------------- - -1. write a "new" dispatcher -2. code display modules using display code from existing content generators -3. add actions and action triggers as needed, using code from existing content generators -4. ? -5. profit diff --git a/doc/devel/converted-cgs b/doc/devel/converted-cgs deleted file mode 100644 index 89d98aebcf..0000000000 --- a/doc/devel/converted-cgs +++ /dev/null @@ -1,81 +0,0 @@ -4 => 3 + GENERATE PATHS WITH URLPath INSTEAD OF HARD CODING -(this has to be done before we can change the virtual heirarchy.) - lib/WeBWorK/ContentGenerator.pm - lib/WeBWorK/ContentGenerator/EquationDisplay.pm - lib/WeBWorK/ContentGenerator/Error.pm - lib/WeBWorK/ContentGenerator/Feedback.pm - lib/WeBWorK/ContentGenerator/Grades.pm - lib/WeBWorK/ContentGenerator/Home.pm - lib/WeBWorK/ContentGenerator/Instructor.pm - lib/WeBWorK/ContentGenerator/Instructor/AddUsers.pm - lib/WeBWorK/ContentGenerator/Instructor/Assigner.pm - lib/WeBWorK/ContentGenerator/Instructor/FileXfer.pm - lib/WeBWorK/ContentGenerator/Instructor/Index.pm - lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm - lib/WeBWorK/ContentGenerator/Login.pm - lib/WeBWorK/ContentGenerator/Logout.pm - lib/WeBWorK/ContentGenerator/Options.pm - lib/WeBWorK/ContentGenerator/Problem.pm - lib/WeBWorK/ContentGenerator/ProblemSet.pm - lib/WeBWorK/ContentGenerator/ProblemSets.pm - - lib/WeBWorK/ContentGenerator/Instructor/ProblemList.pm - lib/WeBWorK/ContentGenerator/Instructor/ProblemSetEditor.pm - lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm - lib/WeBWorK/ContentGenerator/Instructor/Scoring.pm - lib/WeBWorK/ContentGenerator/Instructor/ScoringDownload.pm - lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm - lib/WeBWorK/ContentGenerator/Instructor/SetsAssignedToUser.pm - lib/WeBWorK/ContentGenerator/Instructor/ShowAnswers.pm - lib/WeBWorK/ContentGenerator/Instructor/Stats.pm - lib/WeBWorK/ContentGenerator/Instructor/UserList.pm - -3 => 2 + GET PATH DATA FROM URLPath INSTEAD OF FROM $self->r -(this has to be done before we can take advantage of path/param munging.) - lib/WeBWorK/ContentGenerator/GatewayQuiz.pm - - delaying path generation changes until major cleanup - lib/WeBWorK/ContentGenerator/Hardcopy.pm - - delaying path generation changes until major cleanup - - - - - - - - - - - - lib/WeBWorK/ContentGenerator/Instructor/UsersAssignedToSet.pm - -2 => 1 + GET $ce, $db, $authz FROM $self->r INSTEAD OF FROM $self -(this has to be done before we can remove ce/db/authz from $self.) - - -1 => REMOVE DEPENDANCY ON DATA FROM @_ (get from URLPath instead) -(this has to be done before things will work.) - - - - - -0 => NONE OF THE ABOVE DONE - ------ Code that needs cleaning ----- - -- lots of code needs to be factored out of Problem and GatewayQuiz: - - problem logic (recording answers, checking permissions, etc.) - - display idioms (attemptResults, etc.) -- Hardcopy: - - move actual PDF generation into a Utils::* module - - clean up error handling -- code can be factored out of Grades, Stats, and SendMail - - a widget for displaying the "student progress" chart - - util code for doing mail merge from scoring files (whatever it does) -- Instructor needs work -- there's a lot of cut-n-paste going on -- factor info box formatting code out of Login, ProblemSets, ProblemSet - and into WeBWorK::HTML::InfoBox -- some modules should probably go under Utils: - - Compatability.pm - - Timing.pm diff --git a/doc/devel/daemon-problem-environment b/doc/devel/daemon-problem-environment deleted file mode 100644 index fd9a98e296..0000000000 --- a/doc/devel/daemon-problem-environment +++ /dev/null @@ -1,70 +0,0 @@ -- = not in daemon problem environment from frontier - -- ALLOW_MAIL_TO - answerDate - CAPA_GraphicsDirectory - CAPA_Graphics_URL - CAPA_MCTools - CAPA_Tools - courseName - courseScriptsDirectory -- showHints - displayMode -- showSolutions - dueDate -- externalDvipngPath - externalGif2EpsPath -- externalGif2PngPath -- externalLaTeXPath - externalPng2EpsPath - externalTTHPath - fileName - formattedAnswerDate - formattedDueDate - formattedOpenDate - functAbsTolDefault - functLLimitDefault - functMaxConstantOfIntegration - functNumOfPoints - functRelPercentTolDefault - functULimitDefault - functVarDefault - functZeroLevelDefault - functZeroLevelTolDefault - htmlDirectory - htmlURL - inputs_ref -- languageMode - macroDirectory -- mailSmtpSender -- mailSmtpServer - numAbsTolDefault - numFormatDefault - numOfAttempts - numRelPercentTolDefault - numZeroLevelDefault - numZeroLevelTolDefault - openDate -- outputMode - PRINT_FILE_NAMES_FOR - probFileName - problemSeed - problemValue -- PROBLEM_GRADER_TO_USE - probNum - psvn - questionNumber -- QUIZ_PREFIX -- recitationName -- recitationNumber - sectionName - sectionNumber - sessionKey - setNumber -- studentID - studentLogin - studentName - tempDirectory - templateDirectory - tempURL - webworkDocsURL diff --git a/doc/devel/dont-forget b/doc/devel/dont-forget deleted file mode 100644 index feea0c1e9f..0000000000 --- a/doc/devel/dont-forget +++ /dev/null @@ -1,24 +0,0 @@ -Barbara from UNH had some ideas: - - " [x] destroy existing versions " on build problem sets page. - - others? - -Dan from UNH wants to be able to "name" math expressions so that images can be made conditional (i.e. if( blah ) { TEXT(\[ ... \]) } else { TEXT(\[ ... ]\) } (see email) - -Image generation alternatives: - - math2img, with some generalization - - math2img, as a function in dangerousMacros - - something out of http://preview-latex.sourceforge.net/ - - TeXd -- http://www.activetex.org/ - - - - -defaults.config: - - the "root" of the modperl handler should be sent in from outside - - right now, it's in $webworkURLs{root} - -fun Apache::* modules: - Apache::PerlVINC - Apache::SubProcess - output of subprocesses goes to client - -also: http://perl.apache.org/docs/general/perl_reference/perl_reference.html#Exception_Handling_for_mod_perl diff --git a/doc/devel/hardcopy-notes b/doc/devel/hardcopy-notes deleted file mode 100644 index 8a47286378..0000000000 --- a/doc/devel/hardcopy-notes +++ /dev/null @@ -1,22 +0,0 @@ -students should be able to: - - generate hardcopy for a single problem set for themselves -professors should be able to: - - generate hardcopy of a single problem set for any user - - generate hardcopy of multiple problem sets for any user - - generate hardcopy of a single problem set for multiple users - -inputs: - - $singleSet (from PATH_INFO) - added to @sets list - @sets - lists sets to generate - @users - lists users to generate for - $generateHardcopy - true if "Generate Hardcopy" button has been clicked - -hardcopy generated is (sets x users), if permissions permit - -subroutines: - &generateHardcopy - - compiles tex, translates, converts to pdf - - throws exceptions on PG errors, returns PG warnings - &displayForm - - displays options form diff --git a/doc/devel/new-DB-API b/doc/devel/new-DB-API deleted file mode 100644 index 30c3e319cf..0000000000 --- a/doc/devel/new-DB-API +++ /dev/null @@ -1,91 +0,0 @@ --------------------------------------------------------------------------------- -DB API --------------------------------------------------------------------------------- - -The DB API defines the following methods. These methods are grouped according to which tables they access. A method followed by a list of "=>"-prefixed methods depends on the listed methods. - -password - - listPasswords() - addPassword($Password) - getPassword($userID) - putPassword($Password) - deletePassword($userID) - -permission - - listPermissionLevels() - addPermissionLevel($PermissionLevel) - getPermissionLevel($userID) - putPermissionLevel($PermissionLevel) - deletePermissionLevel($userID) - -key - - listKeys() - addKey($Key) - getKey($userID) - putKey($Key) - deleteKey($userID) - -user - - listUsers() - addUser($User) - getUser($userID) - putUser($User) - deleteUser($userID) - => deletePassword($userID) - => deletePermissionLevel($userID) - => deleteKey($userID) - => deleteUserSet($userID, *) - -set - - listGlobalSets() - addGlobalSet($GlobalSet) - getGlobalSet($setID) - putGlobalSet($GlobalSet) - deleteGlobalSet($setID) - => deleteGlobalProblem($setID, *) - => deleteUserSet(*, $setID) - -set_user - - listSetUsers($setID) - listUserSets($userID) - addUserSet($UserSet) - getUserSet($userID, $setID) - putUserSet($UserSet) - deleteUserSet($userID, $setID) - => deleteUserProblem($userID, $setID, *) - -problem - - listGlobalProblems($setID) - addGlobalProblem($GlobalProblem) - getGlobalProblem($setID, $problemID) - putGlobalProblem($GlobalProblem) - deleteGlobalProblem($setID, $problemID) - => deleteUserProblem(*, $setID, $problemID) - -problem_user - - listProblemUsers($setID, $problemID) - listUserProblems($userID, $setID) - addUserProblem($UserProblem) - getUserProblem($userID, $setID, $problemID) - putUserProblem($UserProblem) - deleteUserProblem($userID, $setID, $problemID) - -set+set_user - - getGlobalUserSet($userID, $setID) - => getGlobalSet($setID) - => getUserSet($userID, $setID) - -problem+problem_user - - getGlobalUserProblem($userID, $setID, $problemID) - => getGlobalProblem($setID, $problemID) - => getUserProblem($userID, $setID, $problemID) diff --git a/doc/devel/new-DB-architecture b/doc/devel/new-DB-architecture deleted file mode 100644 index 3bc4d72110..0000000000 --- a/doc/devel/new-DB-architecture +++ /dev/null @@ -1,165 +0,0 @@ --------------------------------------------------------------------------------- -Architecture --------------------------------------------------------------------------------- - -The new database system uses a three-tier architecture to insulate each layer from the adjacent layers. - -TOP LAYER: DB -------------- - -The top layer of the architecture is the DB module. It provides the methods -listed in doc/new-DB-API, and uses schema modules (via tables) to implement those methods. - - / list* exists* add* get* put* delete* \ <- api -+------------------------------------------------------------------+ -| DB | -+------------------------------------------------------------------+ - \ password permission key user set set_user problem problem_user / <- tables - - -MIDDLE LAYER: SCHEMAS ---------------------- - -The middle layer of the architecture is provided by one or more schema modules. They are called "schema" modules because they control the structure of the data for a table. This includes odd things like the way multiple tables are encoded in a single hash in the WWHash schema, and the encoding scheme used. - -The schema modules provide an API that matches the requirements of the DB layer, on a per-table basis. Each schema module has a style that determines which drivers it can interface with. For example, WWHash is a "hash" style schema. SQL is a "dbi" style schema. - -Both WeBWorK 1.x and 2.x courses use: - - / password permission key \ / user \ <- tables -+-----------------------------+ +----------------+ -| Auth1Hash | | Classlist1Hash | -+-----------------------------+ +----------------+ - \ hash / \ hash / <- style - -WeBWorK 1.x courses also use: - - / set_user problem_user \ / set problem \ -+-------------------------+ +---------------------+ -| WW1Hash | | GlobalTableEmulator | -+-------------------------+ +---------------------+ - \ hash / \ null / - -The GlobalTableEmulator schema emulates the global set and problem tables using data from the set_user and problem_user tables. - -WeBWorK 2.x courses also use: - - / set set_user problem problem_user \ -+-------------------------------------+ -| WW2Hash | -+-------------------------------------+ - \ hash / - -Other drop-in schema modules could be: - - / * \ / password \ -+-------+ +--------------+ -| SQL | | PasswordLDAP | -+-------+ +--------------+ - \ dbi / \ ldap / - - -BOTTOM LAYER: DRIVERS ---------------------- - -Driver modules implement a style for a schema. They provide physical access to a data source containing the data for a table. Some driver modules are as follows: - - / hash \ / hash \ / hash \ <- style -+--------+ +--------+ +--------+ -| DB | | GDBM | | DB3 | -+--------+ +--------+ +--------+ - - / dbi \ / ldap \ -+-------+ +--------+ -| DBI | | LDAP | -+-------+ +--------+ - --------------------------------------------------------------------------------- -Schema API --------------------------------------------------------------------------------- - -$record - an object representing a record in the table -@keyparts - values for fields that make up the table's key - -@tables = tables() - returns list of tables supported. - -$style = style() - returns the required driver style. - -$handle = new($db, $driver, $table, $record, $params) - creates a schema interface for $table, using the driver interface - provided by $driver and using the record class named in $record. dies - if the $driver does not support the driver style needed by the schema. - $params contains extra information needed by the schema. $db is provided - so that schemas can query other schemas. (This is used by the - GlobalTableEmulator schema.) - -@keys = $handle->list(@keyparts) - returns a list containing the key of each record in the table that - matches the values in @keyparts. (i.e. [$userID, undef] will return all - of the records with the specified user_id.) the elements of @keys are - \@keyparts. if no matching records exist, an empty list is returned. - -$result = $handle->exists(@keyparts) - returns whether a record matching @keyparts exists in the table. - -$result = $handle->add($record) - attempts to add $record to the table. die if a record with the same key - exists. - -$record = $handle->get(@keyparts) - attempts to retrieve the record matching @keyparts from the table. - returns undef if no record matches. - -$result = $handle->put($record) - attempts to replace the record in the table that matches the key of - $record. dies if no such record exists. - -$result = $handle->delete(@keyparts) - attempts to delete the record matching @keyparts from the table. returns - true if the record was successfully deleted, or false if it did not - exist. - --------------------------------------------------------------------------------- -Driver API --------------------------------------------------------------------------------- - -COMMON ------- - -$style = style() - returns the supported driver style. - -$handle = new($source, $params) - creates a new interface to the data contained in $source. $params - contains extra information needed by the schema. - -$result = $handle->connect($mode) - connects to the data source with access mode $mode. dies if connection - fails. - -$result = $handle->disconnect() - disconnects from the data source. dies if disconnection fails. - -STYLE: hash ------------ - -$ref = $handle->hash() - returns a reference to the underlying tied hash. dies if the hash is - not available (i.e. not connected). - --------------------------------------------------------------------------------- -@keyparts key order --------------------------------------------------------------------------------- - -table keyparts ------ -------- -password user_id -permission user_id -key user_id -user user_id -set set_id -set_user user_id, set_id -problem set_id, problem_id -problem_user user_id, set_id, problem_id diff --git a/doc/devel/new-DB-notes b/doc/devel/new-DB-notes deleted file mode 100644 index fb2baa0634..0000000000 --- a/doc/devel/new-DB-notes +++ /dev/null @@ -1,67 +0,0 @@ --------------------------------------------------------------------------------- -Notes on the new database system --------------------------------------------------------------------------------- - -CHANGES IN THE ARCHITECTURE ---------------------------- - -The architecture is now three-tier. For more information, consult the file doc/new-DB-architecture. - -CHANGES IN THE TABLE STRUCTURE ------------------------------- - -PSVNs have been added to the set_user table, eliminating the need for a separate PSVN table. - -set_user and problem_user tables have been added, containing student-specific data. In most cases, the override fields (marked `!' above) will be empty. The dynamic fields (marked `~' above) will be populated as the student works through problems. problem_seed (in problem_user) and psvn(in set_user) are neither dynamic or override fields -- they are set at assignment time. - -a problem_order field has been added to the set and set_user tables. It contains a definition of how the problems in each set will be ordered. - -RECORD CREATION DEPENDANCIES ----------------------------- - - password -> user - permission -> user - key -> user - user -> - set -> - set_user -> user, set - problem -> set -problem_user -> set_user, problem - -RECORD DELETION DEPENDANCIES ----------------------------- - - password -> - permission -> - key -> - user -> password permission key set_user - set -> set_user problem - set_user -> problem_user - problem -> problem_user -problem_user -> - -TABLE STRUCTURE IMPLEMENTATION IN HASH-BASED DATABASES ------------------------------------------------------- - -The GeneralHash schema module will implement a new table structure implementation for use with WeBWorK 2. Classlist1Hash, Auth1Hash, and WW1Hash will use the old implementation. - -Each table will be stored in a separate database file. Each table has one or more fields that make up a unique identifier for each record. In the case of a one field identifier, the value of that field will be used as the record's key in the hash. In the case of a two-field identifier, the string "FIELD1:FIELD2" will be used. Literal colons will be escaped as `\:', and literal backslashes as `\\'. - -Rather than use a custom encoding scheme for the hash data, as is done in the 1.x implementation, a simple table-based scheme will be used, in which each field is separated by a colon (`:'). Literal colons (and literal backslashes) will be dealt with as above. This sort of scheme is common in the UNIX world. For example, consider the UNIX password file. - -COMPATABILITY WITH 1.X DATABASES --------------------------------- - -By specifying the WW1Hash schema module for the appropriate tables, 1.x databases can be handled. - -Conversion of 1.x databases to 2.x databases can be achieved by using the most popular value for each field in each user-specific record as the global value, and merging PSVNs from the separate PSVN table. Conversion from 2.x databases to 1.x databases is trivial, if somewhat lossy (i.e. problem_order). - -TREATMENT OF THE CURRENT API ----------------------------- - -The current API (implemented by Auth.pm, Classlist.pm, and WW.pm) will be removed. The code base is currently small enough that it will be easy to migrate existing code to the new API. - -NEW API FUNCTIONS ------------------ - -The new API is outlined in the file doc/new-DB-API. diff --git a/doc/devel/new-DB-sql b/doc/devel/new-DB-sql deleted file mode 100644 index cdd3877fed..0000000000 --- a/doc/devel/new-DB-sql +++ /dev/null @@ -1,79 +0,0 @@ -# Feed this file to the mysql client to create a course database. Replace the -# string !!COURSENAME!! with the name of your course. - -CREATE DATABASE webwork_!!COURSENAME!!; -USE webwork_!!COURSENAME!!; - -CREATE TABLE user ( - user_id VARCHAR(255) NOT NULL PRIMARY KEY, - first_name TEXT, - last_name TEXT, - email_address TEXT, - student_id TEXT, - status TEXT, - section TEXT, - recitation TEXT, - comment TEXT -); - -CREATE TABLE password ( - user_id VARCHAR(255) NOT NULL PRIMARY KEY, - password TEXT -); - -CREATE TABLE permission ( - user_id VARCHAR(255) NOT NULL PRIMARY KEY, - permission INT -); - -CREATE TABLE key_not_a_keyword ( - user_id VARCHAR(255) NOT NULL PRIMARY KEY, - key_not_a_keyword TEXT, - timestamp INT -); - -CREATE TABLE set_not_a_keyword ( - set_id VARCHAR(255) NOT NULL PRIMARY KEY, - set_header TEXT, - problem_header TEXT, - open_date INT, - due_date INT, - answer_date INT -); - -CREATE TABLE set_user ( - user_id VARCHAR(255) NOT NULL, - set_id VARCHAR(255) NOT NULL, - psvn INT NOT NULL PRIMARY KEY AUTO_INCREMENT, - set_header TEXT, - problem_header TEXT, - open_date INT, - due_date INT, - answer_date INT -); - -CREATE TABLE problem ( - set_id VARCHAR(255) NOT NULL, - problem_id VARCHAR(255) NOT NULL, - source_file TEXT, - value INT, - max_attempts INT -); - -CREATE TABLE problem_user ( - user_id VARCHAR(255) NOT NULL, - set_id VARCHAR(255) NOT NULL, - problem_id VARCHAR(255) NOT NULL, - source_file TEXT, - value INT, - max_attempts INT, - problem_seed INT, - status FLOAT, - attempted INT, - last_answer TEXT, - num_correct INT, - num_incorrect INT -); - -GRANT select ON webwork_!!COURSENAME!!.* TO webworkRead@localhost IDENTIFIED BY 'zaqwsxcderfv'; -GRANT select, insert, update, delete ON webwork_!!COURSENAME!!.* TO webworkWrite@localhost IDENTIFIED BY 'qwerfdsazxcv'; diff --git a/doc/devel/new-DB-structure b/doc/devel/new-DB-structure deleted file mode 100644 index 4e42f958b0..0000000000 --- a/doc/devel/new-DB-structure +++ /dev/null @@ -1,69 +0,0 @@ --------------------------------------------------------------------------------- -DB table structure --------------------------------------------------------------------------------- - -The DB API models an underlying table structure that may or may not literally exist, depending on what transformations the schema modules make. However, this is the table structure that is presented to users of the DB module. - -The order of key fields in this list is the order that arguments should be passed to the exists(), get(), and delete() schema functions, and the order of the values in the return value of the list() schema function. - -* indicates a key -=> indicates a relation -! indicates an override field -~ indicates a "dynamic" field (modified by the problem processor) - -password - user_id *,=> user.id - password -permission - user_id *,=> user.id - permission -key - user_id *,=> user.id - key -user - id * - first_name - last_name - email_address - student_id - status - section - recitation - comment -set - id * - set_header - problem_header - open_date - due_date - answer_date - problem_order (not implemented) -set_user - user_id *,=> user.id - set_id *,=> set.id - psvn - set_header ! - problem_header ! - open_date ! - due_date ! - answer_date ! - problem_order ! (not implemented) -problem - id * - set_id *,=> set.id - source_file - value - max_attempts -problem_user - user_id *,=> user.id - set_id *,=> set.id - problem_id *,=> problem.id - source_file ! - value ! - max_attempts ! - problem_seed - status ~ - attempted ~ - last_answer ~ - num_correct ~ - num_incorrect ~ diff --git a/doc/devel/pre-bugzilla-TODO-file b/doc/devel/pre-bugzilla-TODO-file deleted file mode 100644 index eaf799a3ba..0000000000 --- a/doc/devel/pre-bugzilla-TODO-file +++ /dev/null @@ -1,186 +0,0 @@ -################################################################################ -# WeBWorK mod_perl (c) 2000-2002 WeBWorK Project -# $Id: pre-bugzilla-TODO-file,v 1.1 2003-06-03 19:23:03 sh002i Exp $ -################################################################################ - -Son of WeBWorK - TODO list - -DONE write template file from what we drew on the board -DONE normalize files: - - (c) header on all files - - standard order of preamble lines: - 1. (c) header - - 2. package PACKAGENAME; - - 3. short summary of the package (pod's NAME section) - - 4. use - pragmatic modules - 5. use - standard perl modules - 6. use - CPAN modules - 7. use - webwork modules - - ALWAYS use strict and use warnings - - use "use base" rather than "our @ISA" - - no warnings or errors! - - (later on) POD documentation for all files -DONE fix templating code in ContentGenerator, add new escape functions -DONE implement the new template -DONE finish ProblemSets and ProblemSet - --------------------------------------------------------------------------------- - -DONE New interface to PG.pm - DONE - instead of it accessing the database directly, accept $user, - $set, and $problem as arguments (instead of $setName, etc.) - DONE - UPDATE PG.pm'S DOCS!!!!!!!!!!!!! - DONE - Fix Problem.pm and Hardcopy.pm to work with the new interface - - Hardcopy generation - DONE - ad-hoc version of &latex2png in Hardcopy.pm (move later) - DONE - fix code (heh heh) - DONE - fix hardcopySetHeader - - Integration of dvipng method of image generation - N/A - choice between writing to a temporary TeX file from within - dangerousMacros, or queueing the equations up in RAM and - passing them out to the caller (via PG_flags?) who will then - call &latex2png - DONE - write tempfile from dangerousMacros - - PRO: gets it out of RAM - - PRO: no dependancies between equations - - CON: have to coordinate between latex2png and - dangerous macros for tempfile locations - FORGET - queue equations in RAM - - PRO: all file access and external calls happen - outside of the safe compartment. - - PRO: latex2png gets to decide where to put files - - CON: stuff sits in RAM - - CON: uses a package array to queue equations :p - -DONE display of screen set header in ProblemSet.pm (easy) -DONE add -DONE fix alignment of displaymath images -DONE print $pg->{header_text} in head of Problem.pm -DONE remove "set" and "prob" from URL generation, remove s/^(set|prob)//; -DONE remove webwork-dvipng-xxxxx temp directories when finished with them -DONE make "enter" on the problem form trigger "submit answer", not "redisplay" -DONE handling of PG warnings (?!?!?!?!) -DONE time logging -DONE test transaction logging -DONE make static images work -DONE make GD-generated images work -DONE make HTML links work -DONE make images in PDFs work -DONE remove dependancies on Global:: from send_mail_to -DONE preview button in Problem -DONE handle PG errors (and warnings) in Hardcopy -DONE have logout button invalidate key -DONE Options - email address and password - --------------------------------------------------------------------------------- - ->>>>> FOR JANUARY PREVIEW RELEASE <<<<< - -DONE Feedback - need nice modular way of sending email -DONE customize reciever, make object dumps prettier, link to context -DONE Professor - redirect to the old system - pretty die messages (from outside of the translator) -DONE make sure students can't look at not-yet-open problem sets -DONE make answer previews use $displayMode -DONE make preview-on-submit optional - Increase border size on images by a couple pixels -DONE effectiveUser for at least Problem.pm -DONE add a "check answers" button if the user has the apropriate permissions -DONE write a template escape for printing $user, $effectiveUser, &c. nicely - calling logout requires valid key -- doesn't make sense. -DONE hardcopy - allow hardcopy with correct answers (BRANCH) -DONE sort problems by due date -DONE disable "show hint/solution" when there's no hint/solution -DONE results table -DONE part(or blank)/entered/preview/result/messages -DONE don't show messages unless there are some -DONE make displayMode sticky (for nav and siblings) -DONE check TTH mode character encodings on mac - grep for "***" in source, address all issues (hah!) - -mike wants: - problem credit indicator (graphical?) -DONE answers/solutions in hardcopy -DONE turn off verbose debugging in feedback mail -DONE URL on feedback from any module - email address change notification by email -arnie wants: - parse errors in student answers hilited more strongly - ->>>>> FOR SPRING RELEASE <<<<< - - write TeXImage module to take care of TeX image caching and generation - replace direct access of $permissionLevel with calls to Authz - replace hardcoded URL construction with some other method - (unify dispatcher URL parsing and module URL generation) - implement professor pages - have the prof modules be subclasses of the single Professor - ContentGenerator module? more structured? less structured? - THIS WILL TAKE LONGER THAN I THINK IT WILL - implement better (and more unified) file editor - ->>>>> AFTER SPRING RELEASE <<<<< - - MySQL and PostgreSQL database backend - problem library work (?) - PG language work (?) - renderer/frontend/database uncoupling (?) - --------------------------------------------------------------------------------- - -change notes - --------------------------------------------------------------------------------- - -IO functions (dependancies on other IO functions are not listed) - -all variables in the global namespace should be replaced with items in the -%envir hash. all functions that access the envir hash need to be evaluated -from within the safe compartment (i.e. &unrestricted_eval'd). - -includePGtext - $envir{probFileName} -send_mail_to - $REMOTE_HOST - $REMOTE_ADDR - #$Global::smtpServer - #$Global::webmaster - (&Global::wwerror) -read_whole_problem_file -read_whole_file -convertPath stub -getDirDelim stub -getCourseTempDirectory - $envir{tempDirectory} -surePathToTmpFile - #$Global::tmp_directory_permission - #$Global::numericalGroupID - (&Global::wwerror) -fileFromPath -directoryFromPath -createFile -createDirectory -getImageDimmensions - --------------------------------------------------------------------------------- - - Calling LaTeX/dvipng and PDFLaTeX in a nice way - - create LaTeX.pm (?) - - &latex2pdf - - create secure tempdir - - write hardcopy to tex file - - call pdflatex - - move resulting pdf file to tmp/hardcopy/whatever.pdf - - remove tempdir - - &latex2png (dvipng method) - - create secure tempdir - - write equations to tex file - - call latex - - run dvipng (as per ImageGenerator) on dvi file - - move resulting images to tmp/dvipng/whatever.png - - remove tempdir diff --git a/doc/devel/schema-2002 b/doc/devel/schema-2002 deleted file mode 100644 index e449eac1b8..0000000000 --- a/doc/devel/schema-2002 +++ /dev/null @@ -1,77 +0,0 @@ -=> denotes a field which corresponds to the key of another table - --------------------------------------------------------------------------------- -Database: COURSENAME_classlist (i.e. MTH161Q_classlist) --------------------------------------------------------------------------------- - -Table: user -Read by: scoring tools, classlist tools -Written by: classlist tools - - login - last_name - first_name - email_address - student_id - status - section - recitation - comment - -Table: access -Read by: authorization system -Written by: classlist tools - - login - password - permissions - --------------------------------------------------------------------------------- -Database: COURSENAME_webwork --------------------------------------------------------------------------------- - -Table: set -Read by: set lister, problem lister, harcopy generator, problem processor -Written by: set generator - - name - set_header - problem_header - open_date - due_date - answer_date - -Table: problem_SETNAME -Read by: problem lister, hardcopy generator, problem processor, scoring tools -Written by: set generator - - number - source_file - value - max_attempts - -Table: set_user -Read by: same as set table -Written by: set generator - - login => user.login - set => set.name - psvn - problem_order - open_date - due_date - answer_date - -Table: ww_USERNAME -Read by: same as problem_SETNAME table -Written by: set generator, problem processor - - set => set.name - problem => problem.number - max_attempts - problem_seed - status - attempted - last_answer - num_correct - num_incorrect diff --git a/doc/devel/template-escapes b/doc/devel/template-escapes deleted file mode 100644 index 9ddb4f7756..0000000000 --- a/doc/devel/template-escapes +++ /dev/null @@ -1,14 +0,0 @@ -head -path - style = text|image - image = URL of image - text = text separator -links -siblings -nav - style = text|image - imageprefix = prefix to image URL - imagesuffix = suffix to image URL - separator = HTML to place in between links -title -body diff --git a/doc/devel/unified-prof-page-form-notes b/doc/devel/unified-prof-page-form-notes deleted file mode 100644 index b147503bfc..0000000000 --- a/doc/devel/unified-prof-page-form-notes +++ /dev/null @@ -1,32 +0,0 @@ -{ - field_name => { - type => /number|text|date|password|enumerable/, - size => /\d*/, - specificity => /global|user/, - access => /readonly|writeonly|readwrite/, - items => { # Only for enumerable - value => "label", - value => "label", - } - synonyms => { - qr/pattern/ => "value", - qr/pattern/ => "value", - "*" => "value", - } - } -} - -type number, text, longtext, date -label -value -specificity global user - Indicates whether it is a global setting that is overridden for a user, - or a user-only setting that is only overwritten for multiUser editing. -synonyms - Regex-s indicating other values that could mean the same thing as the - keys in the "items" hash. This is for backwards compatibility with the - days when even multi-choice fields were given freeform frontends. - the special value "*" (an illegal regular expression) points to a value - that should be substituted for unrecognized values. The order that the - values are checked against the regular expressions is unspecified, and - probably won't be the order they are given in the source code. diff --git a/doc/parser/README b/doc/parser/README deleted file mode 100644 index 8c62267d3f..0000000000 --- a/doc/parser/README +++ /dev/null @@ -1,145 +0,0 @@ -OVERVIEW: - -This directory contains the documentation for a new -mathematical-expression parser written in perl. It was developed for -use with the WeBWorK on-line homework system, but it can be used in -any perl program. - -The goal was to process vector-valued expressions, but the parser was -designed to be extensible, so that you could add your own functions, -operators, and data types. It is still a work in progress, but should -provide a framework for building more sophisticated expression handling. - -Currenlty, the parser understands: - - - real and complex numbers, - - points, vectors, and matrices (with real or complex entries) - - arbitrary lists of elements - - intervals and unions of intervals - - predefined strings like 'infinity' - -Some other useful features are that you can write sin^2 x for (sin(x))^2 -and sin^-1 x for arcsin(x), and so on. - -Most of the documentation still needs to be written, but you can get some -ideas from the samples in the problems and extensions directories, and by -reading the files in the docs directory. - - -INSTALLATION: - -The parser should already be installed as part of the WeBWorK 2.1 -distribution, so you should not need to install it separately. If you -don't seem to have it installed, then it can be obtained from the -Union CVS repository at - - http://devel.webwork.rochester.edu/twiki/bin/view/Webwork/WeBWorKCVS - -The README file in that directory contains the installation instructions. - - -SAMPLE FILES: - -Sample problems are given in the problems and extensions directories. Move -these to the templates directory of a course where you want to test the -Parser, and move the contents of the macros directory to that course's -macros directory. - -Now try looking at these problems using the Library Browser. Edit the -source to see how they work, and to read the comments within the code -itself. - - -EXAMPLE FILES: - -The 'problems' directory contains several examples that show how to use -Parser within your problem files. - - sample01.pg - Uses the parser to make a string into a formula that you can - evaluate and print in TeX form - - sample02.pg - Shows how to create formulas using perl's usual mathematical - expressions rather than character strings. - - sample03.pg - Shows how to use the parser's differentiation abilities. - - sample04.pg and sample05.pg - Use the parser in conjunction with the graphics macros to generate - function graphs on the fly. These also show how to create a - perl function to evaluate an expression. - - sample06.pg - Shows some simple use of vectors in a problem. - - sample07.pg - Example if using the build-in Real object and its answer - checker - - sample08.pg - Uses complex numbers and the built-in checker - - sample09.pg and sample10.pg - Demonstrates points and vectors and their answer checkers - - sample11.pg and sample12.pg - Shows the answer checkers for intervals and unions. - - sample13.pg, sample14.pg, sample15.pg - Demonstrate various list checkers, including a check for the - word 'NONE', which is a predefined string. - - sample16.pg, sample17.pg, sample18.pg - These show the multi-variable function checker in use (for - functions of the form R->R, R^2->R and R->R^3). - - sample19.pg - Uses the function checker to implement a "constant" that can - be used in formulas. - - sample20.pg - Shows how to use the parser's substitution abilities. - - sample21.pg - Checks for a list of points. - - sample22.pg - Shows how to provide named constants that the student can - use in his answer. - -The 'examples' directory contains samples that show how to extend the -parser to include your own functions, operators, and so on. There are also -some samples of how to call the methods available for Formula objects -generated by the parser, and what some error messages look like. - - 1-function.pg - Adds a single-variable function to the parsers list of functions. - - 2-function.pg - Adds a two-variable function to the parser. - - 3-operator.pg - Adds a binary operator to the parser. (Unary operators are similar.) - - 4-list.pg - Adds a new "list type" object. In this case, it's really an - operation [n,r] that returns n choose r. - - 5-list.pg - Add a new "equality" operator that you can use to handle answers - like "x+y=0". - - 6-precedence.pg - Shows an experimental precedence setting that can be used to make - sin 2x return sin(2x) rather than (sin(2))x. - - 7-context.pg - Shows how to switch contexts (in this case, to complex and to vector - contexts), and how this affects the parsing. - - 8-answer.pg - Implements a simple vector-valued answer checker using the - parser's computation and comparison ability. - diff --git a/doc/parser/docs/ParserAnswerCheckers.pod b/doc/parser/docs/ParserAnswerCheckers.pod deleted file mode 100644 index 10d3f7ad69..0000000000 --- a/doc/parser/docs/ParserAnswerCheckers.pod +++ /dev/null @@ -1,423 +0,0 @@ -=head1 MathObjects-based Answer Checkers - -MathObjects is designed to be used in two ways. First, you can use -it within your perl code when writing problems as a means of making it -easier to handle formulas, and in particular, to genarate be able to -use a single object to produce numeric values, TeX output and answer -strings. This avoids having to type a function three different ways -(which makes maintaining a problem much harder). Since MathObjects -also included vector and complex arthimatic, it is easier to work with -these types of values as well. - -The second reason for MathObjects is to use it to process student -input. This is accomplished through special answer checkers that are -part of the Parser package (rather than the traditional WeBWorK answer -checkers). Checkers are available for all the types of values that -the parser can produce (numbers, complex numbers, infinities, points, -vectors, intervals, unions, formulas, lists of numbers, lists of -points, lists of intervals, lists of formulas returning numbers, lists -of formulas returning points, and so on). - -To use one of these checkers, simply call the ->cmp method of the -object that represents the correct answer. For example: - - $n = Real(sqrt(2)); - ANS($n->cmp); - -will produce an answer checker that matches the square root of two. -Similarly, - - ANS(Vector(1,2,3)->cmp); - -matches the vector <1,2,3> (or any computation that produces it, e.g., -i+2j+3k, or <4,4,4>-<3,2,1>), while - - ANS(Interval("(-inf,3]")->cmp); - -matches the given interval. Other examples include: - - ANS(Infinity->cmp); - ANS(String('NONE')->cmp); - ANS(Union("(-inf,$a) U ($a,inf)")->cmp); - -and so on. - -Formulas are handled in the same way: - - ANS(Formula("x+1")->cmp); - - $a = random(-5,5,1); $b = random(-5,5,1); $x = random(-5,5,1); - $f = Formula("x^2 + $a x + $b")->reduce; - ANS($f->cmp); - ANS($f->eval(x=>$x)->cmp); - - $x = Formula('x'); - ANS((1+$a*$x)->cmp); - - Context("Vector")->variables->are(t=>'Real'); - $v = Formula(""); $t = random(-5,5,1); - ANS($v->cmp); - ANS($v->eval(t=>$t)->cmp); - -and so on. - -Lists of items can be checked as easily: - - ANS(List(1,-1,0)->cmp); - ANS(List(Point($a,$b),Point($a,-$b))->cmp); - ANS(List(Vector(1,0,0),Vector(0,1,1))->cmp); - ANS(Compute("(-inf,2),(4,5)")->cmp); # easy way to get list of intervals - ANS(Formula("x, x+1, x^2-1")->cmp); - ANS(Formula(",,<0,x>")->cmp); - ANS(List('NONE')->cmp); - -and so on. The last example may seem strange, as you could have used -ANS(String('NONE')->cmp), but there is a reason for using this type -of construction. You might be asking for one or more numbers (or -points, or whatever) or the word 'NONE' of there are no numbers (or -points). If you used String('NONE')->cmp, the student would get an -error message about a type mismatch if he entered a list of numbers, -but with List('NONE')->cmp, he will get appropriate error messages for -the wrong entries in the list. - -It is often appropriate to use the list checker in this way even when -the correct answer is a single value, if the student might type a list -of answers. - -On the other hand, using the list checker has its disadvantages. For -example, if you use - - ANS(Interval("(-inf,3]")->cmp); - -and the student enters (-inf,3), she will get a message indicating -that the type of interval is incorrect, while that would not be the -case if - - ANS(List(Interval("(-inf,3]"))->cmp); - -were used. (This is because the student doesn't know how many -intervals there are, so saying that the type of interval is wrong -would inform her that there is only one.) - -The rule of thumb is: the individual checkers can give more detailed -information about what is wrong with the student's answer; the list -checker allows a wider range of answers to be given without giving -away how many answers there are. If the student knows there's only -one, use the individual checker; if there may or may not be more than -one, use the list checker. - -Note that you can form lists of formulas as well. The following all -produce the same answer checker: - - ANS(List(Formula("x+1"),Formula("x-1"))->cmp); - - ANS(Formula("x+1,x-1")->cmp); # easier - - $f = Formula("x+1"); $g = Formula("x-1"); - ANS(List($f,$g)->cmp); - - $x = Formula('x'); - ANS(List($x+1,$x-1)->cmp); - -See the files in webwork2/doc/parser/problems for more -examples of using the parser's answer checkers. - -=head2 Controlling the Details of the Answer Checkers - -The action of the answer checkers can be modified by passing flags to -the cmp() method. For example: - - ANS(Real(pi)->cmp(showTypeWarnings=>0)); - -will prevent the answer checker from reporting errors due to the -student entering in the wrong type of answer (say a vector rather than -a number). - -=head3 Flags common to all answer checkers - -There are a number of flags common to all the checkers: - -=over - -=item S1 or 0 >>> - -show/don't show messages about student -answers not being of the right type. -(default: 1) - -=item S1 or 0 >>> - -show/don't show messages produced by -trying to compare the professor and -student values for equality, e.g., -conversion errors between types. -(default: 1) - -=item S1 or 0 >>> - -show/don't show type mismatch errors -produced by strings (so that 'NONE' will -not cause a type mismatch in a checker -looking for a list of numbers, for example). -(default: 1) - -=back - -In addition to these, the individual types have their own flags: - -=head3 Flags for Real()->cmp - -=over - -=item S1 or 0 >>> - -Don't report type mismatches if the -student enters an infinity. -(default: 1) - -=back - -=head3 Flags for String()->cmp - -=over - -=item Svalue >>> - -Specifies the type of object that -the student should be allowed to enter -(in addition the string). -(default: 'Value::Real') - -=back - -=head3 Flags for Point()->cmp - -=over - -=item S1 or 0 >>> - -show/don't show messages about the -wrong number of coordinates. -(default: 1) - -=item S1 or 0 >>> - -show/don't show message about -which coordinates are right. -(default: 1) - -=back - -=head3 Flags for Vector()->cmp - -=over - -=item S1 or 0 >>> - -show/don't show messages about the -wrong number of coordinates. -(default: 1) - -=item S1 or 0 >>> - -show/don't show message about -which coordinates are right. -(default: 1) - -=item S1 or 0 >>> - -do/don't allow the student to -enter a point rather than a vector. -(default: 1) - -=item S1 or 0 >>> - -Mark the answer as correct if it -is parallel to the professor's answer. -Note that a value of 1 forces -showCoordinateHints to be 0. -(default: 0) - -=item S1 or 0 >>> - -During a parallel check, mark the -answer as correct only if it is in -the same (not the opposite) -direction as the professor's answer. -(default: 0) - -=back - -=head3 Flags for Matrix()->cmp - -=over - -=item S1 or 0 >>> - -show/don't show messages about the -wrong number of coordinates. -(default: 1) - -=back - -The default for showEqualErrors is set to 0 for Matrices, since -these errors usually are dimension errors, and that is handled -separately (and after the equality check). - -=head3 Flags for Interval()->cmp - -=over - -=item S1 or 0 >>> - -do/don't show messages about which -endpoints are correct. -(default: 1) - -=item S1 or 0 >>> - -do/don't show messages about -whether the open/closed status of -the enpoints are correct (only -shown when the endpoints themselves -are correct). -(default: 1) - -=back - -=head3 Flags for Union()->cmp and List()->cmp - -all the flags from the Real()->cmp, plus: - -=over - -=item S1 or 0 >>> - -do/don't show messages about which -entries are incorrect. -(default: $showPartialCorrectAnswers) - -=item S1 or 0 >>> - -do/don't show messages about having the -correct number of entries (only shown -when all the student answers are -correct but there are more needed, or -all the correct answsers are among the -ones given, but some extras were given). -(default: $showPartialCorrectAnswers) - -=item S1 or 0 >>> - -do/don't give partial credit for when -some answers are right, but not all. -(default: $showPartialCorrectAnswers) -(currently the default is 0 since WW -can't handle partial credit properly). - -=item S1 or 0 >>> - -give credit only if the student answers -are in the same order as the -professor's answers. -(default: 0) - -=item S'a (name)' >>> - -The string to use in error messages -about type mismatches. -(default: dynamically determined from list) - -=item S'a (name)' >>> - -The string to use in error messages -about numbers of entries in the list. -(default: dynamically determined from list) - -=item Svalue >>> - -Specifies the type of object that -the student should be allowed to enter -in the list (determines what -constitutes a type mismatch error). -(default: dynamically determined from list) - -=item S1 or 0 >>> - -Do/don't require the parentheses in the -student's answer to match those in the -professor's answer exactly. -(default: 1) - -=item S1 or 0 >>> - -Do/don't remove the parentheses from the -professor's list as part of the correct -answer string. This is so that if you -use List() to create the list (which -doesn't allow you to control the parens -directly), you can still get a list -with no parentheses. -(default: 0 for List() and 1 for Formula()) - -=back - -=head3 Flags for Formula()->cmp - -The flags for formulas are dependent on the type of the result of -the formula. If the result is a list or union, it gets the flags -for that type above, otherwise it gets that flags of the Real -type above. - -More flags need to be added in order to allow more control over the -answer checkers to give the full flexibility of the traditional -WeBWorK answer checkers. Note that some things, like whether trig -functions are allowed in the answer, are controlled through the -Context() rather than the answer checker itself. For example, - - Context()->functions->undefine('sin','cos','tan'); - -would remove those three functions from use. (One would need to remove -cot, sec, csc, arcsin, asin, etc., to do this properly; there could be -a function call to do this.) - -Similarly, which arithmetic operations are available is controlled -through Context()->operations. - -The tolerances used in comparing numbers are part of the Context as -well. You can set these via: - - Context()->flags->set( - tolerance => .0001, # the relative or absolute tolerance - tolType => 'relative', # or 'absolute' - zeroLevel => 1E-14, # when to use zeroLevelTol - zeroLevelTol => 1E-12, # smaller than this matches zero - # when one of the two is less - # than zeroLevel - limits => [-2,2], # limits for variables in formulas - num_points => 5, # the number of test points - ); - -[These need to be handled better.] - -Note that for testing formulas, you can override the limits and -num_points settings by setting these fields of the formula itself: - - $f = Formula("sqrt(x-10)"); - $f->{limits} = [10,12]; - - $f = Formula("log(xy)"); - $f->{limits} = [[.1,2],[.1,2]]; # x and y limits - -You can also specify the test points explicitly: - - $f = Formula("sqrt(x-10)"); - $f->{test_points} = [[11],[11.5],[12]]; - - $f = Formula("log(xy)"); - $f->{test_points} = [[.1,.1],[.1,.5],[.1,.75], - [.5,.1],[.5,.5],[.5,.75]]; - -[There still needs to be a means of handling the tolerances similarly, -and through the ->cmp() call itself.] - diff --git a/doc/parser/docs/UsingParser.pod b/doc/parser/docs/UsingParser.pod deleted file mode 100644 index 4ece7c5b90..0000000000 --- a/doc/parser/docs/UsingParser.pod +++ /dev/null @@ -1,401 +0,0 @@ -=head1 USING MATHOBJECTS - -To use MathObjects in your own problems, you need to load the -"MathObjects.pl" macro file: - - loadMacros("Parser.pl"); - -which defines the commands you need to interact with MathObjects. -Once you have done that, you can call the MathObjects functions to create -formulas for you. The main call is Formula(), which takes a string and -returns a parsed version of the string. For example: - - $f = Formula("x^2 + 3x + 1"); - -will set $f to a reference to the parsed version of the formula. - -=head2 Working With Formulas - -A formula has a number of methods that you can call. These include: - -=over - -=item $f->eval(x=>5) - -Evaluate the formula when x is 5. -If $f has more variables than that, then -you must provide additional values, as in -$f->eval(x=>3,y=>1/2); - -=item $f->reduce - -Tries to remove redundent items from your -formula. For example, Formula("1x+0") returns "x". -Reduce tries to factor out negatives and do -some other adjustments as well. (There still -needs to be more work done on this. What it does -is correct, but not always smart, and there need -to be many more situations covered.) All the -reduction rules can be individually enabled -or disabled using the Context()->reduction->set() -method, but the documentation for the various -rules is not yet ready. - -=item $f->substitute(x=>5) - -Replace x by the value 5 throughout (you may want -to reduce the result afterword, as this is not -done automatically). Note that you can replace a -variable by another formula, if you wish. To make -this easier, substitute will apply Formula() to -any string values automatically. E.g., - Formula("x-1")->substitute(x=>"y") - -returns "y-1" as a formula. - -=item $f->string - -returns a string representation of the formula -(should be equivalent to the original, though not -necessarily equal to it). - -=item $f->TeX - -returns a LaTeX representation of the formula. -You can use this in BEGIN_TEXT...END_TEXT blocks -as follows: - - BEGIN_TEXT - Suppose \(f(x) = \{$f->TeX}\). ... - END_TEXT - -=item $f->perl - -returns a representation of the formula that could -be evaluated by perl's eval() function. - -=item $f->perlFunction - -returns a perl code block that can be called to -evaluate the function. For example: - - $f = Formula('x^2 + 3')->perlFunction; - $y = &$f(5); - -will assign the value 28 to $y. -You can also pass a function name to perlFunction -to get a named function to call: - - Formula('x^2 + 3')->perlFunction('f'); - $y = f(5); - -If the formula involves more than one variable, -then the paramaters should be given in -alphabetical order. - - Formula('x^2 + y')->perlFunction('f'); - $z = f(5,3); # $z is 28. - -Alternatively, you can tell the order for the -parameters: - - Formula('x^2 + y')->perlFunction('f',['y','x']); - $z = f(5,3); $ now $z is 14. - -=back - -=head2 Combining Formulas - -There is a second way to create formulas. Once you have a formula, you can -create additional formulas simply by using perls' built-in operations and -functions, which have been overloaded to handle formulas. For example, - - $x = Formula('x'); - $f = 3*x**2 + 2*$x - 1; - -makes $f be a formula, and is equivalent to having done - - $f = Formula("3x^2 + 2x - 1"); - -This can be very convenient, but also has some pitfalls. First, you -need to include '*' for multiplication, since perl doesn't do implied -multiplication, and you must remember to use '**' not '^'. (If you use '^' -on a formula, the parser will remind you to use '**'.) Second, the -precedences of the operators in perl are fixed, and so changes you make to -the precedence table for the parser are not reflected in formulas produced -in this way. (The reason '^' is not overloaded to do exponentiation is -that the precedence of '^' is wrong for that in perl, and can't be -changed.) As long as you leave the default precedences, however, things -should work as you expect. - -Note that the standard functions, like sin, cos, etc, are overloaded to -generate appropriate formulas when their values are formulas. For example, - - $x = Formula('x'); - $f = cos(3*$x + 1); - -produces the same result as $f = Formula("cos(3x+1)"); and you can then go -on to output its TeX form, etc. - -=head2 Special Syntax - -This parser has support for some things that are missing from the current -one, like absolute values. You can say |1+x| rather than abs(1+x) -(though both are allowed), and even |1 - |x|| works. - -Also, you can use sin^2(x) (or even sin^2 x) to get (sin(x))^2. - -Finally, you can use sin^-1(x) to get arcsin(x). - -There is an experimental set of operator precedences that make it possible -to write sin 2x + 3 and get sin(2x) + 3. See examples/7-precedence.pg -for some details. - -=head2 The Formula Types - -The parser understands a wide range of data types, including real and -complex numbers, points, vectors, matrices, arbitrary lists, intervals, -unions of intervals, and predefined words. Each has a syntax for use -within formulas, as described below: - - numbers the usual form: 153, 233.5, -2.456E-3, etc. - - complex a + b i where a and b are numbers: 1+i, -5i, 6-7i, etc. - - infinitites the words 'infinity' or '-infinity' (or several - equivalents). - - point (a,b,c) where a, b and c are real or complex numbers. - any number of coordinates are allowed. Eg, (1,2), - (1,0,0,0), (-1,2,-3). Points are promoted to vectors - automatically, when necessary. - - vector or a i + b j + c k (when used in vector context). - As with points, vectors can have any number of - coordinates. For example, <1,0,0>, <-1,3>, , etc. - - matrix [[a11,...,a1n],...[am1,...amn]], i.e., use [..] around - each row, and around the matrix itself. The elements - are separated by commas (not spaces). e.g, - [[1,2],[3,4]] (a 2x2 matrix) - [1,2] (a 1x2 matrix, really a vector) - [[1],[2]] (a 2x1 matrix, ie. column vector) - Points and vectors are promoted to matrices when - appropriate. Vectors are converted to column vectors - when needed for matrix-vector multiplication. Matrices - can be 3-dimensional or higher by repeated nesting of - matrices. (In this way, a 2-dimensional matrix is really - thought of as a vector of vectors, and n-dimensional - ones as vectors of (n-1)-dimensional ones.) - - list (a,b,c) where a,b,c are arbitrary elements. - For example, (1+i, -3, <1,2,3>, Infinity). - The empty list () is allowed, and the parentheses are - optional if there is only one list. (This makes it - possible to make list-based answer checkers that - really know where the separations occur.) - - interval (a,b), (a,b], [a,b), [a,b], or [a,a] where a and b are - numbers or appropriate forms of infinity. - For example, (-INF,3], [4,4], [2,INF), (-INF,INF). - - union represented by 'U'. For example [-1,0) U (0,1]. - - string special predefined strings like NONE and DNE. - -These forms are what are used in the strings passed to Formula(). -If you want to create versions of these in perl, there are several -ways to do it. One way is to use the Compute() command, which takes a -string parses it and then evaluates the result (it is equivalent to -Formula(...)->eval). If the formula produces a vector, the result -will be a Vector constant that you can use in perl formulas by hand. - -For example: - - $v = Compute("<1,1,0> >< <-1,4,-2>"); - -would compute the dot product of the two vectors and assign the -resulting vector object to $v. - -Another way to generate constants of the various types is to use the -following routines. If their inputs are constant, they produce a -constant of the appropriate type. If an input is a formula, they -produce corresponding formula objects. - - Real(a) create a real number with "fuzzy" - comparisons (so that 1.0000001 == Real(1) is true). - - Complex(a,b) create a complex number a + b i - - Infinity creates the +infinity object - -(Infinity) creates -infinity - - Point(x1,...xn) or Point([x1,...,xn]) produces (x1,...,xn) - - Vector(x1,...,xn) or Vector([x1,...,xn]) produces - - Matrix([a11,...,a1m],...,[am1,...,amn]) or - Matrix([[a11,...,a1m],...,[am1,...,amn]]) produces an n x m matrix - - List(a,...,b) produces a list with the given elements - - Interval('(',a,b,']') produces (a,b], (the other endpoints work as - expected. Use 'INF' and '-INF' for infinities.) - - Union(I1,...,In) takes the union of the n intervals. (where I1 to In - are intervals.) - - String(word) Produces a string object for the given word (if it - is a known word). This is mostly to be able to - call the ->cmp and ->TeX methods. - -For example, - - $a = random(-5,5,1) - $V = Vector($a,1-$a,$a**2+1); - -produces a vector with some random coordinates. - -Objects of these types also have TeX, string and perl methods, so you can -use: - - Vector(1,2,3)->TeX - -to produce a TeX version of the vector, just as you can with formulas. - -There are several "constant" functions that generate common constant -values. These include pi, i, j, k and Infininty. you can use these -in perl expressions as though they were their actual values: - - $z = $a + $b * i; - $v = $a*i + $b*j + $c*k; - $I = Infinity; - -Note that because of a peculiarity of perl, you need to use -(pi) -or - pi (with a space) rather than -pi, and similary for the other -functions. Without this, you will get an error message about an -ambiguity being resolved. (This is not a problem if you process your -expressions through the parser itself, only if you are writing -expressions in perl directly. Note that since student answers are -processed by the parser, not perl directly, they can write -pi without -problems.) - -Another useful command is Compute(), which evaluates a formula and -returns its value. This is one way to create point or vector-valued -constants, but there is an easier way discussed below. - -=head2 Specifying the Context - -You may have noticed that "i" was used in two different ways in the -examples above. In the first example, it was treated as a complex -number and the second as a coordinate unit vector. To control which -interpretation is used, you specify a parser "context". - -The context controls what operations and functions are defined in the -parser, what variables and constants to allow, how to interpret -various paretheses, and so on. Changing the context can completely -change the way a formula is interpreted. - -There are several predefined contexts: Numeric, Complex, Vector, -Interval and Full. (You can also define your own contexts, but that -will be described elsewhere.) To select a context, use the Context() -function, e.g. - - Context("Numeric"); - -selects the numeric context, where i, j and k have no special meaning, -points and vectors can't be used, and the only predefined variable is -'x'. - -On the other hand, Context("Vector") makes i, j and k represent the -unit coordinate vectors, and defines variables 'x', 'y' and 'z'. - -Context("Interval") is like numeric context, but it also defines the -parentheses so that they will form intervals (rather than points or -lists). - -Once you have selected a context, you can modify it to suit the -particular needs of your problem. The command - - $context = Context(); - -gets you a reference to the current context object (you can also use -something like - - $context = Context("Numeric"); - -to set the context and get its reference at the same time). Once you -have this reference, you can call the Context methods to change values -in the context. These are discussed in more detail in the -documentation of the Context object [not yet written], but some of the -more common actions are described here. - -To add a variable, use, for example, - - $context->variables->add(y=>'Real'); - -To delete any existing variables and replace them with new ones, use - - $context->variables->are(t=>'Real'); - -To remove a variable, use - - $context->variables->remove('t'); - -To get the names of the defind variables, use - - @names = $context->variables->names; - - -Similarly, you can add a named constant via - - $context->constants->add(M=>1/log(10)); - -and can change, remove or list the constants via methods like those -used for variables above. The command - - $M = $context->constants->get('M'); - -will return the value of the consant M. (See the -pg/lib/Value/Context/Data.pm file for more information on the methods -you can call for the various types of context data.) - -To add new predefined words (like 'NONE' and 'DNE'), use something -like - - $context->strings->add(TRUE=>{},FALSE=>{}); - -Strings are case-insensitive, unless you say otherwise. To mark a -string as being case-senstive, use - - $context->strings->add(TRUE => {caseSensitive=>1}); - -You may want to privide several forms for the same word; to do so, -make the additional words into aliases: - - $context->strings->add( - T => {alias=>'TRUE'}, - F => {alias=>'FALSE'}, - ); - -so that either "TRUE" or "T" will be interpreted as TRUE, and -similarly for "FALSE" and "F"; - -There are a number of values stored in the context that control things -like the tolerance used when comparing numbers, and so on. You -control these via commands like: - - $context->flags->set(tolerance=>.00001); - -For example, - - $context->flags->set(ijk=>1); - -will cause the output of all vectors to be written in ijk format -rather than <...> format. - -Finally, you can add or modify the operators and functions that are -available in the parser via calls to $context->operators and -$context->functions. See the files in webwork2/docs/parser/extensions -for examples of how to do this. - diff --git a/doc/parser/extensions/1-function.pg b/doc/parser/extensions/1-function.pg deleted file mode 100644 index 1a4179becf..0000000000 --- a/doc/parser/extensions/1-function.pg +++ /dev/null @@ -1,79 +0,0 @@ -########################################################## -# -# Example showing how to add a new single-variable function to the Parser -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserTables.pl", -); - -########################################################### -# -# Use standard numeric mode -# -Context('Numeric'); - -############################################# -# -# Create a 'log2' function to the Parser for log base 2 -# - -package MyFunction1; -our @ISA = qw(Parser::Function::numeric); # this is what makes it R -> R - -sub log2 { - shift; my $x = shift; - return CORE::log($x)/CORE::log(2); -} - -package main; - -# -# Make it work on formulas as well as numbers -# -sub log2 {Parser::Function->call('log2',@_)} - -# -# Add the new functions into the Parser -# - -Context()->functions->add( - log2 => {class => 'MyFunction1', TeX => '\log_2'}, # fancier TeX output -); - -$x = Formula('x'); - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -In this problem, we have added a new function to the Parser: ${BTT}log2(x)${ETT}. -(Edit the code to see how this is done.) -$PAR -Assuming that ${BTT}${DOLLAR}x = Formula('x')${ETT}, it can be used as follows: -$PAR - -\{ParserTable( - 'Formula("log2(x)")', - 'log2(8)', - 'log2($x+1)', - 'Formula("log2(x)")->eval(x=>16)', - '(log2($x))->eval(x=>16)', - 'Formula("log2()")', - 'Formula("log2(1,x)")', - 'log2()', - 'log2(1,3)', - )\} - -END_TEXT - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/extensions/2-function.pg b/doc/parser/extensions/2-function.pg deleted file mode 100644 index 2c68409c74..0000000000 --- a/doc/parser/extensions/2-function.pg +++ /dev/null @@ -1,79 +0,0 @@ -########################################################## -# -# Example showing how to add a new two-variable function to the Parser -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserTables.pl", -); - -########################################################### -# -# Use standard numeric mode -# -Context('Numeric'); - -############################################# -# -# Create a "Combinations" function -# - -package MyFunction2; -our @ISA = qw(Parser::Function::numeric2); # this is what makes it R^2 -> R - -sub C { - shift; my ($n,$r) = @_; my $C = 1; - $r = $n-$r if ($r > $n-$r); # find the smaller of the two - for (1..$r) {$C = $C*($n-$_+1)/$_} - return $C -} - -package main; - -# -# Make it work on formulas as well as numbers -# -sub C {Parser::Function->call('C',@_)} - -# -# Add the new functions into the Parser -# - -Context()->functions->add(C => {class => 'MyFunction2'}); - -$x = Formula('x'); - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -In this problem, we have added a new function to the Parser: ${BTT}C(n,r)${ETT}. -(Edit the code to see how this is done). -$PAR -Assuming that ${BTT}${DOLLAR}x = Formula('x')${ETT}, it can be used as follows: -$PAR - -\{ParserTable( - 'Formula("C(x,3)")', - 'C(6,2)', - 'C($x,3)', - 'Formula("C(x,3)")->eval(x=>6)', - '(C($x,2))->eval(x=>6)', - 'Formula("C(x)")', - 'Formula("C(1,2,3)")', - 'C(1)', - 'C(1,2,3)', - )\} - -END_TEXT - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/extensions/3-operator.pg b/doc/parser/extensions/3-operator.pg deleted file mode 100644 index 1fa295c30a..0000000000 --- a/doc/parser/extensions/3-operator.pg +++ /dev/null @@ -1,109 +0,0 @@ -########################################################## -# -# Example showing how to add new operators to the Parser -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserTables.pl", -); - -########################################################## -# -# Define our own binary operator -# - -package MyOperator; -our @ISA = qw(Parser::BOP); # subclass of Binary OPerator - -# -# Check that the operand types are numbers. -# -sub _check { - my $self = shift; my $name = $self->{bop}; - return if $self->checkNumbers(); - $self->Error("Operands of '$name' must be Numbers"); -} - -# -# Compute the value of n choose r. -# -sub _eval { - shift; my ($n,$r) = @_; my $C = 1; - $r = $n-$r if ($r > $n-$r); # find the smaller of the two - for (1..$r) {$C = $C*($n-$_+1)/$_} - return $C -} - -# -# Non-standard TeX output -# -sub TeX { - my $self = shift; - return '{'.$self->{lop}->TeX.' \choose '.$self->{rop}->TeX.'}'; -} - -# -# Non-standard perl output -# -sub perl { - my $self = shift; - return '(MyOperator->_eval('.$self->{lop}->perl.','.$self->{rop}->perl.'))'; -} - -package main; - -########################################################## -# -# Add the operator into the current context -# - -$prec = Context()->operators->get('+')->{precedence} - .25; - -Context()->operators->add( - '#' => { - class => 'MyOperator', - precedence => $prec, # just below addition - associativity => 'left', # computed left to right - type => 'bin', # binary operator - string => ' # ', # output string for it - TeX => '\mathop{\#}', # TeX version (overridden above, but just an example) - } -); - - -$CHOOSE = MODES(TeX => '\#', HTML => '#'); - - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -In this problem, we have added a new operator to the Parser: ${BTT}n $CHOOSE r${ETT}, -which returns \(n\choose r\). -$PAR - -\{ParserTable( - 'Formula("x # y")', - 'Formula("x+1 # 5")', - 'Formula("x # 5")->eval(x=>7)', - 'Formula("(x#5)+(x#4)")', - 'Formula("x#5+x#4")', - 'Formula("x # y")', - 'Formula("x # y")->substitute(x=>5)', - 'Formula("x # y")->eval(x=>5,y=>2)', - 'Formula("x # y")->perlFunction(~~'C~~'); C(5,2)', - 'Formula("1 # ")', - )\} - -END_TEXT - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/extensions/4-list.pg b/doc/parser/extensions/4-list.pg deleted file mode 100644 index 0184117b22..0000000000 --- a/doc/parser/extensions/4-list.pg +++ /dev/null @@ -1,102 +0,0 @@ -########################################################## -# -# Example showing how to add a new list-type object -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserTables.pl", -); - -########################################################## -# -# Define our own [n,r] notation for n choose r -# - -package MyChoose; -our @ISA = qw(Parser::List); # subclass of List - -# -# Check that two numbers are given -# -sub _check { - my $self = shift; - $self->{type}{list} = 0; # our result is a single number, not really a list - $self->Error("You need two numbers within '[' and ']'") - if ($self->{type}{length} < 2); - $self->Error("Only two numbers can appear within '[' and ']'") - if ($self->{type}{length} > 2); - my ($n,$r) = @{$self->{coords}}; - $self->Error("The arguments for '[n,r]' must be numbers") - unless ($n->type eq 'Number' && $r->type eq 'Number'); - $self->{type} = $Value::Type{number}; -} - -# -# Compute n choose r -# -sub _eval { - shift; my ($n,$r) = @_; my $C = 1; - $r = $n-$r if ($r > $n-$r); # find the smaller of the two - for (1..$r) {$C = $C*($n-$_+1)/$_} - return $C -} - -# -# Non-standard TeX output -# -sub TeX { - my $self = shift; - return '{'.$self->{coords}[0]->TeX.' \choose '.$self->{coords}[1]->TeX.'}'; -} - -# -# Non-standard perl output -# -sub perl { - my $self = shift; - return '(MyChoose->_eval('.$self->{coords}[0]->perl.','.$self->{coords}[1]->perl.'))'; -} - - -package main; - -########################################################## -# -# Add the new list to the context -# - -Context()->lists->add(Choose => {class => 'MyChoose'}); -Context()->parens->replace('[' => {close => ']', type => 'Choose'}); - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -In this problem, we have added a new list to the Parser: ${BTT}[n,r]${ETT}, -which returns \(n\choose r\). -$PAR - -\{ParserTable( - 'Formula("[x,3]")', - 'Formula("[5,3]")', - 'Formula("[x,3]")->eval(x=>5)', - '$C = Formula("[x,y]"); $C->substitute(x=>5)', - 'Formula("[x,y]")->perlFunction("C"); C(5,3)', - 'Formula("[x,y,3]")', - 'Formula("[x]")', - 'Formula("[x,[y,2]]")', - 'Formula("[x,<1,2>]")', - )\} - -END_TEXT - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/extensions/5-operator.pg b/doc/parser/extensions/5-operator.pg deleted file mode 100644 index 60dea5eb2b..0000000000 --- a/doc/parser/extensions/5-operator.pg +++ /dev/null @@ -1,85 +0,0 @@ -########################################################## -# -# Example of how to implement equalities in the Parser -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserTables.pl", -); - -########################################################## -# -# Define our own operator for equality -# - -package Equality; -our @ISA = qw(Parser::BOP); # subclass of Binary OPerator - -# -# Check that the operand types are numbers. -# -sub _check { - my $self = shift; my $name = $self->{bop}; - $self->Error("Only one equality is allowed in an equation") - if ($self->{lop}->class eq 'Equality' || $self->{rop}->class eq 'Equality') ; - $self->Error("Operands of '$name' must be Numbers") unless $self->checkNumbers(); - $self->{type} = Value::Type('Equality',1); # Make it not a number, to get errors with other operations. -} - -# -# Determine if the two sides are equal -# -sub _eval {return ($_[1] == $_[2])? 1: 0} - -package main; - -# -# Add the operator into the current context -# - -$prec = Context()->operators->get(',')->{precedence} + .25; - -Context()->operators->add( - '=' => { - class => 'Equality', - precedence => $prec, # just above comma - associativity => 'left', # computed left to right - type => 'bin', # binary operator - string => '=', # output string for it - perl => '==', # perl string - } -); - - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -In this problem, we have added a new operator to the Parser: ${BTT} a -= b${ETT}, for equality. -$PAR - -\{ParserTable( - 'Formula("x + y = 0")', - 'Formula("x + y = 0")->{tree}->class', - 'Formula("x + y = 0")->{tree}{lop}', - 'Formula("x + y = 0")->{tree}{rop}', - 'Formula("x + y = 0")->eval(x=>2,y=>3)', - 'Formula("x + y = 0")->eval(x=>2,y=>-2)', - 'Formula("x + y = 0 = z")', - 'Formula("(x + y = 0) + 5")', - 'Formula("x + y = 0, 3x-y = 4")', # you CAN get a list of equalities - )\} - -END_TEXT - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/extensions/6-precedence.pg b/doc/parser/extensions/6-precedence.pg deleted file mode 100644 index 5997a2a838..0000000000 --- a/doc/parser/extensions/6-precedence.pg +++ /dev/null @@ -1,80 +0,0 @@ -########################################################## -# -# Example of the non-standard precedences as a possible alternative -# that makes it possible to write "sin 2x" and get "sin(2x)" -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserTables.pl", -); - -########################################################## -# -# Use standard precedences for multiplication -# - -Context()->usePrecedence("Standard"); - -$standard = ParserTable( - 'Formula("sin 2xy/3")', - 'Formula("sin 2x y/3")', - 'Formula("sin 2x y / 3")', - 'Formula("sin 2x+5")', - 'Formula("sin x(x+1)")', - 'Formula("sin x (x+1)")', - 'Formula("1/2xy")', - 'Formula("1/2 xy")', - 'Formula("1/2x y")', - 'Formula("sin^2 x")', - 'Formula("sin^(-1) x")', - 'Formula("x^2x")', -); - -Context()->usePrecedence("Non-Standard"); - -$nonstandard = ParserTable( - 'Formula("sin 2xy/3")', - 'Formula("sin 2x y/3")', - 'Formula("sin 2x y / 3")', - 'Formula("sin 2x+5")', - 'Formula("sin x(x+1)")', - 'Formula("sin x (x+1)")', - 'Formula("1/2xy")', - 'Formula("1/2 xy")', - 'Formula("1/2x y")', - 'Formula("sin^2 x")', - 'Formula("sin^(-1) x")', - 'Formula("x^2x")', -); - - - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -In this problem, we compare the standard and non-standard precedences for -multiplication. -$PAR - -\{Title("The Non-Standard precedences:")\} -$PAR -$nonstandard -$PAR$BR - -\{Title("The Standard precedences:")\} -$PAR -$standard - -END_TEXT - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/extensions/7-context.pg b/doc/parser/extensions/7-context.pg deleted file mode 100644 index 191c22d2c7..0000000000 --- a/doc/parser/extensions/7-context.pg +++ /dev/null @@ -1,82 +0,0 @@ -########################################################## -# -# Example showing how to switch different contexts -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserTables.pl", -); - -BEGIN_TEXT - -In this problem, we compare formulas in complex and vector contexts. -Note the difference between how ${BTT}i${ETT} is treated in the two -contexts. Note that 'Number' comprises both real and complex numbers. -$PAR - -Assuming that ${BTT}${DOLLAR}x = Formula('x')${ETT}, it can be used as follows: -$PAR - -END_TEXT - -$x = Formula('x'); - -########################################################## -# -# Use Complex context -# - -Context('Complex'); - -BEGIN_TEXT -\{Title("The Complex context:")\} -$PAR -\{ParserTable( - 'i', - 'Formula("1+3i")', - 'Formula("x+3i")', - '1 + 3*i', - '$x + 3*i', - '$z = tan(2*i)', - 'Formula("sinh(zi)")', - 'Formula("3i+4j-k")', - 'Formula("3i+4j-k")->eval', - '3*i + 4*j - k', -)\} -$PAR$BR -END_TEXT - - -########################################################## -# -# Use Vector context -# - -Context('Vector'); - -BEGIN_TEXT -\{Title("The Vector context:")\} -$PAR -\{ParserTable( - 'i', - 'Formula("1+3i")', - 'Formula("x+3i")', - '1 + 3*i', - '$x + 3*i', - '$z = tan(2*i)', - 'Formula("sinh(zi)")', - 'Formula("3i+4j-k")', - 'Formula("3i+4j-k")->eval', - '3*i + 4*j - k', -)\} - -END_TEXT - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/extensions/8-answer.pg b/doc/parser/extensions/8-answer.pg deleted file mode 100644 index 9a45e3b720..0000000000 --- a/doc/parser/extensions/8-answer.pg +++ /dev/null @@ -1,110 +0,0 @@ -########################################################## -# -# Example showing an answer checker that uses the parser -# to evaluate the student (and professor's) answers. -# -# This is now obsolete, as the paser's ->cmp method -# can be used to produce an answer checker for any -# of the parser types. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -########################################################## -# -# Use Vector context -# - -Context('Vector'); - -########################################################## -# -# Make the answer checker -# -sub vector_cmp { - my $v = shift; - die "vector_cmp requires a vector argument" unless defined $v; - my $v = Vector($v); # covert to vector if it isn't already - my $ans = new AnswerEvaluator; - $ans->ans_hash(type => "vector",correct_ans => $v->string, vector=>$v); - $ans->install_evaluator(~~&vector_cmp_check); - return $ans; -} - -sub vector_cmp_check { - my $ans = shift; my $v = $ans->{vector}, - $ans->score(0); # assume failure - my $f = Parser::Formula($ans->{student_ans}); - my $V = Parser::Evaluate($f); - if (defined $V) { - $V = Formula($V) unless Value::isValue($V); # make sure we can call Value methods - $ans->{preview_latex_string} = $f->TeX; - $ans->{preview_text_string} = $f->string; - $ans->{student_ans} = $V->string; - if ($V->type eq 'Vector') { - $ans->score(1) if ($V == $v); # Let the overloaded == do the check - } else { - $ans->{ans_message} = $ans->{error_message} = - "Your answer doesn't seem to be a Vector (it looks like ".Value::showClass($V).")" - unless $inputs_ref->{previewAnswers}; - } - } else { - # - # Student answer evaluation failed. - # Report the error, with formatting, if possible. - # - my $context = Context(); - my $message = $context->{error}{message}; - if ($context->{error}{pos}) { - my $string = $context->{error}{string}; - my ($s,$e) = @{$context->{error}{pos}}; - $message =~ s/; see.*//; # remove the position from the message - $ans->{student_ans} = protectHTML(substr($string,0,$s)) . - '' . - protectHTML(substr($string,$s,$e-$s)) . - '' . - protectHTML(substr($string,$e)); - } - $ans->{ans_message} = $ans->{error_message} = $message; - } - return $ans; -} - -########################################################## -# -# The problem text -# - -$V = Vector(1,2,3); - -Context()->flags->set(ijk=>0); -Context()->constants->add(a=>1,b=>1,c=>1); - -$ABC = Formula(""); - -BEGIN_TEXT -Enter the vector \(\{$V->TeX\}\) in any way you like: \{ans_rule(20)\}. -$PAR -You can use either \(\{$ABC->TeX\}\) or \(\{$ABC->ijk\}\) notation,$BR -and can perform vector operations to produce your answer. -$PAR -${BBOLD}Note:${EBOLD} This problem is obsolete. -END_TEXT - -########################################################### -# -# The answer -# - -ANS(vector_cmp($V)); - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample01.pg b/doc/parser/problems/sample01.pg deleted file mode 100644 index 2d810de877..0000000000 --- a/doc/parser/problems/sample01.pg +++ /dev/null @@ -1,64 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser to make -# a formula that you can evaluate and print in TeX form. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################### -# -# Use standard numeric mode -# -Context('Numeric'); - -# -# Define some functions -# -$a = non_zero_random(-8,8,1); -$b = random(1,8,1); - -@f = ( - "1 + $a*x + $b x^2", - "$a / (1 + $b x)", - "$a x^3 + $b", - "($a - x) / ($b + x^2)" -); - -# -# Pick one at random -# -$k = random(0,$#f,1); -$f = Formula($f[$k])->reduce; - -# -# Where to evaluate it -# -$x = random(-5,5,1); - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -If \(\displaystyle f(x) = \{$f->TeX\}\) then \(f($x)=\) \{ans_rule(10)\}. - -END_TEXT - -########################################################### -# -# The answer -# -ANS(num_cmp($f->eval(x=>$x))); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample02.pg b/doc/parser/problems/sample02.pg deleted file mode 100644 index b57c1fadb2..0000000000 --- a/doc/parser/problems/sample02.pg +++ /dev/null @@ -1,57 +0,0 @@ -########################################################### -# -# Example showing how you can use perl expressions (not -# just character strings) to generate formulas. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################### -# -# Use standard numeric mode -# -Context('Numeric'); -$x = Formula('x'); # used to construct formulas below. - -# -# Define a function and its derivative and make them pretty -# -$a = random(1,8,1); -$b = random(-8,8,1); -$c = random(-8,8,1); - -$f = ($a*$x**2 + $b*$x + $c) -> reduce; -$df = (2*$a*$x + $b) -> reduce; - -$x = random(-8,8,1); - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -Suppose \(f(x) = \{$f->TeX\}\). -$PAR -Then \(f'(x)=\) \{ans_rule(20)\},$BR -and \(f'($x)=\) \{ans_rule(20)\}. - -END_TEXT - -########################################################### -# -# The answers -# -ANS(fun_cmp($df->string)); -ANS(num_cmp($df->eval(x=>$x))); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample03.pg b/doc/parser/problems/sample03.pg deleted file mode 100644 index d473412c87..0000000000 --- a/doc/parser/problems/sample03.pg +++ /dev/null @@ -1,61 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser's differentiation -# capabilities. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "Differentiation.pl", -); - -########################################################### -# -# Use standard numeric mode -# -Context('Numeric'); -$x = Formula('x'); # used to construct formulas below. - -# -# Define a function and its derivative and make them pretty -# -$a = random(1,8,1); -$b = random(-8,8,1); -$c = random(-8,8,1); - -$f = ($a*$x**2 + $b*$x + $c) -> reduce; -$df = $f->D('x'); - -$x = random(-8,8,1); - -########################################################### -# -# The problem text -# -BEGIN_TEXT - -Suppose \(f(x) = \{$f->TeX\}\). -$PAR -Then \(f'(x)=\) \{ans_rule(20)\},$BR -and \(f'($x)=\) \{ans_rule(20)\}. -$PAR -(Same as previous problem, but using the formal differentiation package. -Note that automatic differentiation does not always produce the simples form.) - -END_TEXT - -########################################################### -# -# The answers -# -ANS(fun_cmp($df->string)); -ANS(num_cmp($df->eval(x=>$x))); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample04.pg b/doc/parser/problems/sample04.pg deleted file mode 100644 index ea39ec7192..0000000000 --- a/doc/parser/problems/sample04.pg +++ /dev/null @@ -1,113 +0,0 @@ -################################################################ -# -# Example showing how to use the Parser to create functions you -# can call from perl, to substitute values into a formula, and to -# convert a formula to a form that can be used in graphics generated -# on the fly. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PG.pl", - "PGbasicmacros.pl", - "PGanswermacros.pl", - "PGgraphmacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -############################################## -# -# The setup -# - -Context('Vector'); -Context()->variables->add(a => 'Real', b => 'Real'); -$a = non_zero_random(-4,-1,1); -$b = non_zero_random(-3,3,1); - -# -# The function to plot -# -$f = Formula("ax^2 + by"); # the function to display - -# -# Traces to show -# -$x = non_zero_random(-1,1,1); -$y = non_zero_random(-1,1,1); - -# -# Graph domain and size -# -($xm,$xM) = (-2,2); -($ym,$yM) = (-2,2); -($zm,$zM) = (-5,5); -$size = [200,300]; -$tex_size = 350; - -############################################## - -# -# The plot defaults -# -@Goptions = ( - $ym,$zm,$yM,$zM, # dimensions of graph - axes => [0,0], grid => [$yM-$ym,$zM-$zm], # number of grid lines - size => $size # pixel dimension -); -@imageoptions = (size=>$size, tex_size=>$tex_size); - -$xdomain = "x in <$xm,$xM>"; -#$ydomain = "y in <$ym,$yM>"; # plot_functions only handles variable x -$ydomain = "x in <$ym,$yM>"; -$plotoptions = "using color:red and weight:2"; - -# -# Make the traces -# -$fx = $f->substitute(x=>$x, a=>$a, b=>$b, y=>'x')->reduce; # must have variable x -$Gx = init_graph(@Goptions); -plot_functions($Gx,"$fx for $ydomain $plotoptions"); -$Xtrace = Image($Gx,@imageoptions); - -$fy = $f->substitute(y=>$y, a=>$a, b=>$b)->reduce; -$Gy = init_graph(@Goptions); -plot_functions($Gy,"$fy for $xdomain $plotoptions"); -$Ytrace = Image($Gy,@imageoptions); - -# -# Make the table of images -# -@rowopts = (indent=>0, separation=>30); -$Images = - BeginTable(). - AlignedRow([$Xtrace,$Ytrace], @rowopts). - AlignedRow(["Trace for \(x=$x\)","Trace for \(y=$y\)"], @rowopts). - EndTable(); - -############################################## - -BEGIN_TEXT - -The graphs below are traces for a function \(f(x,y)\) at \(x=$x\) and -\(y=$y\). -$PAR - -$Images -$PAR - -If \(f(x,y) = \{$f->TeX\}\) then -\(a\) = \{ans_rule(6)\} and \(b\) = \{ans_rule(6)\}. - -END_TEXT - -################################################## - -ANS(std_num_cmp($a)); -ANS(std_num_cmp($b)); - -################################################## - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample05.pg b/doc/parser/problems/sample05.pg deleted file mode 100644 index 35ba15f09f..0000000000 --- a/doc/parser/problems/sample05.pg +++ /dev/null @@ -1,137 +0,0 @@ -################################################################ -# -# A more complex example showing how to use the Parser to create -# functions you can call from perl, to substitute values into a -# formula, and to convert a formula to a form that can be used in -# graphics generated on the fly. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "PGgraphmacros.pl", - "PGauxiliaryFunctions.pl", - "Parser.pl", - "parserUtils.pl", -); - -############################################## -# -# The setup -# - -Context('Vector'); -Context()->variables->add(a => 'Real', b => 'Real'); - -$c = non_zero_random(-1,1,1); -$a = $c*random(2,5,1)/2; -$b = -$c*random(2,5,1)/2; - -# -# The function to plot -# -$f = Formula("a x^2 y + b x y^2"); -$f->substitute(a=>$a,b=>$b)->perlFunction('f'); - -# -# Traces to show -# -$x1 = non_zero_random(-2,2,1); $x1 /= 2 if (abs($b) >= 2 && abs($x1) == 2); -$x2 = non_zero_random(-2,2,1); $x2 /= 2 if (abs($a) >= 2 && abs($x2) == 2); - -$x = max(.5,min(3,round(-2*$b*$x2/$a)/2)); -$y = max(.5,min(3,round(-2*$a*$x1/$b)/2)); - -# -# Points to show -# -$xv = round(-$b*$y/$a/2); $xv = 1 if ($xv == 0); -$fxv = f($xv,$y); if (abs($fxv) < .75) {$xv = -$xv; $fxv = f($xv,$y)} - -$yv = round(-$a*$x/$b/2); $yv = -1 if ($yv == 0); -$fyv = f($x,$yv); if (abs($fyv) < .75) {$yv = -$yv; $fyv = f($x,$yv)} - -$M = int(max(abs($fxv),abs($fyv),4))+1; -# -# Graph size -# -($xm,$xM) = (-3,3); -($ym,$yM) = (-3,3); -($zm,$zM) = (-$M,$M); -$size = [200,250]; -$tex_size = 350; - -############################################## - -# -# The plot defaults -# -@Goptions = ( - $ym,$zm,$yM,$zM, # dimensions of graph - axes => [0,0], grid => [$yM-$ym,$zM-$zm], # number of grid lines - size => $size # pixel dimension -); -@imageoptions = (size=>$size, tex_size=>$tex_size); - -$plotoptions = "using color:red and weight:2"; - -# -# Make the traces -# -$fx = $f->substitute(x => x, a => $a, b => $b, y => 'x')->reduce; -$Gx = init_graph(@Goptions); -plot_functions($Gx, - "$fx for x in <$ym,$yv] $plotoptions", - "$fx for x in <$yv,$yM> $plotoptions", -); -$Xtrace = Image($Gx,@imageoptions); - -$fy = $f->substitute(y => $y, a => $a, b => $b)->reduce; -$Gy = init_graph(@Goptions); -plot_functions($Gy, - "$fy for x in <$xm,$xv] $plotoptions", - "$fy for x in <$xv,$xM> $plotoptions", -); -$Ytrace = Image($Gy,@imageoptions); - -Context()->texStrings; - -# -# Make the table of images -# -@rowopts = (indent=>0, separation=>30); -$Images = - BeginTable(). - AlignedRow([$Xtrace,$Ytrace], @rowopts). - AlignedRow(["Trace for \(x=$x\) has","Trace for \(y=$y\) has"], @rowopts). - AlignedRow(["a point at \(($yv,$fyv)\).","a point at \(($xv,$fxv)\)."], @rowopts). - EndTable(); - -############################################## - -BEGIN_TEXT - -The graphs below are traces for a function \(f(x,y)\) at \(x=$x\) and -\(y=$y\). -$PAR - -$Images -$PAR - -If \(f(x,y) = \{$f->TeX\}\) then -\(a\) = \{ans_rule(6)\} and \(b\) = \{ans_rule(6)\}. - -END_TEXT - -Context()->normalStrings; - -################################################## - -ANS(std_num_cmp($a)); -ANS(std_num_cmp($b)); - -################################################## - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample06.pg b/doc/parser/problems/sample06.pg deleted file mode 100644 index e4d8102bee..0000000000 --- a/doc/parser/problems/sample06.pg +++ /dev/null @@ -1,53 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser to make -# a formula that you can evaluate and print in TeX form. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################### -# -# The setup -# -Context('Vector'); - -# -# Define a vector -# -$a = non_zero_random(-8,8,1); -$b = non_zero_random(-8,8,1); -$c = non_zero_random(-8,8,1); - -$V = $a*i + $b*j + $c*k; # equivalently: $V = Vector($a,$b,$c); - -########################################################### -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -The length of the vector \($V\) is \{ans_rule(20)\}. - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS(num_cmp(norm($V)->value)); # easier: ANS($V->cmp) -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample07.pg b/doc/parser/problems/sample07.pg deleted file mode 100644 index 64a320dc06..0000000000 --- a/doc/parser/problems/sample07.pg +++ /dev/null @@ -1,49 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################## -# -# The setup -# - -Context('Numeric'); - -$a = Real(random(2,6,1)); -$b = Real(random($a+1,$a+8,1)); - -$c = sqrt($a**2 + $b**2); # still a Real object - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose the legs of a triangle are of length \($a\) and \($b\).$BR -Then the hypoteneuse is of length \{ans_rule(20)\}. - -END_TEXT -Context()->normalStrings(); - -########################################################### -# -# The answer -# - -ANS($c->cmp); - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample08.pg b/doc/parser/problems/sample08.pg deleted file mode 100644 index 6c7b6d73d1..0000000000 --- a/doc/parser/problems/sample08.pg +++ /dev/null @@ -1,49 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################## -# -# The setup -# - -Context('Complex'); - -$z = random(-5,5,1) + non_zero_random(-5,5,1)*i; - -$f = Formula('z^2 + 2z - 1'); -$fz = $f->eval(z => $z); - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(f(z) = $f\).$BR -Then \(f($z)\) = \{ans_rule(20)\}. - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS($fz->cmp); - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample09.pg b/doc/parser/problems/sample09.pg deleted file mode 100644 index b2a9776653..0000000000 --- a/doc/parser/problems/sample09.pg +++ /dev/null @@ -1,49 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################## -# -# The setup -# - -Context('Vector'); - -$P1 = Point(-2,4,2); -$P2 = Point(2,-3,1); - -$M = ($P1+$P2)/2; - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -The midpoint of the line segment from \($P1\) to \($P2\) -is \{ans_rule(20)\}. - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS($M->cmp); - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample10.pg b/doc/parser/problems/sample10.pg deleted file mode 100644 index 5a25b8e800..0000000000 --- a/doc/parser/problems/sample10.pg +++ /dev/null @@ -1,54 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################## -# -# The setup -# - -Context('Vector'); - -$P1 = Point(1,random(-3,3,1),random(-3,3,1)); -$P2 = Point(random(-3,3,1),4,random(-3,3,1)); - -$V = Vector($P2-$P1); - -Context()->flags->set(ijk=>0); -Context()->constants->add(a=>1,b=>1,c=>1); - -$ABC = Formula(""); - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT -The vector from \($P1\) to \($P2\) is \{ans_rule(20)\}. -$PAR -You can use either \($ABC\) or \(\{$ABC->ijk\}\) notation,$BR -and can perform vector operations to produce your answer. -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS($V->cmp(promotePoints=>1)); # allow answers to be points or vectors - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample11.pg b/doc/parser/problems/sample11.pg deleted file mode 100644 index 8a1ff5e41d..0000000000 --- a/doc/parser/problems/sample11.pg +++ /dev/null @@ -1,48 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################## -# -# The setup -# - -Context('Interval'); - -$p1 = random(-5,2,1); -$p2 = random($p1+1,$p1+7,1); - -$f = Formula("x^2 - ($p1+$p2) x + $p1*$p2")->reduce; -$I = Interval("($p1,$p2)"); - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT -The function \(f(x) = $f\) is negative for values of \(x\) in the interval -\{ans_rule(20)\}. -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS($I->cmp); - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample12.pg b/doc/parser/problems/sample12.pg deleted file mode 100644 index 36222469d9..0000000000 --- a/doc/parser/problems/sample12.pg +++ /dev/null @@ -1,60 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -########################################################## -# -# The setup -# - -Context("Interval"); - -$a = non_zero_random(-5,5,1); -$f = Formula("(x^2+1)/(x-$a)")->reduce; -$R = Union("(-inf,$a) U ($a,inf)"); - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(\displaystyle f(x) = $f\). -$PAR -Then \(f\) is defined on the region \{ans_rule(30)\}. -$PAR -${BCENTER} -${BSMALL} -Several intervals can be combined using the -set union symbol, ${LQ}${BTT}U${ETT}${RQ}.$BR -Use ${LQ}${BTT}infinity${ETT}${RQ} for ${LQ}\(\infty\)${RQ} and -${LQ}${BTT}-infinity${ETT}${RQ} for ${LQ}\(-\infty\)${RQ}. -${ESMALL} -${ECENTER} - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS($R->cmp); -$showPartialCorrectAnswers=1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample13.pg b/doc/parser/problems/sample13.pg deleted file mode 100644 index 5ad161d529..0000000000 --- a/doc/parser/problems/sample13.pg +++ /dev/null @@ -1,59 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -########################################################## -# -# The setup -# - -Context("Interval"); - -$a = non_zero_random(-5,5,1); -$f = Formula("(x^2+1)/(x-$a)")->reduce; -$R = Compute("(-inf,$a),($a,inf)"); - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(\displaystyle f(x) = $f\). -$PAR -Then \(f\) is defined on the intervals \{ans_rule(30)\}. -$PAR -${BCENTER} -${BSMALL} -To enter more than one interval, separate them by commas.$BR -Use ${LQ}${BTT}infinity${ETT}${RQ} for ${LQ}\(\infty\)${RQ} and -${LQ}${BTT}-infinity${ETT}${RQ} for ${LQ}\(-\infty\)${RQ}. -${ESMALL} -${ECENTER} - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS($R->cmp); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample14.pg b/doc/parser/problems/sample14.pg deleted file mode 100644 index 5ffef3f161..0000000000 --- a/doc/parser/problems/sample14.pg +++ /dev/null @@ -1,57 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -########################################################## -# -# The setup -# - -Context("Numeric"); - -$a = random(1,5,1); -$f = Formula("(x^2+1)/(x^2-$a^2)")->reduce; - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(\displaystyle f(x) = $f\). -$PAR -Then \(f\) is defined for all \(x\) except for \{ans_rule(30)\}. -$PAR -${BCENTER} -${BSMALL} -To enter more than one value, separate them by commas.$BR -Enter ${LQ}${BTT}NONE${ETT}${RQ} if there are no such values. -${ESMALL} -${ECENTER} - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS(List($a,-$a)->cmp); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample15.pg b/doc/parser/problems/sample15.pg deleted file mode 100644 index de84abe8b1..0000000000 --- a/doc/parser/problems/sample15.pg +++ /dev/null @@ -1,57 +0,0 @@ -########################################################## -# -# Example showing how to use the built-in answer checker for parsed values. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -########################################################## -# -# The setup -# - -Context("Numeric"); - -$a = random(1,5,1); -$f = Formula("(x^2-$a)/(x^2+$a)"); - -########################################################## -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(\displaystyle f(x) = $f\). -$PAR -Then \(f\) is defined for all \(x\) except for \{ans_rule(30)\}. -$PAR -${BCENTER} -${BSMALL} -To enter more than one value, separate them by commas.$BR -Enter ${LQ}${BTT}NONE${ETT}${RQ} if there are no such values. -${ESMALL} -${ECENTER} - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answer -# - -ANS(List("NONE")->cmp); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample16.pg b/doc/parser/problems/sample16.pg deleted file mode 100644 index 4317142848..0000000000 --- a/doc/parser/problems/sample16.pg +++ /dev/null @@ -1,61 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser's function -# answer checker. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "Differentiation.pl", -); - -########################################################### -# -# The setup -# -Context('Numeric'); -$x = Formula('x'); # used to construct formulas below. - -# -# Define a function and its derivative and make them pretty -# -$a = random(1,8,1); -$b = random(-8,8,1); -$c = random(-8,8,1); - -$f = ($a*$x**2 + $b*$x + $c) -> reduce; -$df = $f->D('x'); - -$x = random(-8,8,1); - -########################################################### -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(f(x) = $f\). -$PAR -Then \(f'(x)=\) \{ans_rule(20)\},$BR -and \(f'($x)=\) \{ans_rule(20)\}. - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answers -# -ANS($df->cmp); -ANS($df->eval(x=>$x)->cmp); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample17.pg b/doc/parser/problems/sample17.pg deleted file mode 100644 index 4fea5b52da..0000000000 --- a/doc/parser/problems/sample17.pg +++ /dev/null @@ -1,62 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser's function -# answer checker. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "Differentiation.pl", -); - -########################################################### -# -# The setup -# -Context('Numeric')->variables->add(y=>'Real'); -$x = Formula('x'); # used to construct formulas below. -$y = Formula('y'); - -# -# Define a function and its derivative and make them pretty -# -$a = random(1,8,1); -$b = random(-8,8,1); -$c = random(-8,8,1); - -$f = ($a*$x**2 + $b*$x*$y + $c*$y**2) -> reduce; -$fx = $f->D('x'); -$fy = $f->D('y'); - -########################################################### -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(f(x,y) = $f\). -$PAR -Then \(f_x(x,y) =\) \{ans_rule(30)\},$BR -and \(f_y(x,y) =\) \{ans_rule(30)\}. - - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answers -# -ANS($fx->cmp); -ANS($fy->cmp); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample18.pg b/doc/parser/problems/sample18.pg deleted file mode 100644 index 8d6fa17afd..0000000000 --- a/doc/parser/problems/sample18.pg +++ /dev/null @@ -1,61 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser's function -# answer checker. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "Differentiation.pl", -); - -########################################################### -# -# The setup -# -Context('Vector')->variables->are(t=>'Real'); - -# -# Define a function and its derivative and make them pretty -# -$a = random(1,8,1); -$b = random(-8,8,1); -$c = random(-8,8,1); - -$f = Formula("") -> reduce; -$df = $f->D('t'); - -$t = random(-5,5,1); - -########################################################### -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(f(t) = $f\). -$PAR -Then \(f'(t) =\) \{ans_rule(20)\},$BR -and \(f'($t) =\) \{ans_rule(20)\}. - - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answers -# -ANS($df->cmp); -ANS($df->eval(t=>$t)->cmp); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample19.pg b/doc/parser/problems/sample19.pg deleted file mode 100644 index a0c0cf0e9a..0000000000 --- a/doc/parser/problems/sample19.pg +++ /dev/null @@ -1,65 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser's function -# answer checker. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -########################################################### -# -# The setup -# -Context('Interval')->variables->add(a=>'Real'); -$x = Formula('x'); $a = Formula('a'); - -$f = log($x-$a); -$I = Formula("(-infinity,a]"); - -########################################################### -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(f(x) = $f\). -$PAR -Then \(f\) is undefined for \(x\) in the interval(s) -\{ans_rule(20)\}. -$PAR -${BCENTER} -${BSMALL} -To enter more than one interval, separate them by commas.$BR -Use ${LQ}${BTT}infinity${ETT}${RQ} for ${LQ}\(\infty\)${RQ} and -${LQ}${BTT}-infinity${ETT}${RQ} for ${LQ}\(-\infty\)${RQ}.$BR -Enter ${LQ}${BTT}NONE${ETT}${RQ} if the function is always defined. -${ESMALL} -${ECENTER} - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answers -# -ANS(List($I)->cmp( - list_type => 'a list of intervals', # override these names to avoid - entry_type => "an interval", # 'formula returning ...' messages -)); -Context()->variables->remove('x'); # error if 'x' is used in answer - -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample20.pg b/doc/parser/problems/sample20.pg deleted file mode 100644 index ecde485d70..0000000000 --- a/doc/parser/problems/sample20.pg +++ /dev/null @@ -1,60 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser's function -# answer checker. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", -); - -########################################################### -# -# The setup -# -Context('Numeric')->variables->are( - x=>'Real',y=>'Real', - s=>'Real',t=>'Real' -); -$x = Formula('x'); $y = Formula('y'); - -$a = random(1,5,1); -$b = random(-5,5,1); -$c = random(-5,5,1); - -$f = ($a*$x**2 + $b*$x*$y + $c*$y**2) -> reduce; - -$x = random(-5,5,1); -$y = random(-5,5,1); - -########################################################### -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(f(x) = $f\). -$PAR -Then \(f($x,$y)\) = \{ans_rule(20)\},$BR -and \(f(s+t,s-t)\) = \{ans_rule(30)\}. - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answers -# -ANS($f->eval(x=>$x,y=>$y)->cmp); -ANS($f->substitute(x=>'s+t',y=>'s-t')->cmp); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample21.pg b/doc/parser/problems/sample21.pg deleted file mode 100644 index bf5c213f85..0000000000 --- a/doc/parser/problems/sample21.pg +++ /dev/null @@ -1,62 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser's function -# answer checker. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -########################################################### -# -# The setup -# -Context('Vector')->variables->are(x=>'Real',y=>'Real'); -$x = Formula('x'); $y = Formula('y'); - -$a = random(1,16,1); -$b = non_zero_random(-5,5,1); - -$f = ($x**2 + $a*$y**2 + $b*$x**2*$y) -> reduce; - -$x = sqrt(2*$a)/$b; $y = -1/$b; - -########################################################### -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(f(x,y) = $f\). -$PAR -Then \(f\) has critical points at the following -points: \{ans_rule(30)\}. -$PAR -${BCENTER} -${BSMALL} -To enter more than one point, separate them by commas.$BR -Enter ${LQ}${BTT}NONE${ETT}${RQ} if there are none. -${ESMALL} -${ECENTER} - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answers -# -ANS(List(Point(0,0),Point($x,$y),Point(-$x,$y))->cmp); -$showPartialCorrectAnswers = 1; - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/doc/parser/problems/sample22.pg b/doc/parser/problems/sample22.pg deleted file mode 100644 index ba43b73657..0000000000 --- a/doc/parser/problems/sample22.pg +++ /dev/null @@ -1,58 +0,0 @@ -########################################################### -# -# Example showing how to use the Parser's function -# answer checker. -# - -DOCUMENT(); # This should be the first executable line in the problem. - -loadMacros( - "PGbasicmacros.pl", - "PGanswermacros.pl", - "Parser.pl", - "parserUtils.pl", -); - -########################################################### -# -# The setup -# -$context = Context('Vector'); -$context->variables->are(t=>'Real'); -$context->constants->add( - p0 => Point(pi,sqrt(2),3/exp(1)), - v => Vector(exp(1),log(10),-(pi**2)), -); -$context->constants->set(v => {TeX => '\boldsymbol{v}'}); # make it print nicer - -$L = Formula("p0+tv"); -$v = Formula('v'); - -########################################################### -# -# The problem text -# - -Context()->texStrings; -BEGIN_TEXT - -Suppose \(p_0\) is a point and \($v\) a vector in \(n\)-space. -$PAR -Then the vector-parametric form for the line through \(p_0\) in the -direction of \(v\) is$PAR -${BBLOCKQUOTE} -\(L(t)\) = \{ans_rule(30)\}. -${EBLOCKQUOTE} - -END_TEXT -Context()->normalStrings; - -########################################################### -# -# The answers -# -ANS($L->cmp); - -########################################################### - -ENDDOCUMENT(); # This should be the last executable line in the problem. diff --git a/docker-config/docker-compose.dist.yml b/docker-config/docker-compose.dist.yml index c5d51e06b9..fa6a02b003 100644 --- a/docker-config/docker-compose.dist.yml +++ b/docker-config/docker-compose.dist.yml @@ -1,4 +1,3 @@ -version: '3.5' services: db: image: mariadb:10.4 @@ -97,7 +96,7 @@ services: # If you would like a 1 stage build process comment out the next line, and just run "docker-compose build". dockerfile: DockerfileStage2 # For the 2 stage build process run - # docker build --tag webwork-base:forWW219 -f DockerfileStage1 . + # docker build --tag webwork-base:forWW220 -f DockerfileStage1 . # and then # docker-compose build # When rebuilding to get updated images add the "--no-cache" option to both commands. @@ -252,6 +251,29 @@ services: #ports: # - "6311:6311" + # SimpleSAMLphp Saml2 identity provider for development use only. This is a separate profile from the other services + # so it doesn't start in normal usage. Use "docker compose --profile saml2dev up" to start, "docker compose --profile + # saml2dev stop" to stop services, and "docker compose --profile saml2dev down" to stop services and remove + # containers. + idp: + build: + context: ./docker-config/idp/ + profiles: + - saml2dev + ports: + - '8180:80' + environment: + SP_METADATA_URL: 'http://app:8080/webwork2/saml2/metadata' + # The healthcheck url is SimpleSAMLphp's url for triggering cron jobs. The cron job it triggers will + # automatically fetch the webwork2 service provider's metadata. + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost/simplesaml/module.php/cron/run/metarefresh/webwork2'] + start_period: 1m + start_interval: 15s + interval: 1h + retries: 1 + timeout: 10s + volumes: oplVolume: driver: local diff --git a/docker-config/idp/Dockerfile b/docker-config/idp/Dockerfile new file mode 100644 index 0000000000..876ba9f7d0 --- /dev/null +++ b/docker-config/idp/Dockerfile @@ -0,0 +1,38 @@ +FROM php:8.3-apache +WORKDIR /var/www + +# Install composer and the php extension installer. +COPY --from=composer/composer:2-bin /composer /usr/bin/composer +COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ + +RUN apt-get update && \ + apt-get -y install git curl vim && \ + install-php-extensions ldap zip + +# Directories used by simplesamlphp. These need to be accessible by the apache2 user. +RUN mkdir simplesamlphp/ /var/cache/simplesamlphp +RUN chown www-data simplesamlphp/ /var/cache/simplesamlphp + +COPY ./idp.apache2.conf /etc/apache2/conf-available +RUN a2enconf idp.apache2 + +# Composer doesn't like to be root, so run the rest as the apache user. +USER www-data + +# Install simplesamlphp +RUN git clone --branch v2.2.1 https://github.com/simplesamlphp/simplesamlphp.git +WORKDIR /var/www/simplesamlphp + +# Generate the server certificates. +RUN cd cert/ && \ + openssl req -newkey rsa:3072 -new -x509 -days 3652 -nodes -out server.crt -keyout server.pem \ + -subj "/C=US/S=New York/L=Rochester/O=WeBWorK/CN=idp.webwork2" + +# Use composer to install dependencies. +RUN composer install && \ + composer require simplesamlphp/simplesamlphp-module-metarefresh + +# Copy configuration files. +COPY ./config/ config/ +COPY ./metadata/ metadata/ + diff --git a/docker-config/idp/README.md b/docker-config/idp/README.md new file mode 100644 index 0000000000..586b9d9953 --- /dev/null +++ b/docker-config/idp/README.md @@ -0,0 +1,174 @@ +# Development identity provider test instance for SAML2 authentication + +A development SAML2 identity provider is provided that uses SimpleSAMLphp. +Instructions for utilizing this instance follow. + +## Webwork2 Configuration + +Copy `/opt/webwork/webwork2/conf/authen_saml2.conf.dist` to +`/opt/webwork/webwork2/conf/authen_saml2.conf`. + +The default `conf/authen_saml2.conf.dist` is configured to use the docker +identity provider. So for the docker build, it should work as is. + +Without the docker build a few changes are needed. + +- Find the `$saml2{idps}{default}` setting and change its value to + `'http://localhost/simplesaml/module.php/saml/idp/metadata'`. +- Find the `$saml2{sp}{entity_id}` setting and change its value to + `'http://localhost:3000/webwork2/saml2'`. +- In the `$saml2{sp}{org}` hash change the `url` to `'https://localhost:3000/'`. + +The above settings assume you will use `morbo` with the default port. Change +the port as needed. + +## Development IdP test instance with docker + +A docker service that implements a SAML2 identity provider is provided in the +`docker-compose.yml.dist` file. To start this identity provider along with the +rest of webwork2, add the `--profile saml2dev` argument to docker compose as in +the following exmaple. + +```bash +docker compose --profile saml2dev up +``` + +Without the profile argument, the identity provider services do not start. + +Stop all docker services with + +```bash +docker compose --profile saml2dev down +``` + +## Development IdP test instance without docker + +Effective development is not done with docker. So it is usually more useful to +set up an identity provider without docker. The following instructions are for +Ubuntu 24.04, but could be adapted for other operating systems. + +A web server and php are needed to serve the SimpleSAMLphp files. Install these +and other dependencies with: + +```bash +sudo apt install \ + apache2 php php-ldap php-zip php-xml php-curl php-sqlite3 php-fpm \ + composer +``` + +Now download the SimpleSAMLphp source, install php dependencies, install the +SimpleSAMLphp metarefresh module, and set file permissions with + +```bash +cd /var/www +sudo mkdir simplesamlphp /var/cache/simplesamlphp +sudo chown $USER:www-data simplesamlphp +sudo chown www-data /var/cache/simplesamlphp +git clone --branch v2.2.1 https://github.com/simplesamlphp/simplesamlphp.git +sudo chown -R $USER:www-data simplesamlphp +sudo chmod -R g+w simplesamlphp +cd simplesamlphp +composer install +composer require simplesamlphp/simplesamlphp-module-metarefresh +``` + +Next, generate certificates for the SimpleSAMLphp identity provider and make +them owned by the `www-data` user with + +```bash +cd /var/www/simplesamlphp/cert +openssl req -newkey rsa:3072 -new -x509 -days 3652 -nodes \ + -out server.crt -keyout server.pem \ + -subj "/C=US/ST=New York/L=Rochester/O=WeBWorK/CN=idp.webwork2" +sudo chown www-data:www-data server.crt server.pem +``` + +Next, copy the `idp` configuration files from `docker-config`. + +```bash +cp /opt/webwork/webwork2/docker-config/idp/config/* /var/www/simplesamlphp/config/ +cp /opt/webwork/webwork2/docker-config/idp/metadata/* /var/www/simplesamlphp/metadata/ +``` + +The configuration files are setup to work with the docker build. So there are +some changes that are needed. + +Edit the file `/var/www/simplesamlphp/config/config.php` and change +`baseurlpath` to `simplesaml/`. + +Edit the file `/var/www/simplesamlphp/metadata/saml20-idp-hosted.php` and change +the line that reads +`$metadata['http://localhost:8180/simplesaml'] = [` +to +`$metadata['http://localhost/simplesaml'] = [`. + +Enable the apache2 idp configuration with + +```bash +sudo cp /opt/webwork/webwork2/docker-config/idp/idp.apache2.conf /etc/apache2/conf-available +sudo a2enconf idp.apache2 php8.3-fpm +``` + +Edit the file `/etc/apache2/conf-available/idp.apache2.conf` and add the line +`SetEnv SP_METADATA_URL http://localhost:3000/webwork2/saml2/metadata` to the +beginning of the file. This again assumes you will use `morbo` with the default +port, so change the port if necessary. + +Restart (or start) apache2 with `sudo systemctl restart apache2`. + +The SimpleSAMLphp identity provider needs to fetch webwork2's service provider +metadata. For this execute + +```bash +curl -f http://localhost/simplesaml/module.php/cron/run/metarefresh/webwork2 +``` + +That is done automatically with the docker build. The command usually only +needs to be done once, but may need to be run again if settings are changed. + +## Identity provider administration + +The identity provider has an admin interface. You can login to the docker +instance with the password 'admin' at +`http://localhost:8180/simplesaml/module.php/admin/federation` +or without docker at +`http://localhost/simplesaml/module.php/admin/federation`. + +The admin interface lets you check if the identity provider has properly +registered the webwork2 service provider under the 'Federation' tab, it should +be listed under the "Trusted entities" section. + +You can also test login with the user accounts listed below in the "Test" tab +under the "example-userpass" authentication source. + +## Single sign-on users + +The following single sign-on accounts are preconfigured: + +- Username: student01, Password: student01 +- Username: instructor01, Password: instructor01 +- Username: staff01, Password: staff01 + +You can add more accounts to the `docker-config/idp/config/authsources.php` file +in the `example-userpass` section. If using docker the identity provider, the +image will need to be rebuilt for the changes to take effect. + +## Troubleshooting + +### "Error retrieving metadata" + +This error message indicates that the Saml2 authentication module wasn't able to +fetch the metadata from the identity provider metadata URL. Make sure the +identity provider is accessible to webwork2. + +### User not found in course + +The user was verified by the identity provider but did not have a corresponding +user account in the Webwork course. The Webwork user account needs to be created +separately as the Saml2 autentication module does not do user provisioning. + +### The WeBWorK service provider does not appear in the service provider Federation tab + +This can occur when using the docker identity provider service because Webwork's +first startup can be slow enough that the IdP wasn't able to successfully fetch +metadata from the webwork2 metadata URL. Restarting everything should fix this. diff --git a/docker-config/idp/certs/saml.crt b/docker-config/idp/certs/saml.crt new file mode 100644 index 0000000000..ca2f952c0f --- /dev/null +++ b/docker-config/idp/certs/saml.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE7zCCA1egAwIBAgIUIteyNYLSAiB0FcNl0GLJNYRppk8wDQYJKoZIhvcNAQEL +BQAwgYYxCzAJBgNVBAYTAkFBMQswCQYDVQQIDAJBQTEQMA4GA1UEBwwHRXhhbXBs +ZTEQMA4GA1UECgwHRXhhbXBsZTEQMA4GA1UECwwHRXhhbXBsZTEQMA4GA1UEAwwH +RXhhbXBsZTEiMCAGCSqGSIb3DQEJARYTZXhhbXBsZUBleGFtcGxlLmVkdTAeFw0y +NDA1MDMwMTA2MzNaFw0zNDA1MDMwMTA2MzNaMIGGMQswCQYDVQQGEwJBQTELMAkG +A1UECAwCQUExEDAOBgNVBAcMB0V4YW1wbGUxEDAOBgNVBAoMB0V4YW1wbGUxEDAO +BgNVBAsMB0V4YW1wbGUxEDAOBgNVBAMMB0V4YW1wbGUxIjAgBgkqhkiG9w0BCQEW +E2V4YW1wbGVAZXhhbXBsZS5lZHUwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGK +AoIBgQC45DCHUejzAeq+eVwEX5zSQWC+kqydEmoxpydT4YiSXnNeoNAkilKfHGOY +Uc4djwx148N14A+S0GCys2j3Ey2wuL7DSep5y1Z9Uxj6Ayg23XGIFFFJJLMy1Qfe +pjCcr1djPH9PpwglG1nTsiWqvHGGc3WWn1u6RfyrCf+jxbhygNRTA+LVPpqNvko6 +MWKsbVLKrYMV2kPcQ0PQByNHJnjBy3KH2k99lS20h32sgHbgbVpJWdAWjeyJrOh9 +aDt4/AfK90BvhkjF4BuQ+Jw5oIwMhbx7YmzIfiJmBLLaGjVRppuoAQtLX9uLst9l +aLZzaeutg+G3RUYcvDMlnP7cU8Sq4BD7uK0ChKxxCMFcihAhQ8wqCKncaaE9WqPs +CM16SB/6xptOxoLcg/5q3PJyUi2g4VDKXuQc6AURKIJxSM9nlrcv/R7fCFgk3Nj/ +piWykDk6/BDWFpEHaj+NnFE9ZIxKr9CjTxdmqiDTyqSv50rNCjleyL/iASBTSCCF +OPVOYQECAwEAAaNTMFEwHQYDVR0OBBYEFGs8F3VIGSEk+DE2MBqqNKX6UuZTMB8G +A1UdIwQYMBaAFGs8F3VIGSEk+DE2MBqqNKX6UuZTMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADggGBAIpDktpfGH7ZqgdWvxbJrjekb1IyCGrWsHOYSjwM ++MxnhAA6oY63wC04a2i31zIMNOkY9F0tAdd4uDchxA9IWHqpb7t7zBlZdDabPPC3 +WoDYnKhtZBULVVo7AvWO0UJGfZNJE393aKer3ePvfoG0OpCyrw4eFI/GCd4UjJBF +DnD7hvUxE7RRwOhbuYrtDRuB3Z7CeeP8o81eDVexyuBpM/9UQjYPqBBAfoeYKQzu +ZIhpGRWXw0ntH+EEOWagRXA5pRru61hteParZe4LBjPqisqN4Ek6ZR7MD9gB5xnt +Pn1BKRY08quFOZyaogzwfkYk5SCF8F8jBA8ZNAYwJWe1gtO3iw5vpUaQc2iCabvI +Y+Pc6qsSNwbkl7+sFrVHzI9QZVyz1cARUXxvrgGNLBkYtprkG91k6mCjX90cQspb +ZwHixcQyCNv+4H738e99h/Wf0YzjxFjDKrbGoosYBzWAsYYtzrtsBvw3SJMTXIh7 +OvFMA+rbIL8XWs8oNmZDDh8g0A== +-----END CERTIFICATE----- diff --git a/docker-config/idp/certs/saml.pem b/docker-config/idp/certs/saml.pem new file mode 100644 index 0000000000..65accf00b2 --- /dev/null +++ b/docker-config/idp/certs/saml.pem @@ -0,0 +1,40 @@ +-----BEGIN PRIVATE KEY----- +MIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQC45DCHUejzAeq+ +eVwEX5zSQWC+kqydEmoxpydT4YiSXnNeoNAkilKfHGOYUc4djwx148N14A+S0GCy +s2j3Ey2wuL7DSep5y1Z9Uxj6Ayg23XGIFFFJJLMy1QfepjCcr1djPH9PpwglG1nT +siWqvHGGc3WWn1u6RfyrCf+jxbhygNRTA+LVPpqNvko6MWKsbVLKrYMV2kPcQ0PQ +ByNHJnjBy3KH2k99lS20h32sgHbgbVpJWdAWjeyJrOh9aDt4/AfK90BvhkjF4BuQ ++Jw5oIwMhbx7YmzIfiJmBLLaGjVRppuoAQtLX9uLst9laLZzaeutg+G3RUYcvDMl +nP7cU8Sq4BD7uK0ChKxxCMFcihAhQ8wqCKncaaE9WqPsCM16SB/6xptOxoLcg/5q +3PJyUi2g4VDKXuQc6AURKIJxSM9nlrcv/R7fCFgk3Nj/piWykDk6/BDWFpEHaj+N +nFE9ZIxKr9CjTxdmqiDTyqSv50rNCjleyL/iASBTSCCFOPVOYQECAwEAAQKCAYAR +p6iCo22tFrfFrGz+9epRoXCNgg/9h66gQyfcOKMD5wT5Oj3l31d4XgucleMqq2gz +MaaOcPDLwh4ZskwJm8k3IM0GdN5w9tuxZ+fwp7CFXKvkpJwGcfyyk+kGd7QYoh2k +GjjF8Fs0v+HZ9x7lqMzmW8wUr+7gYKJ56qCAkPbF6EteCfb1Cd9UPaF04RZdBKtt +MxhbU9Y7CClHigbyWlgZmUW8dzoz8bTFklKL0FCJqad/bZYTMUYu91XT88oKCXbD +AUxpF2Ikbkfj820XOqq8iV3xGpYszt1aMRpsdXbDAhCqfKoNet2X7jnRWlNXZutC +RIUGm4VUNDNeD4nXW8aLgDa8bNQnvsSmM9DUVuPjbejUs0VN7uwxo8rYqvkAKiBQ +1ZqxoBK4ShZVcqgWE6CUj9FRZ3CVzSzydxZSQzex/ZRYPuYLUhQJFHLVIdJSYhf3 +XTEki0+ndwAB7yP/tBNlcxLftCzAaS7mPLLn1tf0A27QPCSjwOsTLxuJ4WYVkmkC +gcEAuuh8EImBfE9WOg3ITmJpr95WlVi8WE6BHWowV8dQwODQLj+38itDDL1xLn9+ +Vuz4o9AaIBiH5fCr6otun28lVp/sNVdWnBVeioSpu3tGV18OiDNaXtXOo7qkUnBI +Z+V7cD69gJLS6byD3OXlGi42h3XxK4mVlhwQtkQ69qI/zhl6rc0O2/iXXUAFa5T5 +MJ84Cw1B9kHFB/NC27sraee+cwAK0Pogj5WnqaBOIPeIO/f+br65xMUvEYvDD1m4 +TwIzAoHBAP082l0IQ5KHBY4WuFIDOoevO5SxHN5EUp2sPRDZwZxwOrjHxFRXPc/h +pDrVEHEn/4HQ706AHYpED0diumr4gee7gusNIDcGpXwjGVdFmFvxKoDbhz1C5vL3 +xC7qgyS/ZtAopxpCPH3+7IrQyBk8e6He+8F97bA0e9sYSBQSuPLcdKQXGNbLYb6s +yLbP02cB2CNeI1GJMQIOXe9bi9Cz5w+hCGMvEKt5oAz5SLWlPBvv1YATpG5Ux8Wy +RbGPD4zj+wKBwBGVDx6rIMAl4nGhnEcrYM/HdZOk/kq8T88JjzSirkkGnO7M1av1 +P+Bx7bS3D5Zzwkv+poaAaEBMLI/qv+RFm1iTwK+f4KjcJcGYCzN0vEA50+8iDY1A +RakHRK/wmg8T+lGrxT3UEf0k266q/atBz6VchexXi/fL+hJ7RqSuzJvBr9WrpYsx +zmNaQ2hEYlCdmbMIcz0MINHHo3FyIPpcb4D37wyLiwaWyGffiZn2Tx19DbUzQdxt +xCi9YgMOqJTeGwKBwQD4rJ0x5j+U0ApgcWcnAgyj2SwE47eZfDY0p0KAHZXGbV78 +vQ7KU7FbRhTjwP6YX9LEQ8v7pktbz2HBk+3DxayrRrNU5lrQLjKrKDxmOu1WvAgk +6W5wdhYcWbnI6HlHyLzJhGIzov+MKp1V45fbUE2Hs1Q9uc+CzMcja0C8lXYQ5vOT +fyrhIm8lsr6W5paN/H2mnXbJRpNdlYYg2iD+HOu1qUh3PWx9Nr44f0MrPMs+E9Hw +J1m9DnvuYxWVOwrmK6kCgcADfcatftIJWMqeYJsDnB9jJaANmjln2G3bppo9WcIC +lvfXFE+Rf3FleaijVrUFbgxDU2MHh/2VPjJgIQT3QtfqS5+OnF1Z5+uOTGwbDNmT +3Th0IcSt6TjvLJwkanNeSkvc+2lMnuNtH6TQLXB0qEs3D7xND0kFWHfyies+RYNC +eualoZJ/6UL9X2gkPG5jmzXjInEBguAL0ll5yETXgx6v0hXR058TcvPl58j73cCQ +dzDq+xUD8nHpKM33A2EaUFY= +-----END PRIVATE KEY----- diff --git a/docker-config/idp/config/authsources.php b/docker-config/idp/config/authsources.php new file mode 100644 index 0000000000..03740f20bf --- /dev/null +++ b/docker-config/idp/config/authsources.php @@ -0,0 +1,354 @@ + [ + // The default is to use core:AdminPassword, but it can be replaced with + // any authentication source. + + 'core:AdminPassword', + ], + + + // An authentication source which can authenticate against SAML 2.0 IdPs. + //'default-sp' => [ + // 'saml:SP', + + // // The entity ID of this SP. + // 'entityID' => 'https://myapp.example.org/', + + // // The entity ID of the IdP this SP should contact. + // // Can be NULL/unset, in which case the user will be shown a list of available IdPs. + // 'idp' => null, + + // // The URL to the discovery service. + // // Can be NULL/unset, in which case a builtin discovery service will be used. + // 'discoURL' => null, + + // /* + // * If SP behind the SimpleSAMLphp in IdP/SP proxy mode requests + // * AuthnContextClassRef, decide whether the AuthnContextClassRef will be + // * processed by the IdP/SP proxy or if it will be passed to the original + // * IdP in front of the IdP/SP proxy. + // */ + // 'proxymode.passAuthnContextClassRef' => false, + + // /* + // * The attributes parameter must contain an array of desired attributes by the SP. + // * The attributes can be expressed as an array of names or as an associative array + // * in the form of 'friendlyName' => 'name'. This feature requires 'name' to be set. + // * The metadata will then be created as follows: + // * + // */ + // /* + // 'name' => [ + // 'en' => 'A service', + // 'no' => 'En tjeneste', + // ], + + // 'attributes' => [ + // 'attrname' => 'urn:oid:x.x.x.x', + // ], + // 'attributes.required' => [ + // 'urn:oid:x.x.x.x', + // ], + // */ + //], + + /* + 'example-sql' => [ + 'sqlauth:SQL', + 'dsn' => 'pgsql:host=sql.example.org;port=5432;dbname=simplesaml', + 'username' => 'simplesaml', + 'password' => 'secretpassword', + 'query' => 'SELECT uid, givenName, email, eduPersonPrincipalName FROM users WHERE uid = :username ' . + 'AND password = SHA2(CONCAT((SELECT salt FROM users WHERE uid = :username), :password), 256);', + ], + */ + + /* + 'example-static' => [ + 'exampleauth:StaticSource', + 'uid' => ['testuser'], + 'eduPersonAffiliation' => ['member', 'employee'], + 'cn' => ['Test User'], + ], + */ + + 'example-userpass' => [ + 'exampleauth:UserPass', + + // Give the user an option to save their username for future login attempts + // And when enabled, what should the default be, to save the username or not + //'remember.username.enabled' => false, + //'remember.username.checked' => false, + + 'users' => [ + 'student01:student01' => [ + 'uid' => ['student01'], + 'displayName' => 'Student 01', + 'eduPersonAffiliation' => ['student'], + 'mail' => 'student01@example.edu' + ], + 'instructor01:instructor01' => [ + 'uid' => ['instructor01'], + 'displayName' => 'Instructor 01', + 'alt' => '51092d7f-2f38-4a91-bfb0-13a021c02df3', + 'eduPersonAffiliation' => ['faculty', 'student'], + 'mail' => 'instructor01@example.edu' + ], + 'staff01:staff01' => [ + 'uid' => ['staff01'], + 'displayName' => 'Staff 01', + 'eduPersonAffiliation' => ['staff', 'alumni'], + 'mail' => 'staff01@example.edu' + ], + ], + ], + + /* + 'crypto-hash' => [ + 'authcrypt:Hash', + // hashed version of 'verysecret', made with bin/pwgen.php + 'professor:{SSHA256}P6FDTEEIY2EnER9a6P2GwHhI5JDrwBgjQ913oVQjBngmCtrNBUMowA==' => [ + 'uid' => ['prof_a'], + 'eduPersonAffiliation' => ['member', 'employee', 'board'], + ], + ], + */ + + /* + 'htpasswd' => [ + 'authcrypt:Htpasswd', + 'htpasswd_file' => '/var/www/foo.edu/legacy_app/.htpasswd', + 'static_attributes' => [ + 'eduPersonAffiliation' => ['member', 'employee'], + 'Organization' => ['University of Foo'], + ], + ], + */ + + /* + // This authentication source serves as an example of integration with an + // external authentication engine. Take a look at the comment in the beginning + // of modules/exampleauth/lib/Auth/Source/External.php for a description of + // how to adjust it to your own site. + 'example-external' => [ + 'exampleauth:External', + ], + */ + + /* + 'yubikey' => [ + 'authYubiKey:YubiKey', + 'id' => '000', + // 'key' => '012345678', + ], + */ + + /* + 'facebook' => [ + 'authfacebook:Facebook', + // Register your Facebook application on http://www.facebook.com/developers + // App ID or API key (requests with App ID should be faster; https://github.com/facebook/php-sdk/issues/214) + 'api_key' => 'xxxxxxxxxxxxxxxx', + // App Secret + 'secret' => 'xxxxxxxxxxxxxxxx', + // which additional data permissions to request from user + // see http://developers.facebook.com/docs/authentication/permissions/ for the full list + // 'req_perms' => 'email,user_birthday', + // Which additional user profile fields to request. + // When empty, only the app-specific user id and name will be returned + // See https://developers.facebook.com/docs/graph-api/reference/v2.6/user for the full list + // 'user_fields' => 'email,birthday,third_party_id,name,first_name,last_name', + ], + */ + + /* + // Twitter OAuth Authentication API. + // Register your application to get an API key here: + // http://twitter.com/oauth_clients + 'twitter' => [ + 'authtwitter:Twitter', + 'key' => 'xxxxxxxxxxxxxxxx', + 'secret' => 'xxxxxxxxxxxxxxxx', + // Forces the user to enter their credentials to ensure the correct users account is authorized. + // Details: https://dev.twitter.com/docs/api/1/get/oauth/authenticate + 'force_login' => false, + ], + */ + + /* + // Microsoft Account (Windows Live ID) Authentication API. + // Register your application to get an API key here: + // https://apps.dev.microsoft.com/ + 'windowslive' => [ + 'authwindowslive:LiveID', + 'key' => 'xxxxxxxxxxxxxxxx', + 'secret' => 'xxxxxxxxxxxxxxxx', + ], + */ + + /* + // Example of a LDAP authentication source. + 'example-ldap' => [ + 'ldap:Ldap', + + // The connection string for the LDAP-server. + // You can add multiple by separating them with a space. + 'connection_string' => 'ldap.example.org', + + // Whether SSL/TLS should be used when contacting the LDAP server. + // Possible values are 'ssl', 'tls' or 'none' + 'encryption' => 'ssl', + + // The LDAP version to use when interfacing the LDAP-server. + // Defaults to 3 + 'version' => 3, + + // Set to TRUE to enable LDAP debug level. Passed to the LDAP connector class. + // + // Default: FALSE + // Required: No + 'ldap.debug' => false, + + // The LDAP-options to pass when setting up a connection + // See [Symfony documentation][1] + 'options' => [ + + // Set whether to follow referrals. + // AD Controllers may require 0x00 to function. + // Possible values are 0x00 (NEVER), 0x01 (SEARCHING), + // 0x02 (FINDING) or 0x03 (ALWAYS). + 'referrals' => 0x00, + + 'network_timeout' => 3, + ], + + // The connector to use. + // Defaults to '\SimpleSAML\Module\ldap\Connector\Ldap', but can be set + // to '\SimpleSAML\Module\ldap\Connector\ActiveDirectory' when + // authenticating against Microsoft Active Directory. This will + // provide you with more specific error messages. + 'connector' => '\SimpleSAML\Module\ldap\Connector\Ldap', + + // Which attributes should be retrieved from the LDAP server. + // This can be an array of attribute names, or NULL, in which case + // all attributes are fetched. + 'attributes' => null, + + // Which attributes should be base64 encoded after retrieval from + // the LDAP server. + 'attributes.binary' => [ + 'jpegPhoto', + 'objectGUID', + 'objectSid', + 'mS-DS-ConsistencyGuid' + ], + + // The pattern which should be used to create the user's DN given + // the username. %username% in this pattern will be replaced with + // the user's username. + // + // This option is not used if the search.enable option is set to TRUE. + 'dnpattern' => 'uid=%username%,ou=people,dc=example,dc=org', + + // As an alternative to specifying a pattern for the users DN, it is + // possible to search for the username in a set of attributes. This is + // enabled by this option. + 'search.enable' => false, + + // An array on DNs which will be used as a base for the search. In + // case of multiple strings, they will be searched in the order given. + 'search.base' => [ + 'ou=people,dc=example,dc=org', + ], + + // The scope of the search. Valid values are 'sub' and 'one' and + // 'base', first one being the default if no value is set. + 'search.scope' => 'sub', + + // The attribute(s) the username should match against. + // + // This is an array with one or more attribute names. Any of the + // attributes in the array may match the value the username. + 'search.attributes' => ['uid', 'mail'], + + // Additional filters that must match for the entire LDAP search to + // be true. + // + // This should be a single string conforming to [RFC 1960][2] + // and [RFC 2544][3]. The string is appended to the search attributes + 'search.filter' => '(&(objectClass=Person)(|(sn=Doe)(cn=John *)))', + + // The username & password where SimpleSAMLphp should bind to before + // searching. If this is left NULL, no bind will be performed before + // searching. + 'search.username' => null, + 'search.password' => null, + ], + */ + + /* + // Example of an LDAPMulti authentication source. + 'example-ldapmulti' => [ + 'ldap:LdapMulti', + + // The way the organization as part of the username should be handled. + // Three possible values: + // - 'none': No handling of the organization. Allows '@' to be part + // of the username. + // - 'allow': Will allow users to type 'username@organization'. + // - 'force': Force users to type 'username@organization'. The dropdown + // list will be hidden. + // + // The default is 'none'. + 'username_organization_method' => 'none', + + // Whether the organization should be included as part of the username + // when authenticating. If this is set to TRUE, the username will be on + // the form @. If this is FALSE, the + // username will be used as the user enters it. + // + // The default is FALSE. + 'include_organization_in_username' => false, + + // A list of available LDAP servers. + // + // The index is an identifier for the organization/group. When + // 'username_organization_method' is set to something other than 'none', + // the organization-part of the username is matched against the index. + // + // The value of each element is an array in the same format as an LDAP + // authentication source. + 'mapping' => [ + 'employees' => [ + // A short name/description for this group. Will be shown in a + // dropdown list when the user logs on. + // + // This option can be a string or an array with + // language => text mappings. + 'description' => 'Employees', + 'authsource' => 'example-ldap', + ], + + 'students' => [ + 'description' => 'Students', + 'authsource' => 'example-ldap-2', + ], + ], + ], + */ +]; diff --git a/docker-config/idp/config/config.php b/docker-config/idp/config/config.php new file mode 100644 index 0000000000..3ecbe1d3ad --- /dev/null +++ b/docker-config/idp/config/config.php @@ -0,0 +1,1301 @@ + 'http://localhost:8180/simplesaml/', + + /* + * The 'application' configuration array groups a set configuration options + * relative to an application protected by SimpleSAMLphp. + */ + 'application' => [ + /* + * The 'baseURL' configuration option allows you to specify a protocol, + * host and optionally a port that serves as the canonical base for all + * your application's URLs. This is useful when the environment + * observed in the server differs from the one observed by end users, + * for example, when using a load balancer to offload TLS. + * + * Note that this configuration option does not allow setting a path as + * part of the URL. If your setup involves URL rewriting or any other + * tricks that would result in SimpleSAMLphp observing a URL for your + * application's scripts different than the canonical one, you will + * need to compute the right URLs yourself and pass them dynamically + * to SimpleSAMLphp's API. + */ + //'baseURL' => 'https://example.com', + ], + + /* + * The following settings are *filesystem paths* which define where + * SimpleSAMLphp can find or write the following things: + * - 'cachedir': Where SimpleSAMLphp can write its cache. + * - 'loggingdir': Where to write logs. MUST be set to NULL when using a logging + * handler other than `file`. + * - 'datadir': Storage of general data. + * - 'tempdir': Saving temporary files. SimpleSAMLphp will attempt to create + * this directory if it doesn't exist. DEPRECATED - replaced by cachedir. + * When specified as a relative path, this is relative to the SimpleSAMLphp + * root directory. + */ + 'cachedir' => '/var/cache/simplesamlphp', + //'loggingdir' => '/var/log/', + //'datadir' => '/var/data/', + //'tempdir' => '/tmp/simplesamlphp', + + /* + * Certificate and key material can be loaded from different possible + * locations. Currently two locations are supported, the local filesystem + * and the database via pdo using the global database configuration. Locations + * are specified by a URL-link prefix before the file name/path or database + * identifier. + */ + + /* To load a certificate or key from the filesystem, it should be specified + * as 'file://' where is either a relative filename or a fully + * qualified path to a file containing the certificate or key in PEM + * format, such as 'cert.pem' or '/path/to/cert.pem'. If the path is + * relative, it will be searched for in the directory defined by the + * 'certdir' parameter below. When 'certdir' is specified as a relative + * path, it will be interpreted as relative to the SimpleSAMLphp root + * directory. Note that locations with no prefix included will be treated + * as file locations. + */ + 'certdir' => 'cert/', + + /* To load a certificate or key from the database, it should be specified + * as 'pdo://' where is the identifier in the database table that + * should be matched. While the certificate and key tables are expected to + * be in the simplesaml database, they are not created or managed by + * simplesaml. The following parameters control how the pdo location + * attempts to retrieve certificates and keys from the database: + * + * - 'cert.pdo.table': name of table where certificates are stored + * - 'cert.pdo.keytable': name of table where keys are stored + * - 'cert.pdo.apply_prefix': whether or not to prepend the database.prefix + * parameter to the table names; if you are using + * database.prefix to separate multiple SSP instances + * in the same database but want to share certificate/key + * data between them, set this to false + * - 'cert.pdo.id_column': name of column to use as identifier + * - 'cert.pdo.data_column': name of column where PEM data is stored + * + * Basically, the query executed will be: + * + * SELECT cert.pdo.data_column FROM cert.pdo.table WHERE cert.pdo.id_column = :id + * + * Defaults are shown below, to change them, uncomment the line and update as + * needed + */ + //'cert.pdo.table' => 'certificates', + //'cert.pdo.keytable' => 'private_keys', + //'cert.pdo.apply_prefix' => true, + //'cert.pdo.id_column' => 'id', + //'cert.pdo.data_column' => 'data', + + /* + * Some information about the technical persons running this installation. + * The email address will be used as the recipient address for error reports, and + * also as the technical contact in generated metadata. + */ + 'technicalcontact_name' => 'Administrator', + 'technicalcontact_email' => 'na@example.org', + + /* + * (Optional) The method by which email is delivered. Defaults to mail which utilizes the + * PHP mail() function. + * + * Valid options are: mail, sendmail and smtp. + */ + //'mail.transport.method' => 'smtp', + + /* + * Set the transport options for the transport method specified. The valid settings are relative to the + * selected transport method. + */ + /* + 'mail.transport.options' => [ + 'host' => 'mail.example.org', // required + 'port' => 25, // optional + 'username' => 'user@example.org', // optional: if set, enables smtp authentication + 'password' => 'password', // optional: if set, enables smtp authentication + 'security' => 'tls', // optional: defaults to no smtp security + 'smtpOptions' => [], // optional: passed to stream_context_create when connecting via SMTP + ], + + // sendmail mail transport options + /* + 'mail.transport.options' => [ + 'path' => '/usr/sbin/sendmail' // optional: defaults to php.ini path + ], + */ + + /* + * The envelope from address for outgoing emails. + * This should be in a domain that has your application's IP addresses in its SPF record + * to prevent it from being rejected by mail filters. + */ + //'sendmail_from' => 'no-reply@example.org', + + /* + * The timezone of the server. This option should be set to the timezone you want + * SimpleSAMLphp to report the time in. The default is to guess the timezone based + * on your system timezone. + * + * See this page for a list of valid timezones: http://php.net/manual/en/timezones.php + */ + 'timezone' => 'America/New_York', + + /********************************** + | SECURITY CONFIGURATION OPTIONS | + **********************************/ + + /* + * This is a secret salt used by SimpleSAMLphp when it needs to generate a secure hash + * of a value. It must be changed from its default value to a secret value. The value of + * 'secretsalt' can be any valid string of any length. + * + * A possible way to generate a random salt is by running the following command from a unix shell: + * LC_ALL=C tr -c -d '0123456789abcdefghijklmnopqrstuvwxyz' /dev/null;echo + */ + 'secretsalt' => 'h6GwzJYCUrc9SgU57Coc7anTduvfnb8U', + + /* + * This password must be kept secret, and modified from the default value 123. + * This password will give access to the installation page of SimpleSAMLphp with + * metadata listing and diagnostics pages. + * You can also put a hash here; run "bin/pwgen.php" to generate one. + */ + 'auth.adminpassword' => 'admin', + + /* + * Set this option to true if you want to require administrator password to access the metadata. + */ + 'admin.protectmetadata' => false, + + /* + * Set this option to false if you don't want SimpleSAMLphp to check for new stable releases when + * visiting the configuration tab in the web interface. + */ + 'admin.checkforupdates' => false, + + /* + * Array of domains that are allowed when generating links or redirects + * to URLs. SimpleSAMLphp will use this option to determine whether to + * to consider a given URL valid or not, but you should always validate + * URLs obtained from the input on your own (i.e. ReturnTo or RelayState + * parameters obtained from the $_REQUEST array). + * + * SimpleSAMLphp will automatically add your own domain (either by checking + * it dynamically, or by using the domain defined in the 'baseurlpath' + * directive, the latter having precedence) to the list of trusted domains, + * in case this option is NOT set to NULL. In that case, you are explicitly + * telling SimpleSAMLphp to verify URLs. + * + * Set to an empty array to disallow ALL redirects or links pointing to + * an external URL other than your own domain. This is the default behaviour. + * + * Set to NULL to disable checking of URLs. DO NOT DO THIS UNLESS YOU KNOW + * WHAT YOU ARE DOING! + * + * Example: + * 'trusted.url.domains' => ['sp.example.com', 'app.example.com'], + */ + 'trusted.url.domains' => [], + + /* + * Enable regular expression matching of trusted.url.domains. + * + * Set to true to treat the values in trusted.url.domains as regular + * expressions. Set to false to do exact string matching. + * + * If enabled, the start and end delimiters ('^' and '$') will be added to + * all regular expressions in trusted.url.domains. + */ + 'trusted.url.regex' => false, + + /* + * Enable secure POST from HTTPS to HTTP. + * + * If you have some SP's on HTTP and IdP is normally on HTTPS, this option + * enables secure POSTing to HTTP endpoint without warning from browser. + * + * For this to work, module.php/core/postredirect.php must be accessible + * also via HTTP on IdP, e.g. if your IdP is on + * https://idp.example.org/ssp/, then + * http://idp.example.org/ssp/module.php/core/postredirect.php must be accessible. + */ + 'enable.http_post' => false, + + /* + * Set the allowed clock skew between encrypting/decrypting assertions + * + * If you have a server that is constantly out of sync, this option + * allows you to adjust the allowed clock-skew. + * + * Allowed range: 180 - 300 + * Defaults to 180. + */ + 'assertion.allowed_clock_skew' => 180, + + /* + * Set custom security headers. The defaults can be found in \SimpleSAML\Configuration::DEFAULT_SECURITY_HEADERS + * + * NOTE: When a header is already set on the response we will NOT overrule it and leave it untouched. + * + * Whenever you change any of these headers, make sure to validate your config by running your + * hostname through a security-test like https://en.internet.nl + 'headers.security' => [ + 'Content-Security-Policy' => "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'", + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Content-Type-Options' => 'nosniff', + 'Referrer-Policy' => 'origin-when-cross-origin', + ], + */ + + + /************************ + | ERRORS AND DEBUGGING | + ************************/ + + /* + * The 'debug' option allows you to control how SimpleSAMLphp behaves in certain + * situations where further action may be taken + * + * It can be left unset, in which case, debugging is switched off for all actions. + * If set, it MUST be an array containing the actions that you want to enable, or + * alternatively a hashed array where the keys are the actions and their + * corresponding values are booleans enabling or disabling each particular action. + * + * SimpleSAMLphp provides some pre-defined actions, though modules could add new + * actions here. Refer to the documentation of every module to learn if they + * allow you to set any more debugging actions. + * + * The pre-defined actions are: + * + * - 'saml': this action controls the logging of SAML messages exchanged with other + * entities. When enabled ('saml' is present in this option, or set to true), all + * SAML messages will be logged, including plaintext versions of encrypted + * messages. + * + * - 'backtraces': this action controls the logging of error backtraces so you + * can debug any possible errors happening in SimpleSAMLphp. + * + * - 'validatexml': this action allows you to validate SAML documents against all + * the relevant XML schemas. SAML 1.1 messages or SAML metadata parsed with + * the XML to SimpleSAMLphp metadata converter or the metaedit module will + * validate the SAML documents if this option is enabled. + * + * If you want to disable debugging completely, unset this option or set it to an + * empty array. + */ + 'debug' => [ + 'saml' => false, + 'backtraces' => true, + 'validatexml' => false, + ], + + /* + * When 'showerrors' is enabled, all error messages and stack traces will be output + * to the browser. + * + * When 'errorreporting' is enabled, a form will be presented for the user to report + * the error to 'technicalcontact_email'. + */ + 'showerrors' => true, + 'errorreporting' => true, + + /* + * Custom error show function called from SimpleSAML\Error\Error::show. + * See docs/simplesamlphp-errorhandling.md for function code example. + * + * Example: + * 'errors.show_function' => ['SimpleSAML\Module\example\Error', 'show'], + */ + + + /************************** + | LOGGING AND STATISTICS | + **************************/ + + /* + * Define the minimum log level to log. Available levels: + * - SimpleSAML\Logger::ERR No statistics, only errors + * - SimpleSAML\Logger::WARNING No statistics, only warnings/errors + * - SimpleSAML\Logger::NOTICE Statistics and errors + * - SimpleSAML\Logger::INFO Verbose logs + * - SimpleSAML\Logger::DEBUG Full debug logs - not recommended for production + * + * Choose logging handler. + * + * Options: [syslog,file,errorlog,stderr] + * + * If you set the handler to 'file', the directory specified in loggingdir above + * must exist and be writable for SimpleSAMLphp. If set to something else, set + * loggingdir above to 'null'. + */ + 'logging.level' => SimpleSAML\Logger::NOTICE, + 'logging.handler' => 'syslog', + + /* + * Specify the format of the logs. Its use varies depending on the log handler used (for instance, you cannot + * control here how dates are displayed when using the syslog or errorlog handlers), but in general the options + * are: + * + * - %date{}: the date and time, with its format specified inside the brackets. See the PHP documentation + * of the date() function for more information on the format. If the brackets are omitted, the standard + * format is applied. This can be useful if you just want to control the placement of the date, but don't care + * about the format. + * + * - %process: the name of the SimpleSAMLphp process. Remember you can configure this in the 'logging.processname' + * option below. + * + * - %level: the log level (name or number depending on the handler used). + * + * - %stat: if the log entry is intended for statistical purposes, it will print the string 'STAT ' (bear in mind + * the trailing space). + * + * - %trackid: the track ID, an identifier that allows you to track a single session. + * + * - %srcip: the IP address of the client. If you are behind a proxy, make sure to modify the + * $_SERVER['REMOTE_ADDR'] variable on your code accordingly to the X-Forwarded-For header. + * + * - %msg: the message to be logged. + * + */ + //'logging.format' => '%date{M j H:i:s} %process %level %stat[%trackid] %msg', + + /* + * Choose which facility should be used when logging with syslog. + * + * These can be used for filtering the syslog output from SimpleSAMLphp into its + * own file by configuring the syslog daemon. + * + * See the documentation for openlog (http://php.net/manual/en/function.openlog.php) for available + * facilities. Note that only LOG_USER is valid on windows. + * + * The default is to use LOG_LOCAL5 if available, and fall back to LOG_USER if not. + */ + 'logging.facility' => defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER, + + /* + * The process name that should be used when logging to syslog. + * The value is also written out by the other logging handlers. + */ + 'logging.processname' => 'simplesamlphp', + + /* + * Logging: file - Logfilename in the loggingdir from above. + */ + 'logging.logfile' => 'simplesamlphp.log', + + /* + * This is an array of outputs. Each output has at least a 'class' option, which + * selects the output. + */ + 'statistics.out' => [ + // Log statistics to the normal log. + /* + [ + 'class' => 'core:Log', + 'level' => 'notice', + ], + */ + // Log statistics to files in a directory. One file per day. + /* + [ + 'class' => 'core:File', + 'directory' => '/var/log/stats', + ], + */ + ], + + + + /*********************** + | PROXY CONFIGURATION | + ***********************/ + + /* + * Proxy to use for retrieving URLs. + * + * Example: + * 'proxy' => 'tcp://proxy.example.com:5100' + */ + 'proxy' => null, + + /* + * Username/password authentication to proxy (Proxy-Authorization: Basic) + * Example: + * 'proxy.auth' = 'myuser:password' + */ + //'proxy.auth' => 'myuser:password', + + + + /************************** + | DATABASE CONFIGURATION | + **************************/ + + /* + * This database configuration is optional. If you are not using + * core functionality or modules that require a database, you can + * skip this configuration. + */ + + /* + * Database connection string. + * Ensure that you have the required PDO database driver installed + * for your connection string. + */ + 'database.dsn' => 'mysql:host=localhost;dbname=saml', + + /* + * SQL database credentials + */ + 'database.username' => 'simplesamlphp', + 'database.password' => 'secret', + 'database.options' => [], + + /* + * (Optional) Table prefix + */ + 'database.prefix' => '', + + /* + * (Optional) Driver options + */ + 'database.driver_options' => [], + + /* + * True or false if you would like a persistent database connection + */ + 'database.persistent' => false, + + /* + * Database secondary configuration is optional as well. If you are only + * running a single database server, leave this blank. If you have + * a primary/secondary configuration, you can define as many secondary servers + * as you want here. Secondaries will be picked at random to be queried from. + * + * Configuration options in the secondary array are exactly the same as the + * options for the primary (shown above) with the exception of the table + * prefix and driver options. + */ + 'database.secondaries' => [ + /* + [ + 'dsn' => 'mysql:host=mysecondary;dbname=saml', + 'username' => 'simplesamlphp', + 'password' => 'secret', + 'persistent' => false, + ], + */ + ], + + + + /************* + | PROTOCOLS | + *************/ + + /* + * Which functionality in SimpleSAMLphp do you want to enable. Normally you would enable only + * one of the functionalities below, but in some cases you could run multiple functionalities. + * In example when you are setting up a federation bridge. + */ + 'enable.saml20-idp' => true, + 'enable.adfs-idp' => false, + + + + /*********** + | MODULES | + ***********/ + + /* + * Configuration for enabling/disabling modules. By default the 'core', 'admin' and 'saml' modules are enabled. + * + * Example: + * + * 'module.enable' => [ + * 'exampleauth' => true, // Setting to TRUE enables. + * 'consent' => false, // Setting to FALSE disables. + * 'core' => null, // Unset or NULL uses default. + * ], + */ + + 'module.enable' => [ + 'exampleauth' => true, + 'core' => true, + 'admin' => true, + 'saml' => true, + 'cron' => true, + 'metarefresh' => true, + ], + + + /************************* + | SESSION CONFIGURATION | + *************************/ + + /* + * This value is the duration of the session in seconds. Make sure that the time duration of + * cookies both at the SP and the IdP exceeds this duration. + */ + 'session.duration' => 60, // 60 seconds + + /* + * Sets the duration, in seconds, data should be stored in the datastore. As the data store is used for + * login and logout requests, this option will control the maximum time these operations can take. + * The default is 4 hours (4*60*60) seconds, which should be more than enough for these operations. + */ + 'session.datastore.timeout' => (4 * 60 * 60), // 4 hours + + /* + * Sets the duration, in seconds, auth state should be stored. + */ + 'session.state.timeout' => (60 * 60), // 1 hour + + /* + * Option to override the default settings for the session cookie name + */ + 'session.cookie.name' => 'SimpleSAMLSessionIDidp', + + /* + * Expiration time for the session cookie, in seconds. + * + * Defaults to 0, which means that the cookie expires when the browser is closed. + * + * Example: + * 'session.cookie.lifetime' => 30*60, + */ + 'session.cookie.lifetime' => 0, + + /* + * Limit the path of the cookies. + * + * Can be used to limit the path of the cookies to a specific subdirectory. + * + * Example: + * 'session.cookie.path' => '/simplesaml/', + */ + 'session.cookie.path' => '/', + + /* + * Cookie domain. + * + * Can be used to make the session cookie available to several domains. + * + * Example: + * 'session.cookie.domain' => '.example.org', + */ + 'session.cookie.domain' => '', + + /* + * Set the secure flag in the cookie. + * + * Set this to TRUE if the user only accesses your service + * through https. If the user can access the service through + * both http and https, this must be set to FALSE. + * + * If unset, SimpleSAMLphp will try to automatically determine the right value + */ + //'session.cookie.secure' => true, + + /* + * Set the SameSite attribute in the cookie. + * + * You can set this to the strings 'None', 'Lax', or 'Strict' to support + * the RFC6265bis SameSite cookie attribute. If set to null, no SameSite + * attribute will be sent. + * + * A value of "None" is required to properly support cross-domain POST + * requests which are used by different SAML bindings. Because some older + * browsers do not support this value, the canSetSameSiteNone function + * can be called to only set it for compatible browsers. + * + * You must also set the 'session.cookie.secure' value above to true. + * + * Example: + * 'session.cookie.samesite' => 'None', + */ + 'session.cookie.samesite' => $httpUtils->canSetSameSiteNone() ? 'None' : null, + + /* + * Options to override the default settings for php sessions. + */ + 'session.phpsession.cookiename' => 'SimpleSAMLidp', + 'session.phpsession.savepath' => null, + 'session.phpsession.httponly' => true, + + /* + * Option to override the default settings for the auth token cookie + */ + 'session.authtoken.cookiename' => 'SimpleSAMLAuthToken', + + /* + * Options for remember me feature for IdP sessions. Remember me feature + * has to be also implemented in authentication source used. + * + * Option 'session.cookie.lifetime' should be set to zero (0), i.e. cookie + * expires on browser session if remember me is not checked. + * + * Session duration ('session.duration' option) should be set according to + * 'session.rememberme.lifetime' option. + * + * It's advised to use remember me feature with session checking function + * defined with 'session.check_function' option. + */ + 'session.rememberme.enable' => false, + 'session.rememberme.checked' => false, + 'session.rememberme.lifetime' => (14 * 86400), + + /* + * Custom function for session checking called on session init and loading. + * See docs/simplesamlphp-advancedfeatures.md for function code example. + * + * Example: + * 'session.check_function' => ['\SimpleSAML\Module\example\Util', 'checkSession'], + */ + + + + /************************** + | MEMCACHE CONFIGURATION | + **************************/ + + /* + * Configuration for the 'memcache' session store. This allows you to store + * multiple redundant copies of sessions on different memcache servers. + * + * 'memcache_store.servers' is an array of server groups. Every data + * item will be mirrored in every server group. + * + * Each server group is an array of servers. The data items will be + * load-balanced between all servers in each server group. + * + * Each server is an array of parameters for the server. The following + * options are available: + * - 'hostname': This is the hostname or ip address where the + * memcache server runs. This is the only required option. + * - 'port': This is the port number of the memcache server. If this + * option isn't set, then we will use the 'memcache.default_port' + * ini setting. This is 11211 by default. + * + * When using the "memcache" extension, the following options are also + * supported: + * - 'weight': This sets the weight of this server in this server + * group. http://php.net/manual/en/function.Memcache-addServer.php + * contains more information about the weight option. + * - 'timeout': The timeout for this server. By default, the timeout + * is 3 seconds. + * + * Example of redundant configuration with load balancing: + * This configuration makes it possible to lose both servers in the + * a-group or both servers in the b-group without losing any sessions. + * Note that sessions will be lost if one server is lost from both the + * a-group and the b-group. + * + * 'memcache_store.servers' => [ + * [ + * ['hostname' => 'mc_a1'], + * ['hostname' => 'mc_a2'], + * ], + * [ + * ['hostname' => 'mc_b1'], + * ['hostname' => 'mc_b2'], + * ], + * ], + * + * Example of simple configuration with only one memcache server, + * running on the same computer as the web server: + * Note that all sessions will be lost if the memcache server crashes. + * + * 'memcache_store.servers' => [ + * [ + * ['hostname' => 'localhost'], + * ], + * ], + * + * Additionally, when using the "memcached" extension, unique keys must + * be provided for each group of servers if persistent connections are + * desired. Each server group can also have an "options" indexed array + * with the options desired for the given group: + * + * 'memcache_store.servers' => [ + * 'memcache_group_1' => [ + * 'options' => [ + * \Memcached::OPT_BINARY_PROTOCOL => true, + * \Memcached::OPT_NO_BLOCK => true, + * \Memcached::OPT_TCP_NODELAY => true, + * \Memcached::OPT_LIBKETAMA_COMPATIBLE => true, + * ], + * ['hostname' => '127.0.0.1', 'port' => 11211], + * ['hostname' => '127.0.0.2', 'port' => 11211], + * ], + * + * 'memcache_group_2' => [ + * 'options' => [ + * \Memcached::OPT_BINARY_PROTOCOL => true, + * \Memcached::OPT_NO_BLOCK => true, + * \Memcached::OPT_TCP_NODELAY => true, + * \Memcached::OPT_LIBKETAMA_COMPATIBLE => true, + * ], + * ['hostname' => '127.0.0.3', 'port' => 11211], + * ['hostname' => '127.0.0.4', 'port' => 11211], + * ], + * ], + * + */ + 'memcache_store.servers' => [ + [ + ['hostname' => 'localhost'], + ], + ], + + /* + * This value allows you to set a prefix for memcache-keys. The default + * for this value is 'simpleSAMLphp', which is fine in most cases. + * + * When running multiple instances of SSP on the same host, and more + * than one instance is using memcache, you probably want to assign + * a unique value per instance to this setting to avoid data collision. + */ + 'memcache_store.prefix' => '', + + /* + * This value is the duration data should be stored in memcache. Data + * will be dropped from the memcache servers when this time expires. + * The time will be reset every time the data is written to the + * memcache servers. + * + * This value should always be larger than the 'session.duration' + * option. Not doing this may result in the session being deleted from + * the memcache servers while it is still in use. + * + * Set this value to 0 if you don't want data to expire. + * + * Note: The oldest data will always be deleted if the memcache server + * runs out of storage space. + */ + 'memcache_store.expires' => 36 * (60 * 60), // 36 hours. + + + + /************************************* + | LANGUAGE AND INTERNATIONALIZATION | + *************************************/ + + /* + * Languages available, RTL languages, and what language is the default. + */ + 'language.available' => [ + 'en', 'no', 'nn', 'se', 'da', 'de', 'sv', 'fi', 'es', 'ca', 'fr', 'it', 'nl', 'lb', + 'cs', 'sk', 'sl', 'lt', 'hr', 'hu', 'pl', 'pt', 'pt-br', 'tr', 'ja', 'zh', 'zh-tw', + 'ru', 'et', 'he', 'id', 'sr', 'lv', 'ro', 'eu', 'el', 'af', 'zu', 'xh', 'st', + ], + 'language.rtl' => ['ar', 'dv', 'fa', 'ur', 'he'], + 'language.default' => 'en', + + /* + * Options to override the default settings for the language parameter + */ + 'language.parameter.name' => 'language', + 'language.parameter.setcookie' => true, + + /* + * Options to override the default settings for the language cookie + */ + 'language.cookie.name' => 'language', + 'language.cookie.domain' => '', + 'language.cookie.path' => '/', + 'language.cookie.secure' => true, + 'language.cookie.httponly' => false, + 'language.cookie.lifetime' => (60 * 60 * 24 * 900), + 'language.cookie.samesite' => $httpUtils->canSetSameSiteNone() ? 'None' : null, + + /** + * Custom getLanguage function called from SimpleSAML\Locale\Language::getLanguage(). + * Function should return language code of one of the available languages or NULL. + * See SimpleSAML\Locale\Language::getLanguage() source code for more info. + * + * This option can be used to implement a custom function for determining + * the default language for the user. + * + * Example: + * 'language.get_language_function' => ['\SimpleSAML\Module\example\Template', 'getLanguage'], + */ + + /************** + | APPEARANCE | + **************/ + + /* + * Which theme directory should be used? + */ + 'theme.use' => 'default', + + /* + * Set this option to the text you would like to appear at the header of each page. Set to false if you don't want + * any text to appear in the header. + */ + //'theme.header' => 'SimpleSAMLphp', + + /** + * A template controller, if any. + * + * Used to intercept certain parts of the template handling, while keeping away unwanted/unexpected hooks. Set + * the 'theme.controller' configuration option to a class that implements the + * \SimpleSAML\XHTML\TemplateControllerInterface interface to use it. + */ + //'theme.controller' => '', + + /* + * Templating options + * + * By default, twig templates are not cached. To turn on template caching: + * Set 'template.cache' to an absolute path pointing to a directory that + * SimpleSAMLphp has read and write permissions to. + */ + //'template.cache' => '', + + /* + * Set the 'template.auto_reload' to true if you would like SimpleSAMLphp to + * recompile the templates (when using the template cache) if the templates + * change. If you don't want to check the source templates for every request, + * set it to false. + */ + 'template.auto_reload' => false, + + /* + * Set this option to true to indicate that your installation of SimpleSAMLphp + * is running in a production environment. This will affect the way resources + * are used, offering an optimized version when running in production, and an + * easy-to-debug one when not. Set it to false when you are testing or + * developing the software, in which case a banner will be displayed to remind + * users that they're dealing with a non-production instance. + * + * Defaults to true. + */ + 'production' => true, + + /* + * SimpleSAMLphp modules can host static resources which are served through PHP. + * The serving of the resources can be configured through these settings. + */ + 'assets' => [ + /* + * These settings adjust the caching headers that are sent + * when serving static resources. + */ + 'caching' => [ + /* + * Amount of seconds before the resource should be fetched again + */ + 'max_age' => 86400, + /* + * Calculate a checksum of every file and send it to the browser + * This allows the browser to avoid downloading assets again in situations + * where the Last-Modified header cannot be trusted, + * for example in cluster setups + * + * Defaults false + */ + 'etag' => false, + ], + ], + + /** + * Set to a full URL if you want to redirect users that land on SimpleSAMLphp's + * front page to somewhere more useful. If left unset, a basic welcome message + * is shown. + */ + //'frontpage.redirect' => 'https://example.com/', + + /********************* + | DISCOVERY SERVICE | + *********************/ + + /* + * Whether the discovery service should allow the user to save his choice of IdP. + */ + 'idpdisco.enableremember' => true, + 'idpdisco.rememberchecked' => true, + + /* + * The disco service only accepts entities it knows. + */ + 'idpdisco.validate' => true, + + 'idpdisco.extDiscoveryStorage' => null, + + /* + * IdP Discovery service look configuration. + * Whether to display a list of idp or to display a dropdown box. For many IdP' a dropdown box + * gives the best use experience. + * + * When using dropdown box a cookie is used to highlight the previously chosen IdP in the dropdown. + * This makes it easier for the user to choose the IdP + * + * Options: [links,dropdown] + */ + 'idpdisco.layout' => 'dropdown', + + + + /************************************* + | AUTHENTICATION PROCESSING FILTERS | + *************************************/ + + /* + * Authentication processing filters that will be executed for all IdPs + */ + 'authproc.idp' => [ + /* Enable the authproc filter below to add URN prefixes to all attributes + 10 => [ + 'class' => 'core:AttributeMap', 'addurnprefix' + ], + */ + /* Enable the authproc filter below to automatically generated eduPersonTargetedID. + 20 => 'core:TargetedID', + */ + + // Adopts language from attribute to use in UI + 30 => 'core:LanguageAdaptor', + + /* When called without parameters, it will fallback to filter attributes 'the old way' + * by checking the 'attributes' parameter in metadata on IdP hosted and SP remote. + */ + 50 => 'core:AttributeLimit', + + /* + * Search attribute "distinguishedName" for pattern and replaces if found + */ + /* + 60 => [ + 'class' => 'core:AttributeAlter', + 'pattern' => '/OU=studerende/', + 'replacement' => 'Student', + 'subject' => 'distinguishedName', + '%replace', + ], + */ + + /* + * Consent module is enabled (with no permanent storage, using cookies). + */ + /* + 90 => [ + 'class' => 'consent:Consent', + 'store' => 'consent:Cookie', + 'focus' => 'yes', + 'checked' => true + ], + */ + // If language is set in Consent module it will be added as an attribute. + 99 => 'core:LanguageAdaptor', + ], + + /* + * Authentication processing filters that will be executed for all SPs + */ + 'authproc.sp' => [ + /* + 10 => [ + 'class' => 'core:AttributeMap', 'removeurnprefix' + ], + */ + + /* + * Generate the 'group' attribute populated from other variables, including eduPersonAffiliation. + 60 => [ + 'class' => 'core:GenerateGroups', 'eduPersonAffiliation' + ], + */ + /* + * All users will be members of 'users' and 'members' + */ + /* + 61 => [ + 'class' => 'core:AttributeAdd', 'groups' => ['users', 'members'] + ], + */ + + // Adopts language from attribute to use in UI + 90 => 'core:LanguageAdaptor', + ], + + + + /************************** + | METADATA CONFIGURATION | + **************************/ + + /* + * This option allows you to specify a directory for your metadata outside of the standard metadata directory + * included in the standard distribution of the software. + */ + 'metadatadir' => 'metadata', + + /* + * This option configures the metadata sources. The metadata sources is given as an array with + * different metadata sources. When searching for metadata, SimpleSAMLphp will search through + * the array from start to end. + * + * Each element in the array is an associative array which configures the metadata source. + * The type of the metadata source is given by the 'type' element. For each type we have + * different configuration options. + * + * Flat file metadata handler: + * - 'type': This is always 'flatfile'. + * - 'directory': The directory we will load the metadata files from. The default value for + * this option is the value of the 'metadatadir' configuration option, or + * 'metadata/' if that option is unset. + * + * XML metadata handler: + * This metadata handler parses an XML file with either an EntityDescriptor element or an + * EntitiesDescriptor element. The XML file may be stored locally, or (for debugging) on a remote + * web server. + * The XML metadata handler defines the following options: + * - 'type': This is always 'xml'. + * - 'file': Path to the XML file with the metadata. + * - 'url': The URL to fetch metadata from. THIS IS ONLY FOR DEBUGGING - THERE IS NO CACHING OF THE RESPONSE. + * + * MDQ metadata handler: + * This metadata handler looks up for the metadata of an entity at the given MDQ server. + * The MDQ metadata handler defines the following options: + * - 'type': This is always 'mdq'. + * - 'server': Base URL of the MDQ server. Mandatory. + * - 'validateCertificate': The certificates file that may be used to sign the metadata. You don't need this + * option if you don't want to validate the signature on the metadata. Optional. + * - 'cachedir': Directory where metadata can be cached. Optional. + * - 'cachelength': Maximum time metadata can be cached, in seconds. Defaults to 24 + * hours (86400 seconds). Optional. + * + * PDO metadata handler: + * This metadata handler looks up metadata of an entity stored in a database. + * + * Note: If you are using the PDO metadata handler, you must configure the database + * options in this configuration file. + * + * The PDO metadata handler defines the following options: + * - 'type': This is always 'pdo'. + * + * Examples: + * + * This example defines two flatfile sources. One is the default metadata directory, the other + * is a metadata directory with auto-generated metadata files. + * + * 'metadata.sources' => [ + * ['type' => 'flatfile'], + * ['type' => 'flatfile', 'directory' => 'metadata-generated'], + * ], + * + * This example defines a flatfile source and an XML source. + * 'metadata.sources' => [ + * ['type' => 'flatfile'], + * ['type' => 'xml', 'file' => 'idp.example.org-idpMeta.xml'], + * ], + * + * This example defines an mdq source. + * 'metadata.sources' => [ + * [ + * 'type' => 'mdq', + * 'server' => 'http://mdq.server.com:8080', + * 'validateCertificate' => [ + * '/var/simplesamlphp/cert/metadata-key.new.crt', + * '/var/simplesamlphp/cert/metadata-key.old.crt' + * ], + * 'cachedir' => '/var/simplesamlphp/mdq-cache', + * 'cachelength' => 86400 + * ] + * ], + * + * This example defines an pdo source. + * 'metadata.sources' => [ + * ['type' => 'pdo'] + * ], + * + * Default: + * 'metadata.sources' => [ + * ['type' => 'flatfile'] + * ], + */ + 'metadata.sources' => [ + ['type' => 'flatfile'], + # webwork sp metadata dir + ['type' => 'flatfile', 'directory' => 'metadata/metarefresh-webwork'], + ], + + /* + * Should signing of generated metadata be enabled by default. + * + * Metadata signing can also be enabled for a individual SP or IdP by setting the + * same option in the metadata for the SP or IdP. + */ + 'metadata.sign.enable' => false, + + /* + * The default key & certificate which should be used to sign generated metadata. These + * are files stored in the cert dir. + * These values can be overridden by the options with the same names in the SP or + * IdP metadata. + * + * If these aren't specified here or in the metadata for the SP or IdP, then + * the 'certificate' and 'privatekey' option in the metadata will be used. + * if those aren't set, signing of metadata will fail. + */ + 'metadata.sign.privatekey' => null, + 'metadata.sign.privatekey_pass' => null, + 'metadata.sign.certificate' => null, + 'metadata.sign.algorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + + + /**************************** + | DATA STORE CONFIGURATION | + ****************************/ + + /* + * Configure the data store for SimpleSAMLphp. + * + * - 'phpsession': Limited datastore, which uses the PHP session. + * - 'memcache': Key-value datastore, based on memcache. + * - 'sql': SQL datastore, using PDO. + * - 'redis': Key-value datastore, based on redis. + * + * The default datastore is 'phpsession'. + */ + 'store.type' => 'phpsession', + + /* + * The DSN the sql datastore should connect to. + * + * See http://www.php.net/manual/en/pdo.drivers.php for the various + * syntaxes. + */ + 'store.sql.dsn' => 'sqlite:/path/to/sqlitedatabase.sq3', + + /* + * The username and password to use when connecting to the database. + */ + 'store.sql.username' => null, + 'store.sql.password' => null, + + /* + * The prefix we should use on our tables. + */ + 'store.sql.prefix' => 'SimpleSAMLphp', + + /* + * The driver-options we should pass to the PDO-constructor. + */ + 'store.sql.options' => [], + + /* + * The hostname and port of the Redis datastore instance. + */ + 'store.redis.host' => 'localhost', + 'store.redis.port' => 6379, + + /* + * The credentials to use when connecting to Redis. + * + * If your Redis server is using the legacy password protection (config + * directive "requirepass" in redis.conf) then you should only provide + * a password. + * + * If your Redis server is using ACL's (which are recommended as of + * Redis 6+) then you should provide both a username and a password. + * See https://redis.io/docs/manual/security/acl/ + */ + 'store.redis.username' => '', + 'store.redis.password' => '', + + /* + * Communicate with Redis over a secure connection instead of plain TCP. + * + * This setting affects both single host connections as + * well as Sentinel mode. + */ + 'store.redis.tls' => false, + + /* + * Verify the Redis server certificate. + */ + 'store.redis.insecure' => false, + + /* + * Files related to secure communication with Redis. + * + * Files are searched in the 'certdir' when using relative paths. + */ + 'store.redis.ca_certificate' => null, + 'store.redis.certificate' => null, + 'store.redis.privatekey' => null, + + /* + * The prefix we should use on our Redis datastore. + */ + 'store.redis.prefix' => 'SimpleSAMLphp', + + /* + * The master group to use for Redis Sentinel. + */ + 'store.redis.mastergroup' => 'mymaster', + + /* + * The Redis Sentinel hosts. + * Example: + * 'store.redis.sentinels' => [ + * 'tcp://[yoursentinel1]:[port]', + * 'tcp://[yoursentinel2]:[port]', + * 'tcp://[yoursentinel3]:[port] + * ], + * + * Use 'tls' instead of 'tcp' in order to make use of the additional + * TLS settings. + */ + 'store.redis.sentinels' => [], + + /********************* + | IdP/SP PROXY MODE | + *********************/ + + /* + * If the IdP in front of SimpleSAMLphp in IdP/SP proxy mode sends + * AuthnContextClassRef, decide whether the AuthnContextClassRef will be + * processed by the IdP/SP proxy or if it will be passed to the SP behind + * the IdP/SP proxy. + */ + 'proxymode.passAuthnContextClassRef' => false, +]; diff --git a/docker-config/idp/config/module_cron.php b/docker-config/idp/config/module_cron.php new file mode 100644 index 0000000000..a05be61da2 --- /dev/null +++ b/docker-config/idp/config/module_cron.php @@ -0,0 +1,8 @@ + 'webwork2', + 'allowed_tags' => ['metarefresh'], + 'debug_message' => true, + 'sendemail' => false, +]; diff --git a/docker-config/idp/config/module_metarefresh.php b/docker-config/idp/config/module_metarefresh.php new file mode 100644 index 0000000000..1b2cf60d61 --- /dev/null +++ b/docker-config/idp/config/module_metarefresh.php @@ -0,0 +1,21 @@ + [ + 'webwork2' => [ + 'cron' => ['metarefresh'], + 'sources' => [ + ['src' => $metadataURL] + ], + 'expiresAfter' => 60 * 60 * 24 * 365 * 10, // 10 years, basically never + 'outputDir' => 'metadata/metarefresh-webwork/', + 'outputFormat' => 'flatfile', + ] + ] +]; diff --git a/docker-config/idp/idp.apache2.conf b/docker-config/idp/idp.apache2.conf new file mode 100644 index 0000000000..5f2e656ebe --- /dev/null +++ b/docker-config/idp/idp.apache2.conf @@ -0,0 +1,7 @@ +SetEnv SIMPLESAMLPHP_CONFIG_DIR /var/www/simplesamlphp/config + +Alias /simplesaml /var/www/simplesamlphp/public + + + Require all granted + diff --git a/docker-config/idp/metadata/saml20-idp-hosted.php b/docker-config/idp/metadata/saml20-idp-hosted.php new file mode 100644 index 0000000000..f0843f3b28 --- /dev/null +++ b/docker-config/idp/metadata/saml20-idp-hosted.php @@ -0,0 +1,50 @@ + '__DEFAULT__', + + // X.509 key and certificate. Relative to the cert directory. + 'privatekey' => 'server.pem', + 'certificate' => 'server.crt', + + /* + * Authentication source to use. Must be one that is configured in + * 'config/authsources.php'. + */ + 'auth' => 'example-userpass', + + /* Uncomment the following to use the uri NameFormat on attributes. */ + 'attributes.NameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', + 'authproc' => [ + // Convert attribute names to oids. + 100 => ['class' => 'core:AttributeMap', 'name2oid'], + ], + + /* + * Uncomment the following to specify the registration information in the + * exported metadata. Refer to: + * http://docs.oasis-open.org/security/saml/Post2.0/saml-metadata-rpi/v1.0/cs01/saml-metadata-rpi-v1.0-cs01.html + * for more information. + */ + /* + 'RegistrationInfo' => [ + 'authority' => 'urn:mace:example.org', + 'instant' => '2008-01-17T11:28:03Z', + 'policies' => [ + 'en' => 'http://example.org/policy', + 'es' => 'http://example.org/politica', + ], + ], + */ +]; diff --git a/htdocs/css/rtl.css b/htdocs/css/rtl.css index c23b30a25c..b1ad162418 100644 --- a/htdocs/css/rtl.css +++ b/htdocs/css/rtl.css @@ -1,17 +1,3 @@ -/* WeBWorK Online Homework Delivery System - * Copyright © 2000-2024 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 diff --git a/htdocs/js/AchievementItems/achievementitems.js b/htdocs/js/AchievementItems/achievementitems.js deleted file mode 100644 index 267d27f495..0000000000 --- a/htdocs/js/AchievementItems/achievementitems.js +++ /dev/null @@ -1,33 +0,0 @@ -(() => { - for (const setSelect of document.querySelectorAll('select[data-problems]')) { - setSelect.addEventListener('change', () => { - const problemIds = JSON.parse( - Array.from(setSelect.querySelectorAll('option')).find((option) => option.value === setSelect.value) - ?.dataset.problemIds ?? '[]' - ); - - const problemSelect = document.getElementById(setSelect.dataset.problems); - if (problemSelect) { - for (const option of problemSelect.querySelectorAll('option')) option.remove(); - for (const id of problemIds) { - const option = document.createElement('option'); - option.value = id; - option.text = id; - problemSelect.add(option); - } - } - - // This is only used by the "Box of Transmogrification". - const problemSelect2 = document.getElementById(setSelect.dataset.problems2); - if (problemSelect2) { - for (const option of problemSelect2.querySelectorAll('option')) option.remove(); - for (const id of problemIds) { - const option = document.createElement('option'); - option.value = id; - option.text = id; - problemSelect2.add(option); - } - } - }); - } -})(); diff --git a/htdocs/js/Achievements/achievements.scss b/htdocs/js/Achievements/achievements.scss index 783c9f1830..0a59532577 100644 --- a/htdocs/js/Achievements/achievements.scss +++ b/htdocs/js/Achievements/achievements.scss @@ -1,17 +1,3 @@ -/* 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. - */ - .levelouterbar { height: 20px; border-style: solid; @@ -24,11 +10,6 @@ background-color: var(--ww-achievement-level-color, #88d); } -.cheevoouterbox img { - height: 50px; - width: 50px; -} - .locked { opacity: 0.65; @@ -69,16 +50,3 @@ font-weight: bold; } } - -.achievement-item { - margin-bottom: 15px; - margin-left: 15px; - - h3 { - line-height: 15px; - font-size: 15px; - margin-bottom: 5px; - margin-top: 2px; - font-weight: bold; - } -} diff --git a/htdocs/js/AddUsers/add-users.js b/htdocs/js/AddUsers/add-users.js new file mode 100644 index 0000000000..5976698420 --- /dev/null +++ b/htdocs/js/AddUsers/add-users.js @@ -0,0 +1,16 @@ +(() => { + const passwordSelect = document.getElementById('fallback_password_source'); + + const setPlaceholders = () => { + for (const input of document.querySelectorAll('.new_password')) { + let placeholder = 'placeholder'; + for (const part of passwordSelect.value.split('_')) { + placeholder += part.charAt(0).toUpperCase() + part.slice(1); + } + input.setAttribute('placeholder', passwordSelect.dataset[placeholder]); + } + }; + + passwordSelect.addEventListener('change', setPlaceholders); + setPlaceholders(); +})(); diff --git a/htdocs/js/CourseAdmin/manage_otp_secrets.js b/htdocs/js/CourseAdmin/manage_otp_secrets.js new file mode 100644 index 0000000000..d452567068 --- /dev/null +++ b/htdocs/js/CourseAdmin/manage_otp_secrets.js @@ -0,0 +1,34 @@ +(() => { + // Save user menus to be updated. + const sourceSingleUserMenu = document.getElementById('sourceSingleUserID'); + const destSingleUserMenu = document.getElementById('destSingleUserID'); + const sourceMultipleUserMenu = document.getElementById('sourceMultipleUserID'); + const destResetUserMenu = document.getElementById('destResetUserID'); + + const updateUserMenu = (e, menu, selectFirst) => { + const userList = e.target.options[e.target.selectedIndex].dataset.users.split(':'); + while (menu.length > 1) menu.lastChild.remove(); + if (selectFirst) { + menu.selectedIndex = 0; + } + userList.forEach((user) => { + const userOption = document.createElement('option'); + userOption.value = userOption.text = user; + menu.append(userOption); + }); + }; + + // Update user menu when course ID is selected/changed. + document.getElementById('sourceSingleCourseID')?.addEventListener('change', (e) => { + updateUserMenu(e, sourceSingleUserMenu, true); + }); + document.getElementById('destSingleCourseID')?.addEventListener('change', (e) => { + updateUserMenu(e, destSingleUserMenu, true); + }); + document.getElementById('sourceMultipleCourseID')?.addEventListener('change', (e) => { + updateUserMenu(e, sourceMultipleUserMenu, false); + }); + document.getElementById('sourceResetCourseID')?.addEventListener('change', (e) => { + updateUserMenu(e, destResetUserMenu, false); + }); +})(); diff --git a/htdocs/js/DatePicker/datepicker.js b/htdocs/js/DatePicker/datepicker.js index b58ce42c08..193ae9b6a1 100644 --- a/htdocs/js/DatePicker/datepicker.js +++ b/htdocs/js/DatePicker/datepicker.js @@ -5,6 +5,7 @@ const datetimeFormats = { en: 'L/d/yy, h:mm a', 'en-US': 'L/d/yy, h:mm a', + 'en-GB': 'dd/LL/yyyy, HH:mm', 'cs-CZ': 'dd.LL.yy H:mm', de: 'dd.LL.yy, HH:mm', el: 'd/L/yy, h:mm a', @@ -32,30 +33,51 @@ const reduced_rule = document.getElementById(`${name}.reduced_scoring_date_id`); if (reduced_rule) groupRules.splice(1, 0, [reduced_rule]); - // Compute the time difference between the current browser timezone and the course timezone. + // Compute the time difference between a time in the browser timezone and the same time in the course timezone. // flatpickr gives the time in the browser's timezone, and this is used to adjust to the course timezone. - // Note that this is in seconds. - const timezoneAdjustment = - new Date(new Date().toLocaleString('en-US')).getTime() - - new Date( - new Date().toLocaleString('en-US', { timeZone: open_rule.dataset.timezone ?? 'America/New_York' }) - ).getTime(); + // Note that the input time is in seconds and output times is in milliseconds. + const timezoneAdjustment = (time) => { + const dateTime = new Date(0); + dateTime.setUTCSeconds(time); + return ( + new Date(dateTime.toLocaleString('en-US')).getTime() - + new Date( + dateTime.toLocaleString('en-US', { timeZone: open_rule.dataset.timezone ?? 'America/New_York' }) + ).getTime() + ); + }; for (const rule of groupRules) { - const value = - rule[0].value || document.getElementsByName(`${rule[0].name}.class_value`)[0]?.dataset.classValue; - rule.push(value ? parseInt(value) * 1000 - timezoneAdjustment : 0); + const classValue = document.getElementsByName(`${rule[0].name}.class_value`)[0]?.dataset.classValue; + const value = rule[0].value || classValue; + rule.push(value ? parseInt(value) * 1000 - timezoneAdjustment(parseInt(value)) : 0); + if (classValue) rule.push(parseInt(classValue) * 1000 - timezoneAdjustment(parseInt(classValue))); } - const update = () => { - for (let i = 1; i < groupRules.length; ++i) { - const prevFieldDate = - groupRules[i - 1][0]?.parentNode._flatpickr.selectedDates[0]?.getTime() || groupRules[i - 1][1]; + const update = (input) => { + const activeIndex = groupRules.findIndex((r) => r[0] === input); + if (activeIndex == -1) return; + const activeFieldDate = + groupRules[activeIndex][0]?.parentNode._flatpickr.selectedDates[0]?.getTime() || + groupRules[activeIndex][2] || + groupRules[activeIndex][1]; + + for (let i = 0; i < groupRules.length; ++i) { + if (i == activeIndex) continue; const thisFieldDate = - groupRules[i][0]?.parentNode._flatpickr.selectedDates[0]?.getTime() || groupRules[i][1]; - if (prevFieldDate && thisFieldDate && prevFieldDate > thisFieldDate) { - groupRules[i][0].parentNode._flatpickr.setDate(prevFieldDate, true); - } + groupRules[i][0]?.parentNode._flatpickr.selectedDates[0]?.getTime() || + groupRules[i][2] || + groupRules[i][1]; + if (i < activeIndex && thisFieldDate > activeFieldDate) + groupRules[i][0].parentNode._flatpickr.setDate( + activeFieldDate === groupRules[i][2] ? undefined : activeFieldDate, + true + ); + else if (i > activeIndex && thisFieldDate < activeFieldDate) + groupRules[i][0].parentNode._flatpickr.setDate( + activeFieldDate === groupRules[i][2] ? undefined : activeFieldDate, + true + ); } }; @@ -103,9 +125,9 @@ selectedDate.setFullYear(today.getFullYear()); selectedDate.setMonth(today.getMonth()); selectedDate.setDate(today.getDate()); - fp.setDate(selectedDate); + fp.setDate(selectedDate, true); } else if (index === 1) { - fp.setDate(new Date()); + fp.setDate(new Date(), true); } } }) @@ -114,7 +136,9 @@ if (this.input.value === orig_value) this.altInput.classList.remove('changed'); else this.altInput.classList.add('changed'); }, - onClose: update, + onClose() { + return update(this.input); + }, onReady() { // Flatpickr hides the original input and adds the alternate input after it. That messes up the // bootstrap input group styling. So move the now hidden original input after the created alternate @@ -132,12 +156,13 @@ // Make the alternate input left-to-right even for right-to-left languages. this.altInput.dir = 'ltr'; - this.altInput.addEventListener('blur', update); + this.altInput.addEventListener('blur', () => update(this.input)); }, parseDate(datestr, format) { // Deal with the case of a unix timestamp. The timezone needs to be adjusted back as this is for // the unix timestamp stored in the hidden input whose value will be sent to the server. - if (format === 'U') return new Date(parseInt(datestr) * 1000 - timezoneAdjustment); + if (format === 'U') + return new Date(parseInt(datestr) * 1000 - timezoneAdjustment(parseInt(datestr))); // Next attempt to parse the datestr with the current format. This should not be adjusted. It is // for display only. @@ -152,7 +177,7 @@ formatDate(date, format) { // In this case the date provided is in the browser's time zone. So it needs to be adjusted to the // timezone of the course. - if (format === 'U') return (date.getTime() + timezoneAdjustment) / 1000; + if (format === 'U') return (date.getTime() + timezoneAdjustment(date.getTime() / 1000)) / 1000; return luxon.DateTime.fromMillis(date.getTime()).toFormat( datetimeFormats[luxon.Settings.defaultLocale] diff --git a/htdocs/js/GatewayQuiz/gateway.js b/htdocs/js/GatewayQuiz/gateway.js index 4aecb3f37e..365389560c 100644 --- a/htdocs/js/GatewayQuiz/gateway.js +++ b/htdocs/js/GatewayQuiz/gateway.js @@ -158,22 +158,20 @@ const remainingTime = serverDueTime - browserTime + timeDelta; - if (!timerDiv.dataset.acting) { - if (remainingTime <= 10 - gracePeriod) { - if (sessionStorage.getItem('gatewayAlertStatus')) { - sessionStorage.removeItem('gatewayAlertStatus'); - - // Submit the test if time is expired and near the end of grace period. - actuallySubmit = true; - submitAnswers.click(); - } - } else { - // Set the timer text and check alerts at page load. - updateTimer(); + if (!timerDiv.dataset.acting && remainingTime <= 10 - gracePeriod) { + if (sessionStorage.getItem('gatewayAlertStatus')) { + sessionStorage.removeItem('gatewayAlertStatus'); - // Start the timer. - setInterval(updateTimer, 1000); + // Submit the test if time is expired and near the end of grace period. + actuallySubmit = true; + submitAnswers.click(); } + } else { + // Set the timer text and check alerts at page load. + updateTimer(); + + // Start the timer. + setInterval(updateTimer, 1000); } } diff --git a/htdocs/js/GatewayQuiz/gateway.scss b/htdocs/js/GatewayQuiz/gateway.scss index 69ebedd7da..f7d450dff6 100644 --- a/htdocs/js/GatewayQuiz/gateway.scss +++ b/htdocs/js/GatewayQuiz/gateway.scss @@ -1,17 +1,3 @@ -/* 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. - */ - /* gateway styles */ div.gwMessage { diff --git a/htdocs/js/MathJaxConfig/mathjax-config.js b/htdocs/js/MathJaxConfig/mathjax-config.js index c998d67ce5..937f33393f 100644 --- a/htdocs/js/MathJaxConfig/mathjax-config.js +++ b/htdocs/js/MathJaxConfig/mathjax-config.js @@ -98,6 +98,24 @@ if (!window.MathJax) { AM.symbols.splice(i, 0, { input: trigger, ...newTriggers[trigger].symbols }); } + // The following is a workaround for a bug in MathJax when the math renderer is changed. + // Note that this should be removed when we have upgraded to MathJax 4. + const { STATE } = MathJax._.core.MathItem; + const { Menu } = MathJax._.ui.menu.Menu; + const { mathjax } = MathJax._.mathjax; + Menu.prototype.rerender = function (start = STATE.TYPESET) { + this.rerenderStart = Math.min(start, this.rerenderStart); + if (!Menu.loading) { + if (this.rerenderStart <= STATE.COMPILED) this.document.reset({ inputJax: [] }); + MathJax.startup.promise.then(() => { + mathjax.handleRetriesFor(() => { + this.document.rerender(this.rerenderStart); + this.rerenderStart = STATE.LAST; + }); + }); + } + }; + return MathJax.startup.defaultReady(); } }, diff --git a/htdocs/js/PGCodeMirror/PG.js b/htdocs/js/PGCodeMirror/PG.js deleted file mode 100644 index e003158379..0000000000 --- a/htdocs/js/PGCodeMirror/PG.js +++ /dev/null @@ -1,1567 +0,0 @@ -// 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 = 'comment'; - 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.registerHelper('fold', 'PG', (cm, start) => { - const m1 = - /^\s*BEGIN_(PGML|PGML_SOLUTION|PGML_HINT|TEXT)\s*$/.exec(cm.getLine(start.line)) || - /^\s*[$\w]*\s*->\s*BEGIN_(TIKZ|LATEX_IMAGE)\s*$/.exec(cm.getLine(start.line)); - const m2 = /^\s*(Section|Scaffold)::Begin/.exec(cm.getLine(start.line)); - if (m1 || m2) { - for (let current_line = start.line + 1; current_line <= cm.lineCount(); ++current_line) { - const end_re = m1 ? RegExp(`END_${m1[1]}`) : RegExp(`${m2[1]}::End`); - if (end_re.test(cm.getLine(current_line))) { - return { - from: CodeMirror.Pos(start.line, cm.getLine(start.line).length), - to: CodeMirror.Pos(current_line, cm.getLine(current_line).length) - }; - } - } - } - return; - }); - - 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 ? c : x); - } - - // eating and vomiting a part of stream from current position - function eatSuffix(stream, c) { - var x = stream.pos + c; - var y; - if (x <= 0) stream.pos = 0; - else if (x >= (y = stream.string.length - 1)) stream.pos = y; - else stream.pos = x; - } -})(); diff --git a/htdocs/js/PGCodeMirror/comment.js b/htdocs/js/PGCodeMirror/comment.js deleted file mode 100644 index 356b9ce146..0000000000 --- a/htdocs/js/PGCodeMirror/comment.js +++ /dev/null @@ -1,287 +0,0 @@ -// CodeMirror, copyright (c) by Marijn Haverbeke and others -// Distributed under an MIT license: https://codemirror.net/5/LICENSE - -(function (mod) { - if (typeof exports == 'object' && typeof module == 'object') - // CommonJS - mod(require('../../lib/codemirror')); - else if (typeof define == 'function' && define.amd) - // AMD - define(['../../lib/codemirror'], mod); - // Plain browser env - else mod(CodeMirror); -})(function (CodeMirror) { - 'use strict'; - - var noOptions = {}; - var nonWS = /[^\s\u00a0]/; - var Pos = CodeMirror.Pos, - cmp = CodeMirror.cmpPos; - - function firstNonWS(str) { - var found = str.search(nonWS); - return found == -1 ? 0 : found; - } - - CodeMirror.commands.toggleComment = function (cm) { - cm.toggleComment(); - }; - - CodeMirror.defineExtension('toggleComment', function (options) { - if (!options) options = noOptions; - var cm = this; - var minLine = Infinity, - ranges = this.listSelections(), - mode = null; - for (var i = ranges.length - 1; i >= 0; i--) { - var from = ranges[i].from(), - to = ranges[i].to(); - if (from.line >= minLine) continue; - if (to.line >= minLine) to = Pos(minLine, 0); - minLine = from.line; - if (mode == null) { - if (cm.uncomment(from, to, options)) mode = 'un'; - else { - cm.lineComment(from, to, options); - mode = 'line'; - } - } else if (mode == 'un') { - cm.uncomment(from, to, options); - } else { - cm.lineComment(from, to, options); - } - } - }); - - // Rough heuristic to try and detect lines that are part of multi-line string - function probablyInsideString(cm, pos, line) { - return /\bstring\b/.test(cm.getTokenTypeAt(Pos(pos.line, 0))) && !/^[\'\"\`]/.test(line); - } - - const md_section = RegExp( - 'DESCRIPTION|KEYWORDS|DBsubject|DBchapter|DBsection|Date|Author|Institution ' + - '|MO|Static|TitleText|EditionText|AuthorText|Section|Problem|Language|Level' - ); - - // Custom version of getMode for PG files. - function getMode(cm, pos) { - const mode = cm.getModeAt(pos); - // Clear any comment fields of mode. - delete mode.lineComment; - delete mode.blockCommentStart; - delete mode.blockCommentEnd; - - if (md_section.test(cm.getLine(pos.line)) || insideDescriptionBlock(cm, pos)) { - mode.lineComment = '##'; - mode.name = 'PG_meta'; - } else if (inPGMLBlock(cm, pos)) { - mode.name = 'PGML'; - mode.blockCommentStart = '[%'; - mode.blockCommentEnd = '%]'; - } else if (inTikzBlock(cm, pos)) { - mode.lineComment = '%'; - mode.name = 'tikz'; - } else { - mode.name = 'perl'; - mode.lineComment = '#'; - } - return mode; - } - - function insideDescriptionBlock(cm, pos) { - for (let line = pos.line; line >= 0; --line) { - if (/ENDDESCRIPTION/.test(cm.getLine(line))) return false; - if (/DESCRIPTION/.test(cm.getLine(line))) return true; - } - return false; - } - - function inTikzBlock(cm, pos) { - for (let line = pos.line; line >= 0; --line) { - if (/BEGIN_TIKZ|BEGIN_LATEX_IMAGE/.test(cm.getLine(line))) return true; - if (/END_PGML|END_TIKZ|END_LATEX_IMAGE/.test(cm.getLine(line))) return false; - } - return false; - } - - function inPGMLBlock(cm, pos) { - for (let line = pos.line; line >= 0; --line) { - if (/BEGIN_PGML/.test(cm.getLine(line))) return true; - if (/END_PGML/.test(cm.getLine(line))) return false; - } - return false; - } - - CodeMirror.defineExtension('lineComment', function (from, to, options) { - if (!options) options = noOptions; - var self = this, - mode = getMode(self, from); - var firstLine = self.getLine(from.line); - if (firstLine == null || probablyInsideString(self, from, firstLine)) return; - - var commentString = options.lineComment || mode.lineComment; - if (!commentString) { - if (options.blockCommentStart || mode.blockCommentStart) { - options.fullLines = true; - self.blockComment(from, to, options); - } - return; - } - - var end = Math.min(to.ch != 0 || to.line == from.line ? to.line + 1 : to.line, self.lastLine() + 1); - var pad = options.padding == null ? ' ' : options.padding; - var blankLines = options.commentBlankLines || from.line == to.line; - - self.operation(function () { - if (options.indent) { - var baseString = null; - for (var i = from.line; i < end; ++i) { - var line = self.getLine(i); - var whitespace = line.search(nonWS) === -1 ? line : line.slice(0, firstNonWS(line)); - if (baseString == null || baseString.length > whitespace.length) { - baseString = whitespace; - } - } - for (var i = from.line; i < end; ++i) { - var line = self.getLine(i), - cut = baseString.length; - if (!blankLines && !nonWS.test(line)) continue; - if (line.slice(0, cut) != baseString) cut = firstNonWS(line); - self.replaceRange(baseString + commentString + pad, Pos(i, 0), Pos(i, cut)); - } - } else { - for (var i = from.line; i < end; ++i) { - if (blankLines || nonWS.test(self.getLine(i))) self.replaceRange(commentString + pad, Pos(i, 0)); - } - } - }); - }); - - CodeMirror.defineExtension('blockComment', function (from, to, options) { - if (!options) options = noOptions; - var self = this, - mode = getMode(self, from); - var startString = options.blockCommentStart || mode.blockCommentStart; - var endString = options.blockCommentEnd || mode.blockCommentEnd; - if (!startString || !endString) { - if ((options.lineComment || mode.lineComment) && options.fullLines != false) - self.lineComment(from, to, options); - return; - } - if (/\bcomment\b/.test(self.getTokenTypeAt(Pos(from.line, 0)))) return; - - var end = Math.min(to.line, self.lastLine()); - if (end != from.line && to.ch == 0 && nonWS.test(self.getLine(end))) --end; - - var pad = options.padding == null ? ' ' : options.padding; - if (from.line > end) return; - - self.operation(function () { - if (options.fullLines != false) { - var lastLineHasText = nonWS.test(self.getLine(end)); - self.replaceRange(pad + endString, Pos(end)); - self.replaceRange(startString + pad, Pos(from.line, 0)); - var lead = options.blockCommentLead || mode.blockCommentLead; - if (lead != null) - for (var i = from.line + 1; i <= end; ++i) - if (i != end || lastLineHasText) self.replaceRange(lead + pad, Pos(i, 0)); - } else { - var atCursor = cmp(self.getCursor('to'), to) == 0, - empty = !self.somethingSelected(); - self.replaceRange(endString, to); - if (atCursor) self.setSelection(empty ? to : self.getCursor('from'), to); - self.replaceRange(startString, from); - } - }); - }); - - CodeMirror.defineExtension('uncomment', function (from, to, options) { - if (!options) options = noOptions; - var self = this, - mode = getMode(self, from); - var end = Math.min(to.ch != 0 || to.line == from.line ? to.line : to.line - 1, self.lastLine()), - start = Math.min(from.line, end); - - // Try finding line comments - var lineString = options.lineComment || mode.lineComment, - lines = []; - var pad = options.padding == null ? ' ' : options.padding, - didSomething; - lineComment: { - if (!lineString) break lineComment; - for (var i = start; i <= end; ++i) { - var line = self.getLine(i); - var found = line.indexOf(lineString); - if (found > -1 && !/comment/.test(self.getTokenTypeAt(Pos(i, found + 1)))) found = -1; - if (found == -1 && nonWS.test(line)) break lineComment; - if (found > -1 && nonWS.test(line.slice(0, found))) break lineComment; - lines.push(line); - } - self.operation(function () { - for (var i = start; i <= end; ++i) { - var line = lines[i - start]; - var pos = line.indexOf(lineString), - endPos = pos + lineString.length; - if (pos < 0) continue; - if (line.slice(endPos, endPos + pad.length) == pad) endPos += pad.length; - didSomething = true; - self.replaceRange('', Pos(i, pos), Pos(i, endPos)); - } - }); - if (didSomething) return true; - } - - // Try block comments - var startString = options.blockCommentStart || mode.blockCommentStart; - var endString = options.blockCommentEnd || mode.blockCommentEnd; - if (!startString || !endString) return false; - var lead = options.blockCommentLead || mode.blockCommentLead; - var startLine = self.getLine(start), - open = startLine.indexOf(startString); - if (open == -1) return false; - var endLine = end == start ? startLine : self.getLine(end); - var close = endLine.indexOf(endString, end == start ? open + startString.length : 0); - var insideStart = Pos(start, open + 1), - insideEnd = Pos(end, close + 1); - if ( - close == -1 || - !/comment/.test(self.getTokenTypeAt(insideStart)) || - !/comment/.test(self.getTokenTypeAt(insideEnd)) || - self.getRange(insideStart, insideEnd, '\n').indexOf(endString) > -1 - ) - return false; - - // Avoid killing block comments completely outside the selection. - // Positions of the last startString before the start of the selection, and the first endString after it. - var lastStart = startLine.lastIndexOf(startString, from.ch); - var firstEnd = - lastStart == -1 ? -1 : startLine.slice(0, from.ch).indexOf(endString, lastStart + startString.length); - if (lastStart != -1 && firstEnd != -1 && firstEnd + endString.length != from.ch) return false; - // Positions of the first endString after the end of the selection, and the last startString before it. - firstEnd = endLine.indexOf(endString, to.ch); - var almostLastStart = endLine.slice(to.ch).lastIndexOf(startString, firstEnd - to.ch); - lastStart = firstEnd == -1 || almostLastStart == -1 ? -1 : to.ch + almostLastStart; - if (firstEnd != -1 && lastStart != -1 && lastStart != to.ch) return false; - - self.operation(function () { - self.replaceRange( - '', - Pos(end, close - (pad && endLine.slice(close - pad.length, close) == pad ? pad.length : 0)), - Pos(end, close + endString.length) - ); - var openEnd = open + startString.length; - if (pad && startLine.slice(openEnd, openEnd + pad.length) == pad) openEnd += pad.length; - self.replaceRange('', Pos(start, open), Pos(start, openEnd)); - if (lead) - for (var i = start + 1; i <= end; ++i) { - var line = self.getLine(i), - found = line.indexOf(lead); - if (found == -1 || nonWS.test(line.slice(0, found))) continue; - var foundEnd = found + lead.length; - if (pad && line.slice(foundEnd, foundEnd + pad.length) == pad) foundEnd += pad.length; - self.replaceRange('', Pos(i, found), Pos(i, foundEnd)); - } - }); - return true; - }); -}); diff --git a/htdocs/js/PGCodeMirror/pgeditor.js b/htdocs/js/PGCodeMirror/pgeditor.js index eee29e1233..28e91c4db7 100644 --- a/htdocs/js/PGCodeMirror/pgeditor.js +++ b/htdocs/js/PGCodeMirror/pgeditor.js @@ -1,148 +1,15 @@ -/* WeBWorK Online Homework Delivery System - * Copyright © 2000-2024 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 () => { + const editorContainer = document.querySelector('.code-mirror-editor'); + if (!PGCodeMirrorEditor || !editorContainer) return; -(async function () { - if (!CodeMirror) return; + const editorInput = document.getElementsByName(editorContainer.id)[0]; - 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; - } + const cm = (webworkConfig.pgCodeMirror = new PGCodeMirrorEditor.View(editorContainer, { + source: editorInput?.value ?? '', + language: editorContainer.dataset.language ?? 'pg' + })); - if (el.dataset.loaded) { - resolve(); - return; - } + new ResizeObserver(() => cm.refresh('window-resize')).observe(editorContainer); - 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 mode = document.querySelector('.codeMirrorEditor')?.dataset.mode ?? 'PG'; - const options = { - mode, - indentUnit: 4, - tabMode: 'spaces', - lineNumbers: true, - lineWrapping: true, - extraKeys: { - Tab: (cm) => cm.execCommand('insertSoftTab'), - 'Shift-Ctrl-[': (cm) => cm.foldCode(cm.getCursor(), { scanUp: true }), - 'Cmd-Alt-[': (cm) => cm.foldCode(cm.getCursor(), { scanUp: true }), - 'Ctrl-Alt-[': (cm) => CodeMirror.commands.foldAll(cm), - 'Ctrl-Alt-]': (cm) => CodeMirror.commands.unfoldAll(cm) - }, - highlightSelectionMatches: { annotateScrollbar: true }, - matchBrackets: true, - inputStyle: 'contenteditable', - spellcheck: localStorage.getItem('WW_PGEditor_spellcheck') === 'true', - gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] - }; - - if (mode === 'PG') { - options.extraKeys['Ctrl-/'] = (cm) => cm.execCommand('toggleComment'); - options.extraKeys['Cmd-/'] = (cm) => cm.execCommand('toggleComment'); - options.foldGutter = { rangeFinder: new CodeMirror.fold.combine(CodeMirror.fold.PG) }; - options.fold = 'PG'; - } else { - options.foldGutter = true; - } - - const cm = (webworkConfig.pgCodeMirror = CodeMirror.fromTextArea( - document.querySelector('.codeMirrorEditor'), - options - )); - cm.setSize('100%', '550px'); - - // Refresh the CodeMirror instance anytime the containing div resizes so that if line wrapping changes, - // the mouse cursor will still go to the correct place when the user clicks on the CodeMirror window. - new ResizeObserver(() => cm.refresh()).observe(document.querySelector('.CodeMirror')); - - 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'); - }); + editorInput?.form.addEventListener('submit', () => (editorInput.value = cm.source)); })(); diff --git a/htdocs/js/PGCodeMirror/pgeditor.scss b/htdocs/js/PGCodeMirror/pgeditor.scss index 1fd132c9fc..3f1cd895c1 100644 --- a/htdocs/js/PGCodeMirror/pgeditor.scss +++ b/htdocs/js/PGCodeMirror/pgeditor.scss @@ -1,25 +1,30 @@ -/* WeBWorK Online Homework Delivery System - * Copyright © 2000-2024 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 { +.code-mirror-editor { border: 1px solid #ddd; min-height: 400px; + overflow: auto; resize: vertical; + height: 600px; + + .cm-editor { + height: 100%; + + .cm-scroller { + height: 100%; + + .cm-content { + height: 100%; + min-height: 400px; + } + } + + .cm-panels { + z-index: 18; + } + } } -// This style is only used if the CodeMirror editor is disabled in localOverrides.conf. -.codeMirrorEditor { +// This style is used if the CodeMirror editor is disabled in localOverrides.conf. +.text-area-editor { border: 1px solid #ddd; padding: 2px; height: 550px; @@ -27,38 +32,3 @@ 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/htdocs/js/PGProblemEditor/pgproblemeditor.js b/htdocs/js/PGProblemEditor/pgproblemeditor.js index f7e419d47b..f48214574f 100644 --- a/htdocs/js/PGProblemEditor/pgproblemeditor.js +++ b/htdocs/js/PGProblemEditor/pgproblemeditor.js @@ -1,4 +1,57 @@ (() => { + const fileChooserForm = document.forms['pg-editor-file-chooser']; + if (fileChooserForm) { + const newProblemRadio = document.getElementById('new_problem'); + + const sourceFilePathInput = fileChooserForm.elements['sourceFilePath']; + const filePathRadio = document.getElementById('file_path'); + + const sampleProblemFileSelect = fileChooserForm.elements['sampleProblemFile']; + const sampleProblemRadio = document.getElementById('sample_problem'); + + newProblemRadio?.addEventListener('change', () => { + if (newProblemRadio.checked) { + sampleProblemFileSelect.required = false; + sourceFilePathInput.required = false; + } + }); + + if (filePathRadio && sourceFilePathInput) { + const filePathSelected = () => { + sampleProblemFileSelect.required = false; + sourceFilePathInput.required = true; + filePathRadio.checked = true; + }; + filePathRadio.addEventListener('change', () => { + if (filePathRadio.checked) filePathSelected(); + }); + sourceFilePathInput.addEventListener('focusin', filePathSelected); + } + if (sampleProblemRadio && sampleProblemFileSelect) { + const sampleProblemSelected = () => { + sampleProblemFileSelect.required = true; + sourceFilePathInput.required = false; + sampleProblemRadio.checked = true; + }; + sampleProblemRadio.addEventListener('change', () => { + if (sampleProblemRadio.checked) sampleProblemSelected(); + }); + sampleProblemFileSelect.addEventListener('change', sampleProblemSelected); + sampleProblemFileSelect.addEventListener('focusin', sampleProblemSelected); + } + + fileChooserForm.addEventListener('submit', (e) => { + if (!fileChooserForm.checkValidity()) { + e.preventDefault(); + e.stopPropagation(); + } + + fileChooserForm.classList.add('was-validated'); + }); + + return; + } + // Add a container for message toasts. const toastContainer = document.createElement('div'); toastContainer.classList.add('toast-container', 'position-fixed', 'bottom-0', 'end-0', 'p-3'); @@ -48,7 +101,7 @@ request_object.rpc_command = 'saveFile'; request_object.outputFilePath = document.getElementsByName('temp_file_path')[0]?.value ?? ''; request_object.fileContents = - webworkConfig?.pgCodeMirror?.getValue() ?? document.getElementById('problemContents')?.value ?? ''; + webworkConfig?.pgCodeMirror?.source ?? document.getElementById('problemContents')?.value ?? ''; if (!request_object.outputFilePath) return; @@ -119,7 +172,7 @@ request_object.rpc_command = 'tidyPGCode'; request_object.pgCode = - webworkConfig?.pgCodeMirror?.getValue() ?? document.getElementById('problemContents')?.value ?? ''; + webworkConfig?.pgCodeMirror?.source ?? document.getElementById('problemContents')?.value ?? ''; fetch(webserviceURL, { method: 'post', mode: 'same-origin', body: new URLSearchParams(request_object) }) .then((response) => response.json()) @@ -141,7 +194,7 @@ if (request_object.pgCode === data.result_data.tidiedPGCode) { showMessage('There were no changes to the code.', true); } else { - if (webworkConfig?.pgCodeMirror) webworkConfig.pgCodeMirror.setValue(data.result_data.tidiedPGCode); + if (webworkConfig?.pgCodeMirror) webworkConfig.pgCodeMirror.source = data.result_data.tidiedPGCode; else document.getElementById('problemContents').value = data.result_data.tidiedPGCode; saveTempFile(); showMessage('Successfuly perltidied code.', true); @@ -161,7 +214,7 @@ request_object.rpc_command = 'convertCodeToPGML'; request_object.pgCode = - webworkConfig?.pgCodeMirror?.getValue() ?? document.getElementById('problemContents')?.value ?? ''; + webworkConfig?.pgCodeMirror?.source ?? document.getElementById('problemContents')?.value ?? ''; fetch(webserviceURL, { method: 'post', mode: 'same-origin', body: new URLSearchParams(request_object) }) .then((response) => response.json()) @@ -169,7 +222,7 @@ if (request_object.pgCode === data.result_data.pgmlCode) { showMessage('There were no changes to the code.', true); } else { - if (webworkConfig?.pgCodeMirror) webworkConfig.pgCodeMirror.setValue(data.result_data.pgmlCode); + if (webworkConfig?.pgCodeMirror) webworkConfig.pgCodeMirror.source = data.result_data.pgmlCode; else document.getElementById('problemContents').value = data.result_data.pgmlCode; saveTempFile(); showMessage('Successfully converted code to PGML', true); @@ -252,9 +305,9 @@ const renderArea = document.getElementById('pgedit-render-area'); const fileType = document.getElementsByName('file_type')[0]?.value; - // This is either the div created by the CodeMirror editor or the problemContents textarea in the case that + // This is either the div containing the CodeMirror editor or the problemContents textarea in the case that // CodeMirror is disabled in localOverrides.conf. - const editorArea = document.querySelector('.CodeMirror') ?? document.getElementById('problemContents'); + const editorArea = document.querySelector('.code-mirror-editor') ?? document.getElementById('problemContents'); // Add hot key, ctrl-enter, to render the problem editorArea.addEventListener('keydown', async (e) => { @@ -279,8 +332,7 @@ if (window.getComputedStyle(renderArea).getPropertyValue('height') !== `${height}px`) renderArea.style.height = `${height}px`; if (window.getComputedStyle(editorArea).getPropertyValue('height') !== `${height}px`) { - if (webworkConfig?.pgCodeMirror) webworkConfig.pgCodeMirror.setSize('100%', `${height}px`); - else editorArea.style.height = `${height}px`; + editorArea.style.height = `${height}px`; } } } @@ -321,7 +373,7 @@ const requestData = new URLSearchParams(new FormData(problemForm)); requestData.set( 'rawProblemSource', - webworkConfig?.pgCodeMirror?.getValue() ?? document.getElementById('problemContents')?.value ?? '' + webworkConfig?.pgCodeMirror?.source ?? document.getElementById('problemContents')?.value ?? '' ); requestData.set('send_pg_flags', 1); requestData.set(button.name, button.value); @@ -352,7 +404,7 @@ } if (fileType === 'course_info') { - const contents = webworkConfig?.pgCodeMirror?.getValue(); + const contents = webworkConfig?.pgCodeMirror?.source; if (contents) renderArea.innerHTML = `
${contents}
`; else renderArea.innerHTML = @@ -370,7 +422,7 @@ } if (fileType === 'hardcopy_theme') { - const contents = webworkConfig?.pgCodeMirror?.getValue(); + const contents = webworkConfig?.pgCodeMirror?.source; if (contents) { renderArea.innerHTML = '
' + contents.replace(/&/g, '&').replace(/';
 				} else
@@ -396,10 +448,8 @@
 					problemSeed: document.getElementById('action_view_seed_id')?.value ?? 1,
 					sourceFilePath: document.getElementsByName('edit_file_path')[0]?.value,
 					rawProblemSource:
-						webworkConfig?.pgCodeMirror?.getValue() ??
-						document.getElementById('problemContents')?.value ??
-						'',
-					outputformat: 'simple',
+						webworkConfig?.pgCodeMirror?.source ?? document.getElementById('problemContents')?.value ?? '',
+					outputformat: 'debug',
 					showAnswerNumbers: 0,
 					// The set id is really only needed by set headers to get the correct dates for the set.
 					set_id: document.getElementsByName('hidden_set_id')[0]?.value ?? 'Unknown Set',
@@ -508,9 +558,7 @@
 					problemSeed: document.getElementById('action_hardcopy_seed_id')?.value ?? 1,
 					sourceFilePath: document.getElementsByName('edit_file_path')[0]?.value,
 					rawProblemSource:
-						webworkConfig?.pgCodeMirror?.getValue() ??
-						document.getElementById('problemContents')?.value ??
-						'',
+						webworkConfig?.pgCodeMirror?.source ?? document.getElementById('problemContents')?.value ?? '',
 					outputformat: document.getElementById('action_hardcopy_format_id')?.value ?? 'pdf',
 					hardcopy_theme: document.getElementById('action_hardcopy_theme_id')?.value ?? 'oneColumn',
 					// The set id is really only needed by set headers to get the correct dates for the set.
diff --git a/htdocs/js/Problem/problem.js b/htdocs/js/Problem/problem.js
index 26baf236f1..a3b8777ac3 100644
--- a/htdocs/js/Problem/problem.js
+++ b/htdocs/js/Problem/problem.js
@@ -4,9 +4,4 @@
 		const bsToast = new bootstrap.Toast(toast, { delay: 5000 });
 		bsToast.show();
 	});
-
-	// Prevent problems which are disabled from acting as links
-	$('.problem-list .disabled-problem')
-		.addClass('disabled')
-		.on('click', (e) => e.preventDefault());
 })();
diff --git a/htdocs/js/ProblemSetList/problemsetlist.js b/htdocs/js/ProblemSetList/problemsetlist.js
index c2802ebc9e..03eacc7b09 100644
--- a/htdocs/js/ProblemSetList/problemsetlist.js
+++ b/htdocs/js/ProblemSetList/problemsetlist.js
@@ -158,6 +158,7 @@
 	const datetimeFormats = {
 		en: 'L/d/yy, h:mm a',
 		'en-US': 'L/d/yy, h:mm a',
+		'en-GB': 'dd/LL/yyyy, HH:mm',
 		'cs-CZ': 'dd.LL.yy H:mm',
 		de: 'dd.LL.yy, HH:mm',
 		el: 'd/L/yy, h:mm a',
@@ -178,17 +179,24 @@
 	if (importDateShift) {
 		luxon.Settings.defaultLocale = importDateShift.dataset.locale ?? 'en';
 
-		// Compute the time difference between the current browser timezone and the course timezone.
+		// Compute the time difference between a time in the browser timezone and the same time in the course timezone.
 		// flatpickr gives the time in the browser's timezone, and this is used to adjust to the course timezone.
-		// Note that this is in seconds.
-		const timezoneAdjustment =
-			new Date(new Date().toLocaleString('en-US')).getTime() -
-			new Date(
-				new Date().toLocaleString('en-US', { timeZone: importDateShift.dataset.timezone ?? 'America/New_York' })
-			).getTime();
+		// Note that the input time is in seconds and output times is in milliseconds.
+		const timezoneAdjustment = (time) => {
+			const dateTime = new Date(0);
+			dateTime.setUTCSeconds(time);
+			return (
+				new Date(dateTime.toLocaleString('en-US')).getTime() -
+				new Date(
+					dateTime.toLocaleString('en-US', {
+						timeZone: importDateShift.dataset.timezone ?? 'America/New_York'
+					})
+				).getTime()
+			);
+		};
 
 		let fallbackDate = importDateShift.value
-			? new Date(parseInt(importDateShift.value) * 1000 - timezoneAdjustment)
+			? new Date(parseInt(importDateShift.value) * 1000 - timezoneAdjustment(parseInt(importDateShift.value)))
 			: new Date();
 
 		const fp = flatpickr(importDateShift.parentNode, {
@@ -247,7 +255,7 @@
 			parseDate(datestr, format) {
 				// Deal with the case of a unix timestamp.  The timezone needs to be adjusted back as this is for
 				// the unix timestamp stored in the hidden input whose value will be sent to the server.
-				if (format === 'U') return new Date(parseInt(datestr) * 1000 - timezoneAdjustment);
+				if (format === 'U') return new Date(parseInt(datestr) * 1000 - timezoneAdjustment(parseInt(datestr)));
 
 				// Next attempt to parse the datestr with the current format.  This should not be adjusted.  It is
 				// for display only.
@@ -262,7 +270,7 @@
 			formatDate(date, format) {
 				// In this case the date provided is in the browser's time zone.  So it needs to be adjusted to the
 				// timezone of the course.
-				if (format === 'U') return (date.getTime() + timezoneAdjustment) / 1000;
+				if (format === 'U') return (date.getTime() + timezoneAdjustment(date.getTime() / 1000)) / 1000;
 
 				return luxon.DateTime.fromMillis(date.getTime()).toFormat(
 					datetimeFormats[luxon.Settings.defaultLocale]
diff --git a/htdocs/js/SampleProblemViewer/sample-problem.js b/htdocs/js/SampleProblemViewer/sample-problem.js
index 7907504d2f..aea4fd72f7 100644
--- a/htdocs/js/SampleProblemViewer/sample-problem.js
+++ b/htdocs/js/SampleProblemViewer/sample-problem.js
@@ -1,5 +1,5 @@
-for (const pre of document.body.querySelectorAll('pre.CodeMirror')) {
-	CodeMirror.runMode(pre.textContent, 'PG', pre);
+for (const pre of document.body.querySelectorAll('pre.PGCodeMirror')) {
+	PGCodeMirrorEditor.runMode(pre.textContent, pre);
 }
 
 for (const btn of document.querySelectorAll('.clipboard-btn')) {
diff --git a/htdocs/js/SampleProblemViewer/sample-problem.scss b/htdocs/js/SampleProblemViewer/sample-problem.scss
index 4d42527785..f94287b838 100644
--- a/htdocs/js/SampleProblemViewer/sample-problem.scss
+++ b/htdocs/js/SampleProblemViewer/sample-problem.scss
@@ -1,4 +1,4 @@
-pre.CodeMirror {
+pre.PGCodeMirror {
 	background-color: #fcfaf1;
 }
 
diff --git a/htdocs/js/System/system.js b/htdocs/js/System/system.js
index 9e4aff02bc..634f07a87c 100644
--- a/htdocs/js/System/system.js
+++ b/htdocs/js/System/system.js
@@ -1,17 +1,3 @@
-/* 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.
- */
-
 (() => {
 	// Enable site-navigation menu toggling if the page has a site-navigation element.
 	const navigation_element = document.getElementById('site-navigation');
@@ -117,18 +103,4 @@
 			messages.forEach((message) => bootstrap.Alert.getOrCreateInstance(message)?.close())
 		);
 	}
-
-	// Accessibility
-	// Present the contents of the data-alt attribute as alternative content for screen reader users.
-	// The icon should be formatted as 
-	// FIXME:  Don't add these by javascript.  Just add these in place instead.
-	document.querySelectorAll('i.icon').forEach((icon) => {
-		if (typeof icon.dataset.alt !== 'undefined') {
-			const glyph = document.createElement('span');
-			glyph.classList.add('visually-hidden');
-			glyph.style.fontSize = icon.style.fontSize;
-			glyph.textContent = icon.dataset.alt;
-			icon.after(glyph);
-		}
-	});
 })();
diff --git a/htdocs/js/System/system.scss b/htdocs/js/System/system.scss
index 6f60b1b4ab..c6a9468d58 100644
--- a/htdocs/js/System/system.scss
+++ b/htdocs/js/System/system.scss
@@ -1,17 +1,3 @@
-/* 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.
- */
-
 /* General styles */
 
 table caption {
@@ -809,7 +795,7 @@ input.changed[type='text'] {
 #pgedit-render-area {
 	border: 1px solid #ddd;
 	min-height: 400px;
-	height: 550px;
+	height: 600px;
 	resize: vertical;
 	display: flex;
 	flex-direction: column;
diff --git a/htdocs/js/UserList/userlist.js b/htdocs/js/UserList/userlist.js
index 74f16a1596..617594371f 100644
--- a/htdocs/js/UserList/userlist.js
+++ b/htdocs/js/UserList/userlist.js
@@ -113,7 +113,10 @@
 				e.preventDefault();
 				e.stopPropagation();
 				show_errors(['select_user_err_msg'], [export_select]);
-			} else if (export_select_target?.value === 'new' && export_filename.value === '') {
+			} else if (
+				export_select_target?.value === 'new' &&
+				(export_filename.value === '' || /\//.test(export_filename.value))
+			) {
 				e.preventDefault();
 				e.stopPropagation();
 				show_errors(['export_file_err_msg'], [export_filename, export_select_target]);
diff --git a/htdocs/package-lock.json b/htdocs/package-lock.json
index 3b186ab181..1680182cef 100644
--- a/htdocs/package-lock.json
+++ b/htdocs/package-lock.json
@@ -1,2777 +1,3582 @@
 {
-    "name": "webwork.javascript_package_manager",
-    "lockfileVersion": 2,
-    "requires": true,
-    "packages": {
-        "": {
-            "name": "webwork.javascript_package_manager",
-            "license": "GPL-2.0+",
-            "dependencies": {
-                "@fortawesome/fontawesome-free": "^6.5.2",
-                "bootstrap": "~5.3.3",
-                "codemirror": "^5.65.15",
-                "flatpickr": "^4.6.13",
-                "iframe-resizer": "^4.3.11",
-                "jquery": "^3.7.1",
-                "jquery-ui-dist": "^1.13.2",
-                "luxon": "^3.4.4",
-                "mathjax": "^3.2.2",
-                "shortcut-buttons-flatpickr": "^0.4.0",
-                "sortablejs": "^1.15.2"
-            },
-            "devDependencies": {
-                "autoprefixer": "^10.4.19",
-                "chokidar": "^3.6.0",
-                "cssnano": "^6.1.2",
-                "postcss": "^8.4.38",
-                "prettier": "^3.2.5",
-                "rtlcss": "^4.1.1",
-                "sass": "^1.75.0",
-                "terser": "^5.30.4",
-                "yargs": "^17.7.2"
-            }
-        },
-        "node_modules/@fortawesome/fontawesome-free": {
-            "version": "6.5.2",
-            "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
-            "integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==",
-            "hasInstallScript": true,
-            "engines": {
-                "node": ">=6"
-            }
-        },
-        "node_modules/@jridgewell/gen-mapping": {
-            "version": "0.3.5",
-            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
-            "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
-            "dev": true,
-            "dependencies": {
-                "@jridgewell/set-array": "^1.2.1",
-                "@jridgewell/sourcemap-codec": "^1.4.10",
-                "@jridgewell/trace-mapping": "^0.3.24"
-            },
-            "engines": {
-                "node": ">=6.0.0"
-            }
-        },
-        "node_modules/@jridgewell/resolve-uri": {
-            "version": "3.1.2",
-            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
-            "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
-            "dev": true,
-            "engines": {
-                "node": ">=6.0.0"
-            }
-        },
-        "node_modules/@jridgewell/set-array": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
-            "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
-            "dev": true,
-            "engines": {
-                "node": ">=6.0.0"
-            }
-        },
-        "node_modules/@jridgewell/source-map": {
-            "version": "0.3.6",
-            "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
-            "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
-            "dev": true,
-            "dependencies": {
-                "@jridgewell/gen-mapping": "^0.3.5",
-                "@jridgewell/trace-mapping": "^0.3.25"
-            }
-        },
-        "node_modules/@jridgewell/sourcemap-codec": {
-            "version": "1.4.15",
-            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
-            "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
-            "dev": true
-        },
-        "node_modules/@jridgewell/trace-mapping": {
-            "version": "0.3.25",
-            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
-            "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
-            "dev": true,
-            "dependencies": {
-                "@jridgewell/resolve-uri": "^3.1.0",
-                "@jridgewell/sourcemap-codec": "^1.4.14"
-            }
-        },
-        "node_modules/@popperjs/core": {
-            "version": "2.11.8",
-            "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
-            "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
-            "peer": true,
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/popperjs"
-            }
-        },
-        "node_modules/@trysound/sax": {
-            "version": "0.2.0",
-            "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
-            "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
-            "dev": true,
-            "engines": {
-                "node": ">=10.13.0"
-            }
-        },
-        "node_modules/acorn": {
-            "version": "8.11.3",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
-            "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
-            "dev": true,
-            "bin": {
-                "acorn": "bin/acorn"
-            },
-            "engines": {
-                "node": ">=0.4.0"
-            }
-        },
-        "node_modules/ansi-regex": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
-            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-            "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-            }
-        },
-        "node_modules/anymatch": {
-            "version": "3.1.3",
-            "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
-            "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
-            "dev": true,
-            "dependencies": {
-                "normalize-path": "^3.0.0",
-                "picomatch": "^2.0.4"
-            },
-            "engines": {
-                "node": ">= 8"
-            }
-        },
-        "node_modules/autoprefixer": {
-            "version": "10.4.19",
-            "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
-            "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "opencollective",
-                    "url": "https://opencollective.com/postcss/"
-                },
-                {
-                    "type": "tidelift",
-                    "url": "https://tidelift.com/funding/github/npm/autoprefixer"
-                },
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/ai"
-                }
-            ],
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "caniuse-lite": "^1.0.30001599",
-                "fraction.js": "^4.3.7",
-                "normalize-range": "^0.1.2",
-                "picocolors": "^1.0.0",
-                "postcss-value-parser": "^4.2.0"
-            },
-            "bin": {
-                "autoprefixer": "bin/autoprefixer"
-            },
-            "engines": {
-                "node": "^10 || ^12 || >=14"
-            },
-            "peerDependencies": {
-                "postcss": "^8.1.0"
-            }
-        },
-        "node_modules/binary-extensions": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
-            "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/boolbase": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
-            "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
-            "dev": true
-        },
-        "node_modules/bootstrap": {
-            "version": "5.3.3",
-            "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
-            "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
-            "funding": [
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/twbs"
-                },
-                {
-                    "type": "opencollective",
-                    "url": "https://opencollective.com/bootstrap"
-                }
-            ],
-            "peerDependencies": {
-                "@popperjs/core": "^2.11.8"
-            }
-        },
-        "node_modules/braces": {
-            "version": "3.0.3",
-            "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
-            "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
-            "dev": true,
-            "dependencies": {
-                "fill-range": "^7.1.1"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/browserslist": {
-            "version": "4.23.0",
-            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz",
-            "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "opencollective",
-                    "url": "https://opencollective.com/browserslist"
-                },
-                {
-                    "type": "tidelift",
-                    "url": "https://tidelift.com/funding/github/npm/browserslist"
-                },
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/ai"
-                }
-            ],
-            "dependencies": {
-                "caniuse-lite": "^1.0.30001587",
-                "electron-to-chromium": "^1.4.668",
-                "node-releases": "^2.0.14",
-                "update-browserslist-db": "^1.0.13"
-            },
-            "bin": {
-                "browserslist": "cli.js"
-            },
-            "engines": {
-                "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
-            }
-        },
-        "node_modules/buffer-from": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
-            "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
-            "dev": true
-        },
-        "node_modules/caniuse-api": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
-            "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.0.0",
-                "caniuse-lite": "^1.0.0",
-                "lodash.memoize": "^4.1.2",
-                "lodash.uniq": "^4.5.0"
-            }
-        },
-        "node_modules/caniuse-lite": {
-            "version": "1.0.30001612",
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz",
-            "integrity": "sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "opencollective",
-                    "url": "https://opencollective.com/browserslist"
-                },
-                {
-                    "type": "tidelift",
-                    "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
-                },
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/ai"
-                }
-            ]
-        },
-        "node_modules/chokidar": {
-            "version": "3.6.0",
-            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
-            "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
-            "dev": true,
-            "dependencies": {
-                "anymatch": "~3.1.2",
-                "braces": "~3.0.2",
-                "glob-parent": "~5.1.2",
-                "is-binary-path": "~2.1.0",
-                "is-glob": "~4.0.1",
-                "normalize-path": "~3.0.0",
-                "readdirp": "~3.6.0"
-            },
-            "engines": {
-                "node": ">= 8.10.0"
-            },
-            "funding": {
-                "url": "https://paulmillr.com/funding/"
-            },
-            "optionalDependencies": {
-                "fsevents": "~2.3.2"
-            }
-        },
-        "node_modules/cliui": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
-            "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
-            "dev": true,
-            "dependencies": {
-                "string-width": "^4.2.0",
-                "strip-ansi": "^6.0.1",
-                "wrap-ansi": "^7.0.0"
-            },
-            "engines": {
-                "node": ">=12"
-            }
-        },
-        "node_modules/codemirror": {
-            "version": "5.65.16",
-            "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.16.tgz",
-            "integrity": "sha512-br21LjYmSlVL0vFCPWPfhzUCT34FM/pAdK7rRIZwa0rrtrIdotvP4Oh4GUHsu2E3IrQMCfRkL/fN3ytMNxVQvg=="
-        },
-        "node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
-            "engines": {
-                "node": ">=7.0.0"
-            }
-        },
-        "node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/colord": {
-            "version": "2.9.3",
-            "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
-            "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
-            "dev": true
-        },
-        "node_modules/commander": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
-            "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
-            "dev": true,
-            "engines": {
-                "node": ">= 10"
-            }
-        },
-        "node_modules/css-declaration-sorter": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz",
-            "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==",
-            "dev": true,
-            "engines": {
-                "node": "^14 || ^16 || >=18"
-            },
-            "peerDependencies": {
-                "postcss": "^8.0.9"
-            }
-        },
-        "node_modules/css-select": {
-            "version": "5.1.0",
-            "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
-            "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
-            "dev": true,
-            "dependencies": {
-                "boolbase": "^1.0.0",
-                "css-what": "^6.1.0",
-                "domhandler": "^5.0.2",
-                "domutils": "^3.0.1",
-                "nth-check": "^2.0.1"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/fb55"
-            }
-        },
-        "node_modules/css-tree": {
-            "version": "2.3.1",
-            "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
-            "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
-            "dev": true,
-            "dependencies": {
-                "mdn-data": "2.0.30",
-                "source-map-js": "^1.0.1"
-            },
-            "engines": {
-                "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
-            }
-        },
-        "node_modules/css-what": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
-            "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
-            "dev": true,
-            "engines": {
-                "node": ">= 6"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/fb55"
-            }
-        },
-        "node_modules/cssesc": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
-            "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
-            "dev": true,
-            "bin": {
-                "cssesc": "bin/cssesc"
-            },
-            "engines": {
-                "node": ">=4"
-            }
-        },
-        "node_modules/cssnano": {
-            "version": "6.1.2",
-            "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz",
-            "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==",
-            "dev": true,
-            "dependencies": {
-                "cssnano-preset-default": "^6.1.2",
-                "lilconfig": "^3.1.1"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/cssnano"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/cssnano-preset-default": {
-            "version": "6.1.2",
-            "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz",
-            "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "css-declaration-sorter": "^7.2.0",
-                "cssnano-utils": "^4.0.2",
-                "postcss-calc": "^9.0.1",
-                "postcss-colormin": "^6.1.0",
-                "postcss-convert-values": "^6.1.0",
-                "postcss-discard-comments": "^6.0.2",
-                "postcss-discard-duplicates": "^6.0.3",
-                "postcss-discard-empty": "^6.0.3",
-                "postcss-discard-overridden": "^6.0.2",
-                "postcss-merge-longhand": "^6.0.5",
-                "postcss-merge-rules": "^6.1.1",
-                "postcss-minify-font-values": "^6.1.0",
-                "postcss-minify-gradients": "^6.0.3",
-                "postcss-minify-params": "^6.1.0",
-                "postcss-minify-selectors": "^6.0.4",
-                "postcss-normalize-charset": "^6.0.2",
-                "postcss-normalize-display-values": "^6.0.2",
-                "postcss-normalize-positions": "^6.0.2",
-                "postcss-normalize-repeat-style": "^6.0.2",
-                "postcss-normalize-string": "^6.0.2",
-                "postcss-normalize-timing-functions": "^6.0.2",
-                "postcss-normalize-unicode": "^6.1.0",
-                "postcss-normalize-url": "^6.0.2",
-                "postcss-normalize-whitespace": "^6.0.2",
-                "postcss-ordered-values": "^6.0.2",
-                "postcss-reduce-initial": "^6.1.0",
-                "postcss-reduce-transforms": "^6.0.2",
-                "postcss-svgo": "^6.0.3",
-                "postcss-unique-selectors": "^6.0.4"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/cssnano-utils": {
-            "version": "4.0.2",
-            "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz",
-            "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==",
-            "dev": true,
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/csso": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
-            "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
-            "dev": true,
-            "dependencies": {
-                "css-tree": "~2.2.0"
-            },
-            "engines": {
-                "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
-                "npm": ">=7.0.0"
-            }
-        },
-        "node_modules/csso/node_modules/css-tree": {
-            "version": "2.2.1",
-            "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
-            "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
-            "dev": true,
-            "dependencies": {
-                "mdn-data": "2.0.28",
-                "source-map-js": "^1.0.1"
-            },
-            "engines": {
-                "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
-                "npm": ">=7.0.0"
-            }
-        },
-        "node_modules/csso/node_modules/mdn-data": {
-            "version": "2.0.28",
-            "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
-            "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
-            "dev": true
-        },
-        "node_modules/dom-serializer": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
-            "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
-            "dev": true,
-            "dependencies": {
-                "domelementtype": "^2.3.0",
-                "domhandler": "^5.0.2",
-                "entities": "^4.2.0"
-            },
-            "funding": {
-                "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
-            }
-        },
-        "node_modules/domelementtype": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
-            "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/fb55"
-                }
-            ]
-        },
-        "node_modules/domhandler": {
-            "version": "5.0.3",
-            "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
-            "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
-            "dev": true,
-            "dependencies": {
-                "domelementtype": "^2.3.0"
-            },
-            "engines": {
-                "node": ">= 4"
-            },
-            "funding": {
-                "url": "https://github.com/fb55/domhandler?sponsor=1"
-            }
-        },
-        "node_modules/domutils": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
-            "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
-            "dev": true,
-            "dependencies": {
-                "dom-serializer": "^2.0.0",
-                "domelementtype": "^2.3.0",
-                "domhandler": "^5.0.3"
-            },
-            "funding": {
-                "url": "https://github.com/fb55/domutils?sponsor=1"
-            }
-        },
-        "node_modules/electron-to-chromium": {
-            "version": "1.4.747",
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.747.tgz",
-            "integrity": "sha512-+FnSWZIAvFHbsNVmUxhEqWiaOiPMcfum1GQzlWCg/wLigVtshOsjXHyEFfmt6cFK6+HkS3QOJBv6/3OPumbBfw==",
-            "dev": true
-        },
-        "node_modules/emoji-regex": {
-            "version": "8.0.0",
-            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-            "dev": true
-        },
-        "node_modules/entities": {
-            "version": "4.5.0",
-            "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
-            "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.12"
-            },
-            "funding": {
-                "url": "https://github.com/fb55/entities?sponsor=1"
-            }
-        },
-        "node_modules/escalade": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
-            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
-            "dev": true,
-            "engines": {
-                "node": ">=6"
-            }
-        },
-        "node_modules/fill-range": {
-            "version": "7.1.1",
-            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
-            "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
-            "dev": true,
-            "dependencies": {
-                "to-regex-range": "^5.0.1"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/flatpickr": {
-            "version": "4.6.13",
-            "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
-            "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw=="
-        },
-        "node_modules/fraction.js": {
-            "version": "4.3.7",
-            "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
-            "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
-            "dev": true,
-            "engines": {
-                "node": "*"
-            },
-            "funding": {
-                "type": "patreon",
-                "url": "https://github.com/sponsors/rawify"
-            }
-        },
-        "node_modules/fsevents": {
-            "version": "2.3.2",
-            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-            "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
-            "dev": true,
-            "hasInstallScript": true,
-            "optional": true,
-            "os": [
-                "darwin"
-            ],
-            "engines": {
-                "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-            }
-        },
-        "node_modules/get-caller-file": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
-            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-            "dev": true,
-            "engines": {
-                "node": "6.* || 8.* || >= 10.*"
-            }
-        },
-        "node_modules/glob-parent": {
-            "version": "5.1.2",
-            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
-            "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
-            "dev": true,
-            "dependencies": {
-                "is-glob": "^4.0.1"
-            },
-            "engines": {
-                "node": ">= 6"
-            }
-        },
-        "node_modules/iframe-resizer": {
-            "version": "4.3.11",
-            "resolved": "https://registry.npmjs.org/iframe-resizer/-/iframe-resizer-4.3.11.tgz",
-            "integrity": "sha512-5QtnsmfH11GDsuC7Gxd/eNzojudX3346Gb0E+Ku8ln8AtfSq+cWCZtnhCrthrtE7f1CI2/kwHkZ9G4sFYzHP7A==",
-            "engines": {
-                "node": ">=0.8.0"
-            },
-            "funding": {
-                "type": "individual",
-                "url": "https://github.com/davidjbradshaw/iframe-resizer/blob/master/FUNDING.md"
-            }
-        },
-        "node_modules/immutable": {
-            "version": "4.3.5",
-            "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
-            "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
-            "dev": true
-        },
-        "node_modules/is-binary-path": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
-            "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
-            "dev": true,
-            "dependencies": {
-                "binary-extensions": "^2.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/is-extglob": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/is-fullwidth-code-point": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/is-glob": {
-            "version": "4.0.3",
-            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
-            "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
-            "dev": true,
-            "dependencies": {
-                "is-extglob": "^2.1.1"
-            },
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/is-number": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
-            "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.12.0"
-            }
-        },
-        "node_modules/jquery": {
-            "version": "3.7.1",
-            "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
-            "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
-        },
-        "node_modules/jquery-ui-dist": {
-            "version": "1.13.2",
-            "resolved": "https://registry.npmjs.org/jquery-ui-dist/-/jquery-ui-dist-1.13.2.tgz",
-            "integrity": "sha512-oVDRd1NLtTbBwpRKAYdIRgpWVDzeBhfy7Gu0RmY6JEaZtmBq6kDn1pm5SgDiAotrnDS+RoTRXO6xvcNTxA9tOA==",
-            "dependencies": {
-                "jquery": ">=1.8.0 <4.0.0"
-            }
-        },
-        "node_modules/lilconfig": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz",
-            "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/antonk52"
-            }
-        },
-        "node_modules/lodash.memoize": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
-            "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
-            "dev": true
-        },
-        "node_modules/lodash.uniq": {
-            "version": "4.5.0",
-            "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
-            "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
-            "dev": true
-        },
-        "node_modules/luxon": {
-            "version": "3.4.4",
-            "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
-            "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
-            "engines": {
-                "node": ">=12"
-            }
-        },
-        "node_modules/mathjax": {
-            "version": "3.2.2",
-            "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.2.tgz",
-            "integrity": "sha512-Bt+SSVU8eBG27zChVewOicYs7Xsdt40qm4+UpHyX7k0/O9NliPc+x77k1/FEsPsjKPZGJvtRZM1vO+geW0OhGw=="
-        },
-        "node_modules/mdn-data": {
-            "version": "2.0.30",
-            "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
-            "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
-            "dev": true
-        },
-        "node_modules/nanoid": {
-            "version": "3.3.7",
-            "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
-            "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/ai"
-                }
-            ],
-            "bin": {
-                "nanoid": "bin/nanoid.cjs"
-            },
-            "engines": {
-                "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
-            }
-        },
-        "node_modules/node-releases": {
-            "version": "2.0.14",
-            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
-            "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
-            "dev": true
-        },
-        "node_modules/normalize-path": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
-            "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/normalize-range": {
-            "version": "0.1.2",
-            "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
-            "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/nth-check": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
-            "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
-            "dev": true,
-            "dependencies": {
-                "boolbase": "^1.0.0"
-            },
-            "funding": {
-                "url": "https://github.com/fb55/nth-check?sponsor=1"
-            }
-        },
-        "node_modules/picocolors": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
-            "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
-            "dev": true
-        },
-        "node_modules/picomatch": {
-            "version": "2.3.1",
-            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-            "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-            "dev": true,
-            "engines": {
-                "node": ">=8.6"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/jonschlinkert"
-            }
-        },
-        "node_modules/postcss": {
-            "version": "8.4.38",
-            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
-            "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "opencollective",
-                    "url": "https://opencollective.com/postcss/"
-                },
-                {
-                    "type": "tidelift",
-                    "url": "https://tidelift.com/funding/github/npm/postcss"
-                },
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/ai"
-                }
-            ],
-            "dependencies": {
-                "nanoid": "^3.3.7",
-                "picocolors": "^1.0.0",
-                "source-map-js": "^1.2.0"
-            },
-            "engines": {
-                "node": "^10 || ^12 || >=14"
-            }
-        },
-        "node_modules/postcss-calc": {
-            "version": "9.0.1",
-            "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz",
-            "integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==",
-            "dev": true,
-            "dependencies": {
-                "postcss-selector-parser": "^6.0.11",
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.2.2"
-            }
-        },
-        "node_modules/postcss-colormin": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz",
-            "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "caniuse-api": "^3.0.0",
-                "colord": "^2.9.3",
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-convert-values": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz",
-            "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-discard-comments": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz",
-            "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==",
-            "dev": true,
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-discard-duplicates": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz",
-            "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==",
-            "dev": true,
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-discard-empty": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz",
-            "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==",
-            "dev": true,
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-discard-overridden": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz",
-            "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==",
-            "dev": true,
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-merge-longhand": {
-            "version": "6.0.5",
-            "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz",
-            "integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0",
-                "stylehacks": "^6.1.1"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-merge-rules": {
-            "version": "6.1.1",
-            "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.1.tgz",
-            "integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "caniuse-api": "^3.0.0",
-                "cssnano-utils": "^4.0.2",
-                "postcss-selector-parser": "^6.0.16"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-minify-font-values": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz",
-            "integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-minify-gradients": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz",
-            "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==",
-            "dev": true,
-            "dependencies": {
-                "colord": "^2.9.3",
-                "cssnano-utils": "^4.0.2",
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-minify-params": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz",
-            "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "cssnano-utils": "^4.0.2",
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-minify-selectors": {
-            "version": "6.0.4",
-            "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.4.tgz",
-            "integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==",
-            "dev": true,
-            "dependencies": {
-                "postcss-selector-parser": "^6.0.16"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-charset": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz",
-            "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==",
-            "dev": true,
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-display-values": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz",
-            "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-positions": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz",
-            "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-repeat-style": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz",
-            "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-string": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz",
-            "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-timing-functions": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz",
-            "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-unicode": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz",
-            "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-url": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz",
-            "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-normalize-whitespace": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz",
-            "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-ordered-values": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz",
-            "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==",
-            "dev": true,
-            "dependencies": {
-                "cssnano-utils": "^4.0.2",
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-reduce-initial": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz",
-            "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "caniuse-api": "^3.0.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-reduce-transforms": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz",
-            "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-selector-parser": {
-            "version": "6.0.16",
-            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz",
-            "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==",
-            "dev": true,
-            "dependencies": {
-                "cssesc": "^3.0.0",
-                "util-deprecate": "^1.0.2"
-            },
-            "engines": {
-                "node": ">=4"
-            }
-        },
-        "node_modules/postcss-svgo": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz",
-            "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==",
-            "dev": true,
-            "dependencies": {
-                "postcss-value-parser": "^4.2.0",
-                "svgo": "^3.2.0"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >= 18"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-unique-selectors": {
-            "version": "6.0.4",
-            "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.4.tgz",
-            "integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==",
-            "dev": true,
-            "dependencies": {
-                "postcss-selector-parser": "^6.0.16"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/postcss-value-parser": {
-            "version": "4.2.0",
-            "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
-            "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
-            "dev": true
-        },
-        "node_modules/prettier": {
-            "version": "3.2.5",
-            "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
-            "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
-            "dev": true,
-            "bin": {
-                "prettier": "bin/prettier.cjs"
-            },
-            "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/prettier/prettier?sponsor=1"
-            }
-        },
-        "node_modules/readdirp": {
-            "version": "3.6.0",
-            "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
-            "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
-            "dev": true,
-            "dependencies": {
-                "picomatch": "^2.2.1"
-            },
-            "engines": {
-                "node": ">=8.10.0"
-            }
-        },
-        "node_modules/require-directory": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
-            "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/rtlcss": {
-            "version": "4.1.1",
-            "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.1.1.tgz",
-            "integrity": "sha512-/oVHgBtnPNcggP2aVXQjSy6N1mMAfHg4GSag0QtZBlD5bdDgAHwr4pydqJGd+SUCu9260+Pjqbjwtvu7EMH1KQ==",
-            "dev": true,
-            "dependencies": {
-                "escalade": "^3.1.1",
-                "picocolors": "^1.0.0",
-                "postcss": "^8.4.21",
-                "strip-json-comments": "^3.1.1"
-            },
-            "bin": {
-                "rtlcss": "bin/rtlcss.js"
-            },
-            "engines": {
-                "node": ">=12.0.0"
-            }
-        },
-        "node_modules/sass": {
-            "version": "1.75.0",
-            "resolved": "https://registry.npmjs.org/sass/-/sass-1.75.0.tgz",
-            "integrity": "sha512-ShMYi3WkrDWxExyxSZPst4/okE9ts46xZmJDSawJQrnte7M1V9fScVB+uNXOVKRBt0PggHOwoZcn8mYX4trnBw==",
-            "dev": true,
-            "dependencies": {
-                "chokidar": ">=3.0.0 <4.0.0",
-                "immutable": "^4.0.0",
-                "source-map-js": ">=0.6.2 <2.0.0"
-            },
-            "bin": {
-                "sass": "sass.js"
-            },
-            "engines": {
-                "node": ">=14.0.0"
-            }
-        },
-        "node_modules/shortcut-buttons-flatpickr": {
-            "version": "0.4.0",
-            "resolved": "https://registry.npmjs.org/shortcut-buttons-flatpickr/-/shortcut-buttons-flatpickr-0.4.0.tgz",
-            "integrity": "sha512-JKmT4my3Hm1e18OvG4Q6RcFhN4WRqqpTMkHrvZ7fup/dp6aTIWGVCHdRYtASkp/FCzDlJh6iCLQ/VcwwNpAMoQ=="
-        },
-        "node_modules/sortablejs": {
-            "version": "1.15.2",
-            "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
-            "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
-        },
-        "node_modules/source-map": {
-            "version": "0.6.1",
-            "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-            "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/source-map-js": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
-            "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/source-map-support": {
-            "version": "0.5.21",
-            "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
-            "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
-            "dev": true,
-            "dependencies": {
-                "buffer-from": "^1.0.0",
-                "source-map": "^0.6.0"
-            }
-        },
-        "node_modules/string-width": {
-            "version": "4.2.3",
-            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
-            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-            "dev": true,
-            "dependencies": {
-                "emoji-regex": "^8.0.0",
-                "is-fullwidth-code-point": "^3.0.0",
-                "strip-ansi": "^6.0.1"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/strip-ansi": {
-            "version": "6.0.1",
-            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
-            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-            "dev": true,
-            "dependencies": {
-                "ansi-regex": "^5.0.1"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/strip-json-comments": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
-            "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/stylehacks": {
-            "version": "6.1.1",
-            "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
-            "integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==",
-            "dev": true,
-            "dependencies": {
-                "browserslist": "^4.23.0",
-                "postcss-selector-parser": "^6.0.16"
-            },
-            "engines": {
-                "node": "^14 || ^16 || >=18.0"
-            },
-            "peerDependencies": {
-                "postcss": "^8.4.31"
-            }
-        },
-        "node_modules/svgo": {
-            "version": "3.2.0",
-            "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz",
-            "integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==",
-            "dev": true,
-            "dependencies": {
-                "@trysound/sax": "0.2.0",
-                "commander": "^7.2.0",
-                "css-select": "^5.1.0",
-                "css-tree": "^2.3.1",
-                "css-what": "^6.1.0",
-                "csso": "^5.0.5",
-                "picocolors": "^1.0.0"
-            },
-            "bin": {
-                "svgo": "bin/svgo"
-            },
-            "engines": {
-                "node": ">=14.0.0"
-            },
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/svgo"
-            }
-        },
-        "node_modules/terser": {
-            "version": "5.30.4",
-            "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz",
-            "integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==",
-            "dev": true,
-            "dependencies": {
-                "@jridgewell/source-map": "^0.3.3",
-                "acorn": "^8.8.2",
-                "commander": "^2.20.0",
-                "source-map-support": "~0.5.20"
-            },
-            "bin": {
-                "terser": "bin/terser"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/terser/node_modules/commander": {
-            "version": "2.20.3",
-            "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-            "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-            "dev": true
-        },
-        "node_modules/to-regex-range": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
-            "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
-            "dev": true,
-            "dependencies": {
-                "is-number": "^7.0.0"
-            },
-            "engines": {
-                "node": ">=8.0"
-            }
-        },
-        "node_modules/update-browserslist-db": {
-            "version": "1.0.13",
-            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
-            "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "opencollective",
-                    "url": "https://opencollective.com/browserslist"
-                },
-                {
-                    "type": "tidelift",
-                    "url": "https://tidelift.com/funding/github/npm/browserslist"
-                },
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/ai"
-                }
-            ],
-            "dependencies": {
-                "escalade": "^3.1.1",
-                "picocolors": "^1.0.0"
-            },
-            "bin": {
-                "update-browserslist-db": "cli.js"
-            },
-            "peerDependencies": {
-                "browserslist": ">= 4.21.0"
-            }
-        },
-        "node_modules/util-deprecate": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-            "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-            "dev": true
-        },
-        "node_modules/wrap-ansi": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
-            "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-            "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.0.0",
-                "string-width": "^4.1.0",
-                "strip-ansi": "^6.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
-            }
-        },
-        "node_modules/y18n": {
-            "version": "5.0.8",
-            "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
-            "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/yargs": {
-            "version": "17.7.2",
-            "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
-            "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
-            "dev": true,
-            "dependencies": {
-                "cliui": "^8.0.1",
-                "escalade": "^3.1.1",
-                "get-caller-file": "^2.0.5",
-                "require-directory": "^2.1.1",
-                "string-width": "^4.2.3",
-                "y18n": "^5.0.5",
-                "yargs-parser": "^21.1.1"
-            },
-            "engines": {
-                "node": ">=12"
-            }
-        },
-        "node_modules/yargs-parser": {
-            "version": "21.1.1",
-            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
-            "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
-            "dev": true,
-            "engines": {
-                "node": ">=12"
-            }
-        }
-    },
-    "dependencies": {
-        "@fortawesome/fontawesome-free": {
-            "version": "6.5.2",
-            "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
-            "integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q=="
-        },
-        "@jridgewell/gen-mapping": {
-            "version": "0.3.5",
-            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
-            "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
-            "dev": true,
-            "requires": {
-                "@jridgewell/set-array": "^1.2.1",
-                "@jridgewell/sourcemap-codec": "^1.4.10",
-                "@jridgewell/trace-mapping": "^0.3.24"
-            }
-        },
-        "@jridgewell/resolve-uri": {
-            "version": "3.1.2",
-            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
-            "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
-            "dev": true
-        },
-        "@jridgewell/set-array": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
-            "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
-            "dev": true
-        },
-        "@jridgewell/source-map": {
-            "version": "0.3.6",
-            "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
-            "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
-            "dev": true,
-            "requires": {
-                "@jridgewell/gen-mapping": "^0.3.5",
-                "@jridgewell/trace-mapping": "^0.3.25"
-            }
-        },
-        "@jridgewell/sourcemap-codec": {
-            "version": "1.4.15",
-            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
-            "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
-            "dev": true
-        },
-        "@jridgewell/trace-mapping": {
-            "version": "0.3.25",
-            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
-            "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
-            "dev": true,
-            "requires": {
-                "@jridgewell/resolve-uri": "^3.1.0",
-                "@jridgewell/sourcemap-codec": "^1.4.14"
-            }
-        },
-        "@popperjs/core": {
-            "version": "2.11.8",
-            "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
-            "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
-            "peer": true
-        },
-        "@trysound/sax": {
-            "version": "0.2.0",
-            "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
-            "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
-            "dev": true
-        },
-        "acorn": {
-            "version": "8.11.3",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
-            "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
-            "dev": true
-        },
-        "ansi-regex": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
-            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-            "dev": true
-        },
-        "ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-            "dev": true,
-            "requires": {
-                "color-convert": "^2.0.1"
-            }
-        },
-        "anymatch": {
-            "version": "3.1.3",
-            "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
-            "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
-            "dev": true,
-            "requires": {
-                "normalize-path": "^3.0.0",
-                "picomatch": "^2.0.4"
-            }
-        },
-        "autoprefixer": {
-            "version": "10.4.19",
-            "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
-            "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "caniuse-lite": "^1.0.30001599",
-                "fraction.js": "^4.3.7",
-                "normalize-range": "^0.1.2",
-                "picocolors": "^1.0.0",
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "binary-extensions": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
-            "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
-            "dev": true
-        },
-        "boolbase": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
-            "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
-            "dev": true
-        },
-        "bootstrap": {
-            "version": "5.3.3",
-            "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
-            "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
-            "requires": {}
-        },
-        "braces": {
-            "version": "3.0.3",
-            "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
-            "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
-            "dev": true,
-            "requires": {
-                "fill-range": "^7.1.1"
-            }
-        },
-        "browserslist": {
-            "version": "4.23.0",
-            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz",
-            "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==",
-            "dev": true,
-            "requires": {
-                "caniuse-lite": "^1.0.30001587",
-                "electron-to-chromium": "^1.4.668",
-                "node-releases": "^2.0.14",
-                "update-browserslist-db": "^1.0.13"
-            }
-        },
-        "buffer-from": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
-            "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
-            "dev": true
-        },
-        "caniuse-api": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
-            "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.0.0",
-                "caniuse-lite": "^1.0.0",
-                "lodash.memoize": "^4.1.2",
-                "lodash.uniq": "^4.5.0"
-            }
-        },
-        "caniuse-lite": {
-            "version": "1.0.30001612",
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz",
-            "integrity": "sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==",
-            "dev": true
-        },
-        "chokidar": {
-            "version": "3.6.0",
-            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
-            "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
-            "dev": true,
-            "requires": {
-                "anymatch": "~3.1.2",
-                "braces": "~3.0.2",
-                "fsevents": "~2.3.2",
-                "glob-parent": "~5.1.2",
-                "is-binary-path": "~2.1.0",
-                "is-glob": "~4.0.1",
-                "normalize-path": "~3.0.0",
-                "readdirp": "~3.6.0"
-            }
-        },
-        "cliui": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
-            "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
-            "dev": true,
-            "requires": {
-                "string-width": "^4.2.0",
-                "strip-ansi": "^6.0.1",
-                "wrap-ansi": "^7.0.0"
-            }
-        },
-        "codemirror": {
-            "version": "5.65.16",
-            "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.16.tgz",
-            "integrity": "sha512-br21LjYmSlVL0vFCPWPfhzUCT34FM/pAdK7rRIZwa0rrtrIdotvP4Oh4GUHsu2E3IrQMCfRkL/fN3ytMNxVQvg=="
-        },
-        "color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
-            "requires": {
-                "color-name": "~1.1.4"
-            }
-        },
-        "color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "colord": {
-            "version": "2.9.3",
-            "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
-            "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
-            "dev": true
-        },
-        "commander": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
-            "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
-            "dev": true
-        },
-        "css-declaration-sorter": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz",
-            "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==",
-            "dev": true,
-            "requires": {}
-        },
-        "css-select": {
-            "version": "5.1.0",
-            "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
-            "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
-            "dev": true,
-            "requires": {
-                "boolbase": "^1.0.0",
-                "css-what": "^6.1.0",
-                "domhandler": "^5.0.2",
-                "domutils": "^3.0.1",
-                "nth-check": "^2.0.1"
-            }
-        },
-        "css-tree": {
-            "version": "2.3.1",
-            "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
-            "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
-            "dev": true,
-            "requires": {
-                "mdn-data": "2.0.30",
-                "source-map-js": "^1.0.1"
-            }
-        },
-        "css-what": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
-            "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
-            "dev": true
-        },
-        "cssesc": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
-            "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
-            "dev": true
-        },
-        "cssnano": {
-            "version": "6.1.2",
-            "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz",
-            "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==",
-            "dev": true,
-            "requires": {
-                "cssnano-preset-default": "^6.1.2",
-                "lilconfig": "^3.1.1"
-            }
-        },
-        "cssnano-preset-default": {
-            "version": "6.1.2",
-            "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz",
-            "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "css-declaration-sorter": "^7.2.0",
-                "cssnano-utils": "^4.0.2",
-                "postcss-calc": "^9.0.1",
-                "postcss-colormin": "^6.1.0",
-                "postcss-convert-values": "^6.1.0",
-                "postcss-discard-comments": "^6.0.2",
-                "postcss-discard-duplicates": "^6.0.3",
-                "postcss-discard-empty": "^6.0.3",
-                "postcss-discard-overridden": "^6.0.2",
-                "postcss-merge-longhand": "^6.0.5",
-                "postcss-merge-rules": "^6.1.1",
-                "postcss-minify-font-values": "^6.1.0",
-                "postcss-minify-gradients": "^6.0.3",
-                "postcss-minify-params": "^6.1.0",
-                "postcss-minify-selectors": "^6.0.4",
-                "postcss-normalize-charset": "^6.0.2",
-                "postcss-normalize-display-values": "^6.0.2",
-                "postcss-normalize-positions": "^6.0.2",
-                "postcss-normalize-repeat-style": "^6.0.2",
-                "postcss-normalize-string": "^6.0.2",
-                "postcss-normalize-timing-functions": "^6.0.2",
-                "postcss-normalize-unicode": "^6.1.0",
-                "postcss-normalize-url": "^6.0.2",
-                "postcss-normalize-whitespace": "^6.0.2",
-                "postcss-ordered-values": "^6.0.2",
-                "postcss-reduce-initial": "^6.1.0",
-                "postcss-reduce-transforms": "^6.0.2",
-                "postcss-svgo": "^6.0.3",
-                "postcss-unique-selectors": "^6.0.4"
-            }
-        },
-        "cssnano-utils": {
-            "version": "4.0.2",
-            "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz",
-            "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==",
-            "dev": true,
-            "requires": {}
-        },
-        "csso": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
-            "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
-            "dev": true,
-            "requires": {
-                "css-tree": "~2.2.0"
-            },
-            "dependencies": {
-                "css-tree": {
-                    "version": "2.2.1",
-                    "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
-                    "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
-                    "dev": true,
-                    "requires": {
-                        "mdn-data": "2.0.28",
-                        "source-map-js": "^1.0.1"
-                    }
-                },
-                "mdn-data": {
-                    "version": "2.0.28",
-                    "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
-                    "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
-                    "dev": true
-                }
-            }
-        },
-        "dom-serializer": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
-            "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
-            "dev": true,
-            "requires": {
-                "domelementtype": "^2.3.0",
-                "domhandler": "^5.0.2",
-                "entities": "^4.2.0"
-            }
-        },
-        "domelementtype": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
-            "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
-            "dev": true
-        },
-        "domhandler": {
-            "version": "5.0.3",
-            "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
-            "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
-            "dev": true,
-            "requires": {
-                "domelementtype": "^2.3.0"
-            }
-        },
-        "domutils": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
-            "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
-            "dev": true,
-            "requires": {
-                "dom-serializer": "^2.0.0",
-                "domelementtype": "^2.3.0",
-                "domhandler": "^5.0.3"
-            }
-        },
-        "electron-to-chromium": {
-            "version": "1.4.747",
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.747.tgz",
-            "integrity": "sha512-+FnSWZIAvFHbsNVmUxhEqWiaOiPMcfum1GQzlWCg/wLigVtshOsjXHyEFfmt6cFK6+HkS3QOJBv6/3OPumbBfw==",
-            "dev": true
-        },
-        "emoji-regex": {
-            "version": "8.0.0",
-            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-            "dev": true
-        },
-        "entities": {
-            "version": "4.5.0",
-            "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
-            "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
-            "dev": true
-        },
-        "escalade": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
-            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
-            "dev": true
-        },
-        "fill-range": {
-            "version": "7.1.1",
-            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
-            "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
-            "dev": true,
-            "requires": {
-                "to-regex-range": "^5.0.1"
-            }
-        },
-        "flatpickr": {
-            "version": "4.6.13",
-            "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
-            "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw=="
-        },
-        "fraction.js": {
-            "version": "4.3.7",
-            "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
-            "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
-            "dev": true
-        },
-        "fsevents": {
-            "version": "2.3.2",
-            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-            "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
-            "dev": true,
-            "optional": true
-        },
-        "get-caller-file": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
-            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-            "dev": true
-        },
-        "glob-parent": {
-            "version": "5.1.2",
-            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
-            "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
-            "dev": true,
-            "requires": {
-                "is-glob": "^4.0.1"
-            }
-        },
-        "iframe-resizer": {
-            "version": "4.3.11",
-            "resolved": "https://registry.npmjs.org/iframe-resizer/-/iframe-resizer-4.3.11.tgz",
-            "integrity": "sha512-5QtnsmfH11GDsuC7Gxd/eNzojudX3346Gb0E+Ku8ln8AtfSq+cWCZtnhCrthrtE7f1CI2/kwHkZ9G4sFYzHP7A=="
-        },
-        "immutable": {
-            "version": "4.3.5",
-            "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
-            "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
-            "dev": true
-        },
-        "is-binary-path": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
-            "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
-            "dev": true,
-            "requires": {
-                "binary-extensions": "^2.0.0"
-            }
-        },
-        "is-extglob": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
-            "dev": true
-        },
-        "is-fullwidth-code-point": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-            "dev": true
-        },
-        "is-glob": {
-            "version": "4.0.3",
-            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
-            "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
-            "dev": true,
-            "requires": {
-                "is-extglob": "^2.1.1"
-            }
-        },
-        "is-number": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
-            "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
-            "dev": true
-        },
-        "jquery": {
-            "version": "3.7.1",
-            "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
-            "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
-        },
-        "jquery-ui-dist": {
-            "version": "1.13.2",
-            "resolved": "https://registry.npmjs.org/jquery-ui-dist/-/jquery-ui-dist-1.13.2.tgz",
-            "integrity": "sha512-oVDRd1NLtTbBwpRKAYdIRgpWVDzeBhfy7Gu0RmY6JEaZtmBq6kDn1pm5SgDiAotrnDS+RoTRXO6xvcNTxA9tOA==",
-            "requires": {
-                "jquery": ">=1.8.0 <4.0.0"
-            }
-        },
-        "lilconfig": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz",
-            "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==",
-            "dev": true
-        },
-        "lodash.memoize": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
-            "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
-            "dev": true
-        },
-        "lodash.uniq": {
-            "version": "4.5.0",
-            "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
-            "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
-            "dev": true
-        },
-        "luxon": {
-            "version": "3.4.4",
-            "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
-            "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA=="
-        },
-        "mathjax": {
-            "version": "3.2.2",
-            "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.2.tgz",
-            "integrity": "sha512-Bt+SSVU8eBG27zChVewOicYs7Xsdt40qm4+UpHyX7k0/O9NliPc+x77k1/FEsPsjKPZGJvtRZM1vO+geW0OhGw=="
-        },
-        "mdn-data": {
-            "version": "2.0.30",
-            "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
-            "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
-            "dev": true
-        },
-        "nanoid": {
-            "version": "3.3.7",
-            "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
-            "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
-            "dev": true
-        },
-        "node-releases": {
-            "version": "2.0.14",
-            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
-            "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
-            "dev": true
-        },
-        "normalize-path": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
-            "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
-            "dev": true
-        },
-        "normalize-range": {
-            "version": "0.1.2",
-            "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
-            "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
-            "dev": true
-        },
-        "nth-check": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
-            "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
-            "dev": true,
-            "requires": {
-                "boolbase": "^1.0.0"
-            }
-        },
-        "picocolors": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
-            "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
-            "dev": true
-        },
-        "picomatch": {
-            "version": "2.3.1",
-            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-            "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-            "dev": true
-        },
-        "postcss": {
-            "version": "8.4.38",
-            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
-            "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
-            "dev": true,
-            "requires": {
-                "nanoid": "^3.3.7",
-                "picocolors": "^1.0.0",
-                "source-map-js": "^1.2.0"
-            }
-        },
-        "postcss-calc": {
-            "version": "9.0.1",
-            "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz",
-            "integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==",
-            "dev": true,
-            "requires": {
-                "postcss-selector-parser": "^6.0.11",
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-colormin": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz",
-            "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "caniuse-api": "^3.0.0",
-                "colord": "^2.9.3",
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-convert-values": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz",
-            "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-discard-comments": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz",
-            "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==",
-            "dev": true,
-            "requires": {}
-        },
-        "postcss-discard-duplicates": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz",
-            "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==",
-            "dev": true,
-            "requires": {}
-        },
-        "postcss-discard-empty": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz",
-            "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==",
-            "dev": true,
-            "requires": {}
-        },
-        "postcss-discard-overridden": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz",
-            "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==",
-            "dev": true,
-            "requires": {}
-        },
-        "postcss-merge-longhand": {
-            "version": "6.0.5",
-            "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz",
-            "integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0",
-                "stylehacks": "^6.1.1"
-            }
-        },
-        "postcss-merge-rules": {
-            "version": "6.1.1",
-            "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.1.tgz",
-            "integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "caniuse-api": "^3.0.0",
-                "cssnano-utils": "^4.0.2",
-                "postcss-selector-parser": "^6.0.16"
-            }
-        },
-        "postcss-minify-font-values": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz",
-            "integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-minify-gradients": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz",
-            "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==",
-            "dev": true,
-            "requires": {
-                "colord": "^2.9.3",
-                "cssnano-utils": "^4.0.2",
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-minify-params": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz",
-            "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "cssnano-utils": "^4.0.2",
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-minify-selectors": {
-            "version": "6.0.4",
-            "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.4.tgz",
-            "integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==",
-            "dev": true,
-            "requires": {
-                "postcss-selector-parser": "^6.0.16"
-            }
-        },
-        "postcss-normalize-charset": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz",
-            "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==",
-            "dev": true,
-            "requires": {}
-        },
-        "postcss-normalize-display-values": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz",
-            "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-normalize-positions": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz",
-            "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-normalize-repeat-style": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz",
-            "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-normalize-string": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz",
-            "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-normalize-timing-functions": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz",
-            "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-normalize-unicode": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz",
-            "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-normalize-url": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz",
-            "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-normalize-whitespace": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz",
-            "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-ordered-values": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz",
-            "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==",
-            "dev": true,
-            "requires": {
-                "cssnano-utils": "^4.0.2",
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-reduce-initial": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz",
-            "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "caniuse-api": "^3.0.0"
-            }
-        },
-        "postcss-reduce-transforms": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz",
-            "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0"
-            }
-        },
-        "postcss-selector-parser": {
-            "version": "6.0.16",
-            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz",
-            "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==",
-            "dev": true,
-            "requires": {
-                "cssesc": "^3.0.0",
-                "util-deprecate": "^1.0.2"
-            }
-        },
-        "postcss-svgo": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz",
-            "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==",
-            "dev": true,
-            "requires": {
-                "postcss-value-parser": "^4.2.0",
-                "svgo": "^3.2.0"
-            }
-        },
-        "postcss-unique-selectors": {
-            "version": "6.0.4",
-            "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.4.tgz",
-            "integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==",
-            "dev": true,
-            "requires": {
-                "postcss-selector-parser": "^6.0.16"
-            }
-        },
-        "postcss-value-parser": {
-            "version": "4.2.0",
-            "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
-            "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
-            "dev": true
-        },
-        "prettier": {
-            "version": "3.2.5",
-            "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
-            "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
-            "dev": true
-        },
-        "readdirp": {
-            "version": "3.6.0",
-            "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
-            "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
-            "dev": true,
-            "requires": {
-                "picomatch": "^2.2.1"
-            }
-        },
-        "require-directory": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
-            "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
-            "dev": true
-        },
-        "rtlcss": {
-            "version": "4.1.1",
-            "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.1.1.tgz",
-            "integrity": "sha512-/oVHgBtnPNcggP2aVXQjSy6N1mMAfHg4GSag0QtZBlD5bdDgAHwr4pydqJGd+SUCu9260+Pjqbjwtvu7EMH1KQ==",
-            "dev": true,
-            "requires": {
-                "escalade": "^3.1.1",
-                "picocolors": "^1.0.0",
-                "postcss": "^8.4.21",
-                "strip-json-comments": "^3.1.1"
-            }
-        },
-        "sass": {
-            "version": "1.75.0",
-            "resolved": "https://registry.npmjs.org/sass/-/sass-1.75.0.tgz",
-            "integrity": "sha512-ShMYi3WkrDWxExyxSZPst4/okE9ts46xZmJDSawJQrnte7M1V9fScVB+uNXOVKRBt0PggHOwoZcn8mYX4trnBw==",
-            "dev": true,
-            "requires": {
-                "chokidar": ">=3.0.0 <4.0.0",
-                "immutable": "^4.0.0",
-                "source-map-js": ">=0.6.2 <2.0.0"
-            }
-        },
-        "shortcut-buttons-flatpickr": {
-            "version": "0.4.0",
-            "resolved": "https://registry.npmjs.org/shortcut-buttons-flatpickr/-/shortcut-buttons-flatpickr-0.4.0.tgz",
-            "integrity": "sha512-JKmT4my3Hm1e18OvG4Q6RcFhN4WRqqpTMkHrvZ7fup/dp6aTIWGVCHdRYtASkp/FCzDlJh6iCLQ/VcwwNpAMoQ=="
-        },
-        "sortablejs": {
-            "version": "1.15.2",
-            "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
-            "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
-        },
-        "source-map": {
-            "version": "0.6.1",
-            "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-            "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-            "dev": true
-        },
-        "source-map-js": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
-            "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
-            "dev": true
-        },
-        "source-map-support": {
-            "version": "0.5.21",
-            "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
-            "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
-            "dev": true,
-            "requires": {
-                "buffer-from": "^1.0.0",
-                "source-map": "^0.6.0"
-            }
-        },
-        "string-width": {
-            "version": "4.2.3",
-            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
-            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-            "dev": true,
-            "requires": {
-                "emoji-regex": "^8.0.0",
-                "is-fullwidth-code-point": "^3.0.0",
-                "strip-ansi": "^6.0.1"
-            }
-        },
-        "strip-ansi": {
-            "version": "6.0.1",
-            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
-            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-            "dev": true,
-            "requires": {
-                "ansi-regex": "^5.0.1"
-            }
-        },
-        "strip-json-comments": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
-            "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
-            "dev": true
-        },
-        "stylehacks": {
-            "version": "6.1.1",
-            "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
-            "integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==",
-            "dev": true,
-            "requires": {
-                "browserslist": "^4.23.0",
-                "postcss-selector-parser": "^6.0.16"
-            }
-        },
-        "svgo": {
-            "version": "3.2.0",
-            "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz",
-            "integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==",
-            "dev": true,
-            "requires": {
-                "@trysound/sax": "0.2.0",
-                "commander": "^7.2.0",
-                "css-select": "^5.1.0",
-                "css-tree": "^2.3.1",
-                "css-what": "^6.1.0",
-                "csso": "^5.0.5",
-                "picocolors": "^1.0.0"
-            }
-        },
-        "terser": {
-            "version": "5.30.4",
-            "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz",
-            "integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==",
-            "dev": true,
-            "requires": {
-                "@jridgewell/source-map": "^0.3.3",
-                "acorn": "^8.8.2",
-                "commander": "^2.20.0",
-                "source-map-support": "~0.5.20"
-            },
-            "dependencies": {
-                "commander": {
-                    "version": "2.20.3",
-                    "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-                    "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-                    "dev": true
-                }
-            }
-        },
-        "to-regex-range": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
-            "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
-            "dev": true,
-            "requires": {
-                "is-number": "^7.0.0"
-            }
-        },
-        "update-browserslist-db": {
-            "version": "1.0.13",
-            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
-            "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
-            "dev": true,
-            "requires": {
-                "escalade": "^3.1.1",
-                "picocolors": "^1.0.0"
-            }
-        },
-        "util-deprecate": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-            "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-            "dev": true
-        },
-        "wrap-ansi": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
-            "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-            "dev": true,
-            "requires": {
-                "ansi-styles": "^4.0.0",
-                "string-width": "^4.1.0",
-                "strip-ansi": "^6.0.0"
-            }
-        },
-        "y18n": {
-            "version": "5.0.8",
-            "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
-            "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
-            "dev": true
-        },
-        "yargs": {
-            "version": "17.7.2",
-            "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
-            "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
-            "dev": true,
-            "requires": {
-                "cliui": "^8.0.1",
-                "escalade": "^3.1.1",
-                "get-caller-file": "^2.0.5",
-                "require-directory": "^2.1.1",
-                "string-width": "^4.2.3",
-                "y18n": "^5.0.5",
-                "yargs-parser": "^21.1.1"
-            }
-        },
-        "yargs-parser": {
-            "version": "21.1.1",
-            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
-            "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
-            "dev": true
-        }
-    }
+	"name": "webwork.javascript_package_manager",
+	"lockfileVersion": 2,
+	"requires": true,
+	"packages": {
+		"": {
+			"name": "webwork.javascript_package_manager",
+			"license": "GPL-2.0+",
+			"dependencies": {
+				"@fortawesome/fontawesome-free": "^6.5.2",
+				"@openwebwork/pg-codemirror-editor": "^0.0.4",
+				"bootstrap": "~5.3.3",
+				"flatpickr": "^4.6.13",
+				"iframe-resizer": "^4.3.11",
+				"jquery": "^3.7.1",
+				"jquery-ui-dist": "^1.13.2",
+				"luxon": "^3.4.4",
+				"mathjax": "^3.2.2",
+				"shortcut-buttons-flatpickr": "^0.4.0",
+				"sortablejs": "^1.15.2"
+			},
+			"devDependencies": {
+				"autoprefixer": "^10.4.19",
+				"chokidar": "^3.6.0",
+				"cssnano": "^6.1.2",
+				"postcss": "^8.4.38",
+				"prettier": "^3.2.5",
+				"rtlcss": "^4.1.1",
+				"sass": "^1.75.0",
+				"terser": "^5.30.4",
+				"yargs": "^17.7.2"
+			}
+		},
+		"node_modules/@codemirror/autocomplete": {
+			"version": "6.18.6",
+			"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
+			"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/commands": {
+			"version": "6.8.1",
+			"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
+			"integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.4.0",
+				"@codemirror/view": "^6.27.0",
+				"@lezer/common": "^1.1.0"
+			}
+		},
+		"node_modules/@codemirror/lang-css": {
+			"version": "6.3.1",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+			"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@lezer/common": "^1.0.2",
+				"@lezer/css": "^1.1.7"
+			}
+		},
+		"node_modules/@codemirror/lang-html": {
+			"version": "6.4.9",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
+			"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/lang-css": "^6.0.0",
+				"@codemirror/lang-javascript": "^6.0.0",
+				"@codemirror/language": "^6.4.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0",
+				"@lezer/css": "^1.1.0",
+				"@lezer/html": "^1.3.0"
+			}
+		},
+		"node_modules/@codemirror/lang-javascript": {
+			"version": "6.2.3",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.3.tgz",
+			"integrity": "sha512-8PR3vIWg7pSu7ur8A07pGiYHgy3hHj+mRYRCSG8q+mPIrl0F02rgpGv+DsQTHRTc30rydOsf5PZ7yjKFg2Ackw==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/language": "^6.6.0",
+				"@codemirror/lint": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0",
+				"@lezer/javascript": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/lang-xml": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
+			"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/language": "^6.4.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/common": "^1.0.0",
+				"@lezer/xml": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/language": {
+			"version": "6.11.0",
+			"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.0.tgz",
+			"integrity": "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.23.0",
+				"@lezer/common": "^1.1.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0",
+				"style-mod": "^4.0.0"
+			}
+		},
+		"node_modules/@codemirror/lint": {
+			"version": "6.8.5",
+			"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
+			"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.35.0",
+				"crelt": "^1.0.5"
+			}
+		},
+		"node_modules/@codemirror/search": {
+			"version": "6.5.10",
+			"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.10.tgz",
+			"integrity": "sha512-RMdPdmsrUf53pb2VwflKGHEe1XVM07hI7vV2ntgw1dmqhimpatSJKva4VA9h4TLUDOD4EIF02201oZurpnEFsg==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"crelt": "^1.0.5"
+			}
+		},
+		"node_modules/@codemirror/state": {
+			"version": "6.5.2",
+			"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
+			"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
+			"license": "MIT",
+			"dependencies": {
+				"@marijn/find-cluster-break": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/theme-one-dark": {
+			"version": "6.1.2",
+			"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz",
+			"integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/@codemirror/view": {
+			"version": "6.36.7",
+			"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.7.tgz",
+			"integrity": "sha512-kCWGW/chWGPgZqfZ36Um9Iz0X2IVpmCjg1P/qY6B6a2ecXtWRRAigmpJ6YgUQ5lTWXMyyVdfmpzhLZmsZQMbtg==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/state": "^6.5.0",
+				"style-mod": "^4.1.0",
+				"w3c-keyname": "^2.2.4"
+			}
+		},
+		"node_modules/@fortawesome/fontawesome-free": {
+			"version": "6.5.2",
+			"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
+			"integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==",
+			"hasInstallScript": true,
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/@jridgewell/gen-mapping": {
+			"version": "0.3.5",
+			"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
+			"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+			"dev": true,
+			"dependencies": {
+				"@jridgewell/set-array": "^1.2.1",
+				"@jridgewell/sourcemap-codec": "^1.4.10",
+				"@jridgewell/trace-mapping": "^0.3.24"
+			},
+			"engines": {
+				"node": ">=6.0.0"
+			}
+		},
+		"node_modules/@jridgewell/resolve-uri": {
+			"version": "3.1.2",
+			"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+			"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+			"dev": true,
+			"engines": {
+				"node": ">=6.0.0"
+			}
+		},
+		"node_modules/@jridgewell/set-array": {
+			"version": "1.2.1",
+			"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+			"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+			"dev": true,
+			"engines": {
+				"node": ">=6.0.0"
+			}
+		},
+		"node_modules/@jridgewell/source-map": {
+			"version": "0.3.6",
+			"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
+			"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
+			"dev": true,
+			"dependencies": {
+				"@jridgewell/gen-mapping": "^0.3.5",
+				"@jridgewell/trace-mapping": "^0.3.25"
+			}
+		},
+		"node_modules/@jridgewell/sourcemap-codec": {
+			"version": "1.4.15",
+			"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+			"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
+			"dev": true
+		},
+		"node_modules/@jridgewell/trace-mapping": {
+			"version": "0.3.25",
+			"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+			"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+			"dev": true,
+			"dependencies": {
+				"@jridgewell/resolve-uri": "^3.1.0",
+				"@jridgewell/sourcemap-codec": "^1.4.14"
+			}
+		},
+		"node_modules/@lezer/common": {
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
+			"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
+			"license": "MIT"
+		},
+		"node_modules/@lezer/css": {
+			"version": "1.1.11",
+			"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.11.tgz",
+			"integrity": "sha512-FuAnusbLBl1SEAtfN8NdShxYJiESKw9LAFysfea1T96jD3ydBn12oYjaSG1a04BQRIUd93/0D8e5CV1cUMkmQg==",
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0"
+			}
+		},
+		"node_modules/@lezer/highlight": {
+			"version": "1.2.1",
+			"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
+			"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.0.0"
+			}
+		},
+		"node_modules/@lezer/html": {
+			"version": "1.3.10",
+			"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
+			"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0"
+			}
+		},
+		"node_modules/@lezer/javascript": {
+			"version": "1.5.1",
+			"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
+			"integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==",
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.1.3",
+				"@lezer/lr": "^1.3.0"
+			}
+		},
+		"node_modules/@lezer/lr": {
+			"version": "1.4.2",
+			"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
+			"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.0.0"
+			}
+		},
+		"node_modules/@lezer/xml": {
+			"version": "1.0.6",
+			"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
+			"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
+			"license": "MIT",
+			"dependencies": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0"
+			}
+		},
+		"node_modules/@marijn/find-cluster-break": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+			"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+			"license": "MIT"
+		},
+		"node_modules/@openwebwork/codemirror-lang-pg": {
+			"version": "0.0.2",
+			"resolved": "https://registry.npmjs.org/@openwebwork/codemirror-lang-pg/-/codemirror-lang-pg-0.0.2.tgz",
+			"integrity": "sha512-xnipB2bydjDbcRVO9XpFNkhI9muoA6FzPqx6gRxQk10cXj8nzOn694gl7ATpn/xn5J5Y+7YpFAFTDqs0atgI6g==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/language": "^6.11.0",
+				"@lezer/highlight": "^1.2.1",
+				"@lezer/lr": "^1.4.2"
+			}
+		},
+		"node_modules/@openwebwork/pg-codemirror-editor": {
+			"version": "0.0.4",
+			"resolved": "https://registry.npmjs.org/@openwebwork/pg-codemirror-editor/-/pg-codemirror-editor-0.0.4.tgz",
+			"integrity": "sha512-aJ3/AmKc1Ck/6zKctETP29QnuKn4PHIWhEZNdCzi49zAI+Ls2wH0JQdPadoj3z4savAxhabsdYOu8WWV7vwx5g==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/lang-html": "^6.4.9",
+				"@codemirror/lang-xml": "^6.1.0",
+				"@codemirror/theme-one-dark": "^6.1.2",
+				"@openwebwork/codemirror-lang-pg": "^0.0.2",
+				"@replit/codemirror-emacs": "^6.1.0",
+				"@replit/codemirror-vim": "^6.3.0",
+				"cm6-theme-basic-dark": "^0.2.0",
+				"cm6-theme-basic-light": "^0.2.0",
+				"cm6-theme-gruvbox-dark": "^0.2.0",
+				"cm6-theme-gruvbox-light": "^0.2.0",
+				"cm6-theme-material-dark": "^0.2.0",
+				"cm6-theme-nord": "^0.2.0",
+				"cm6-theme-solarized-dark": "^0.2.0",
+				"cm6-theme-solarized-light": "^0.2.0",
+				"codemirror": "^6.0.1",
+				"codemirror-lang-mt": "^0.0.2",
+				"codemirror-lang-perl": "^0.1.5",
+				"style-mod": "^4.1.2",
+				"thememirror": "^2.0.1"
+			}
+		},
+		"node_modules/@popperjs/core": {
+			"version": "2.11.8",
+			"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+			"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+			"peer": true,
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/popperjs"
+			}
+		},
+		"node_modules/@replit/codemirror-emacs": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz",
+			"integrity": "sha512-74DITnht6Cs6sHg02PQ169IKb1XgtyhI9sLD0JeOFco6Ds18PT+dkD8+DgXBDokne9UIFKsBbKPnpFRAz60/Lw==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/autocomplete": "^6.0.2",
+				"@codemirror/commands": "^6.0.0",
+				"@codemirror/search": "^6.0.0",
+				"@codemirror/state": "^6.0.1",
+				"@codemirror/view": "^6.3.0"
+			}
+		},
+		"node_modules/@replit/codemirror-vim": {
+			"version": "6.3.0",
+			"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
+			"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/commands": "6.x.x",
+				"@codemirror/language": "6.x.x",
+				"@codemirror/search": "6.x.x",
+				"@codemirror/state": "6.x.x",
+				"@codemirror/view": "6.x.x"
+			}
+		},
+		"node_modules/@trysound/sax": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
+			"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
+			"dev": true,
+			"engines": {
+				"node": ">=10.13.0"
+			}
+		},
+		"node_modules/acorn": {
+			"version": "8.11.3",
+			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
+			"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+			"dev": true,
+			"bin": {
+				"acorn": "bin/acorn"
+			},
+			"engines": {
+				"node": ">=0.4.0"
+			}
+		},
+		"node_modules/ansi-regex": {
+			"version": "5.0.1",
+			"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+			"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+			"dev": true,
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/ansi-styles": {
+			"version": "4.3.0",
+			"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+			"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+			"dev": true,
+			"dependencies": {
+				"color-convert": "^2.0.1"
+			},
+			"engines": {
+				"node": ">=8"
+			},
+			"funding": {
+				"url": "https://github.com/chalk/ansi-styles?sponsor=1"
+			}
+		},
+		"node_modules/anymatch": {
+			"version": "3.1.3",
+			"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+			"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+			"dev": true,
+			"dependencies": {
+				"normalize-path": "^3.0.0",
+				"picomatch": "^2.0.4"
+			},
+			"engines": {
+				"node": ">= 8"
+			}
+		},
+		"node_modules/autoprefixer": {
+			"version": "10.4.19",
+			"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
+			"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "opencollective",
+					"url": "https://opencollective.com/postcss/"
+				},
+				{
+					"type": "tidelift",
+					"url": "https://tidelift.com/funding/github/npm/autoprefixer"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/ai"
+				}
+			],
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"caniuse-lite": "^1.0.30001599",
+				"fraction.js": "^4.3.7",
+				"normalize-range": "^0.1.2",
+				"picocolors": "^1.0.0",
+				"postcss-value-parser": "^4.2.0"
+			},
+			"bin": {
+				"autoprefixer": "bin/autoprefixer"
+			},
+			"engines": {
+				"node": "^10 || ^12 || >=14"
+			},
+			"peerDependencies": {
+				"postcss": "^8.1.0"
+			}
+		},
+		"node_modules/binary-extensions": {
+			"version": "2.2.0",
+			"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+			"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+			"dev": true,
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/boolbase": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+			"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+			"dev": true
+		},
+		"node_modules/bootstrap": {
+			"version": "5.3.3",
+			"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
+			"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/twbs"
+				},
+				{
+					"type": "opencollective",
+					"url": "https://opencollective.com/bootstrap"
+				}
+			],
+			"peerDependencies": {
+				"@popperjs/core": "^2.11.8"
+			}
+		},
+		"node_modules/braces": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+			"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+			"dev": true,
+			"dependencies": {
+				"fill-range": "^7.1.1"
+			},
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/browserslist": {
+			"version": "4.23.0",
+			"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz",
+			"integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "opencollective",
+					"url": "https://opencollective.com/browserslist"
+				},
+				{
+					"type": "tidelift",
+					"url": "https://tidelift.com/funding/github/npm/browserslist"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/ai"
+				}
+			],
+			"dependencies": {
+				"caniuse-lite": "^1.0.30001587",
+				"electron-to-chromium": "^1.4.668",
+				"node-releases": "^2.0.14",
+				"update-browserslist-db": "^1.0.13"
+			},
+			"bin": {
+				"browserslist": "cli.js"
+			},
+			"engines": {
+				"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+			}
+		},
+		"node_modules/buffer-from": {
+			"version": "1.1.2",
+			"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+			"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+			"dev": true
+		},
+		"node_modules/caniuse-api": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
+			"integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.0.0",
+				"caniuse-lite": "^1.0.0",
+				"lodash.memoize": "^4.1.2",
+				"lodash.uniq": "^4.5.0"
+			}
+		},
+		"node_modules/caniuse-lite": {
+			"version": "1.0.30001723",
+			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz",
+			"integrity": "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "opencollective",
+					"url": "https://opencollective.com/browserslist"
+				},
+				{
+					"type": "tidelift",
+					"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/ai"
+				}
+			],
+			"license": "CC-BY-4.0"
+		},
+		"node_modules/chokidar": {
+			"version": "3.6.0",
+			"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+			"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+			"dev": true,
+			"dependencies": {
+				"anymatch": "~3.1.2",
+				"braces": "~3.0.2",
+				"glob-parent": "~5.1.2",
+				"is-binary-path": "~2.1.0",
+				"is-glob": "~4.0.1",
+				"normalize-path": "~3.0.0",
+				"readdirp": "~3.6.0"
+			},
+			"engines": {
+				"node": ">= 8.10.0"
+			},
+			"funding": {
+				"url": "https://paulmillr.com/funding/"
+			},
+			"optionalDependencies": {
+				"fsevents": "~2.3.2"
+			}
+		},
+		"node_modules/cliui": {
+			"version": "8.0.1",
+			"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+			"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+			"dev": true,
+			"dependencies": {
+				"string-width": "^4.2.0",
+				"strip-ansi": "^6.0.1",
+				"wrap-ansi": "^7.0.0"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/cm6-theme-basic-dark": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-basic-dark/-/cm6-theme-basic-dark-0.2.0.tgz",
+			"integrity": "sha512-+mNNJecRtxS/KkloMDCQF0oTrT6aFGRZTjnBcdT5UG1pcDO4Brq8l1+0KR/8dZ7hub2gOGOzoi3rGFD8GzlH7Q==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/cm6-theme-basic-light": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz",
+			"integrity": "sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/cm6-theme-gruvbox-dark": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-gruvbox-dark/-/cm6-theme-gruvbox-dark-0.2.0.tgz",
+			"integrity": "sha512-xyqsG19qV+nb7ZHTMocSNWwZHMExfQxDm0FlbNMqEGKeQR96WryssXJH/IZZQudwrPpWU2dCoyOgMFhti2UTYA==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/cm6-theme-gruvbox-light": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-gruvbox-light/-/cm6-theme-gruvbox-light-0.2.0.tgz",
+			"integrity": "sha512-sc4dEMLU5y4F3QGLjwMQs1H3Q0a0ooXA1EvyWnknxLEGQVXwJrxkkV67gs1TqWASl2i63iomt4zyz5pkbfO1yg==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/cm6-theme-material-dark": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-material-dark/-/cm6-theme-material-dark-0.2.0.tgz",
+			"integrity": "sha512-H09JZihzg4w0mTtOqo5bQdxItkQWw+ergKlk7BSfwYjaR2nOi+wIN0R+ByAo7bON8GbFODvjTxH3EIqdhovFeA==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/cm6-theme-nord": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-nord/-/cm6-theme-nord-0.2.0.tgz",
+			"integrity": "sha512-jTh+5nvl+N/5CtTK7UVcrxDCj2AOStvbNM8uP6tx6amq4QaaLDlapjMw+MNzEkvxcPnHY+YM91tbklS2KNlR2w==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/cm6-theme-solarized-dark": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-solarized-dark/-/cm6-theme-solarized-dark-0.2.0.tgz",
+			"integrity": "sha512-FWtYHcX8NLzNSs21yGbkLF+q/5m2u80ug0JytKoI9nMZWPP5dcnsFYp1iZBEegLehiZnpv1qcmTsLTUG2KD39w==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/cm6-theme-solarized-light": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-solarized-light/-/cm6-theme-solarized-light-0.2.0.tgz",
+			"integrity": "sha512-Iw7Xv+9A6NlT7sRGlM2pOwD3ZBETkAqpb7c6O0LPj5kjwcK6C3k+mvjzaQt1gzfBErMmhL1HHuK07zICeXkE+w==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"node_modules/codemirror": {
+			"version": "6.0.1",
+			"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
+			"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/commands": "^6.0.0",
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/lint": "^6.0.0",
+				"@codemirror/search": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0"
+			}
+		},
+		"node_modules/codemirror-lang-mt": {
+			"version": "0.0.2",
+			"resolved": "https://registry.npmjs.org/codemirror-lang-mt/-/codemirror-lang-mt-0.0.2.tgz",
+			"integrity": "sha512-m2q+EVgNeDxq3A8gCnoUGZvTuXvvZKlZliiqif4VAMPiu7dKJsaopvXZo8S1KH6cb2x9fJuKr5yUTnkxSLQZIQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/lang-css": "^6.3.1",
+				"@codemirror/lang-html": "^6.4.9",
+				"@codemirror/lang-javascript": "^6.2.3",
+				"@codemirror/language": "^6.11.0",
+				"@lezer/highlight": "^1.2.1",
+				"@lezer/lr": "^1.4.2"
+			}
+		},
+		"node_modules/codemirror-lang-perl": {
+			"version": "0.1.5",
+			"resolved": "https://registry.npmjs.org/codemirror-lang-perl/-/codemirror-lang-perl-0.1.5.tgz",
+			"integrity": "sha512-o0QBzsO4z+ZWaN7ueYnFVYWoFlFvvfgcgNA/dQLxYUDiKGSUY0R52UL/NqTO6swUVrR+O6JI3Xh1j/ed81JIwA==",
+			"license": "MIT",
+			"dependencies": {
+				"@codemirror/language": "^6.11.0",
+				"@lezer/highlight": "^1.2.1",
+				"@lezer/lr": "^1.4.2"
+			}
+		},
+		"node_modules/color-convert": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+			"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+			"dev": true,
+			"dependencies": {
+				"color-name": "~1.1.4"
+			},
+			"engines": {
+				"node": ">=7.0.0"
+			}
+		},
+		"node_modules/color-name": {
+			"version": "1.1.4",
+			"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+			"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+			"dev": true
+		},
+		"node_modules/colord": {
+			"version": "2.9.3",
+			"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
+			"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
+			"dev": true
+		},
+		"node_modules/commander": {
+			"version": "7.2.0",
+			"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+			"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+			"dev": true,
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/crelt": {
+			"version": "1.0.6",
+			"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+			"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+			"license": "MIT"
+		},
+		"node_modules/css-declaration-sorter": {
+			"version": "7.2.0",
+			"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz",
+			"integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==",
+			"dev": true,
+			"engines": {
+				"node": "^14 || ^16 || >=18"
+			},
+			"peerDependencies": {
+				"postcss": "^8.0.9"
+			}
+		},
+		"node_modules/css-select": {
+			"version": "5.1.0",
+			"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
+			"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
+			"dev": true,
+			"dependencies": {
+				"boolbase": "^1.0.0",
+				"css-what": "^6.1.0",
+				"domhandler": "^5.0.2",
+				"domutils": "^3.0.1",
+				"nth-check": "^2.0.1"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/fb55"
+			}
+		},
+		"node_modules/css-tree": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
+			"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
+			"dev": true,
+			"dependencies": {
+				"mdn-data": "2.0.30",
+				"source-map-js": "^1.0.1"
+			},
+			"engines": {
+				"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+			}
+		},
+		"node_modules/css-what": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
+			"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
+			"dev": true,
+			"engines": {
+				"node": ">= 6"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/fb55"
+			}
+		},
+		"node_modules/cssesc": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+			"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+			"dev": true,
+			"bin": {
+				"cssesc": "bin/cssesc"
+			},
+			"engines": {
+				"node": ">=4"
+			}
+		},
+		"node_modules/cssnano": {
+			"version": "6.1.2",
+			"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz",
+			"integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==",
+			"dev": true,
+			"dependencies": {
+				"cssnano-preset-default": "^6.1.2",
+				"lilconfig": "^3.1.1"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/cssnano"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/cssnano-preset-default": {
+			"version": "6.1.2",
+			"resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz",
+			"integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"css-declaration-sorter": "^7.2.0",
+				"cssnano-utils": "^4.0.2",
+				"postcss-calc": "^9.0.1",
+				"postcss-colormin": "^6.1.0",
+				"postcss-convert-values": "^6.1.0",
+				"postcss-discard-comments": "^6.0.2",
+				"postcss-discard-duplicates": "^6.0.3",
+				"postcss-discard-empty": "^6.0.3",
+				"postcss-discard-overridden": "^6.0.2",
+				"postcss-merge-longhand": "^6.0.5",
+				"postcss-merge-rules": "^6.1.1",
+				"postcss-minify-font-values": "^6.1.0",
+				"postcss-minify-gradients": "^6.0.3",
+				"postcss-minify-params": "^6.1.0",
+				"postcss-minify-selectors": "^6.0.4",
+				"postcss-normalize-charset": "^6.0.2",
+				"postcss-normalize-display-values": "^6.0.2",
+				"postcss-normalize-positions": "^6.0.2",
+				"postcss-normalize-repeat-style": "^6.0.2",
+				"postcss-normalize-string": "^6.0.2",
+				"postcss-normalize-timing-functions": "^6.0.2",
+				"postcss-normalize-unicode": "^6.1.0",
+				"postcss-normalize-url": "^6.0.2",
+				"postcss-normalize-whitespace": "^6.0.2",
+				"postcss-ordered-values": "^6.0.2",
+				"postcss-reduce-initial": "^6.1.0",
+				"postcss-reduce-transforms": "^6.0.2",
+				"postcss-svgo": "^6.0.3",
+				"postcss-unique-selectors": "^6.0.4"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/cssnano-utils": {
+			"version": "4.0.2",
+			"resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz",
+			"integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==",
+			"dev": true,
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/csso": {
+			"version": "5.0.5",
+			"resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
+			"integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
+			"dev": true,
+			"dependencies": {
+				"css-tree": "~2.2.0"
+			},
+			"engines": {
+				"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
+				"npm": ">=7.0.0"
+			}
+		},
+		"node_modules/csso/node_modules/css-tree": {
+			"version": "2.2.1",
+			"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
+			"integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
+			"dev": true,
+			"dependencies": {
+				"mdn-data": "2.0.28",
+				"source-map-js": "^1.0.1"
+			},
+			"engines": {
+				"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
+				"npm": ">=7.0.0"
+			}
+		},
+		"node_modules/csso/node_modules/mdn-data": {
+			"version": "2.0.28",
+			"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
+			"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
+			"dev": true
+		},
+		"node_modules/dom-serializer": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+			"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+			"dev": true,
+			"dependencies": {
+				"domelementtype": "^2.3.0",
+				"domhandler": "^5.0.2",
+				"entities": "^4.2.0"
+			},
+			"funding": {
+				"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+			}
+		},
+		"node_modules/domelementtype": {
+			"version": "2.3.0",
+			"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+			"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/fb55"
+				}
+			]
+		},
+		"node_modules/domhandler": {
+			"version": "5.0.3",
+			"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+			"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+			"dev": true,
+			"dependencies": {
+				"domelementtype": "^2.3.0"
+			},
+			"engines": {
+				"node": ">= 4"
+			},
+			"funding": {
+				"url": "https://github.com/fb55/domhandler?sponsor=1"
+			}
+		},
+		"node_modules/domutils": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+			"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+			"dev": true,
+			"dependencies": {
+				"dom-serializer": "^2.0.0",
+				"domelementtype": "^2.3.0",
+				"domhandler": "^5.0.3"
+			},
+			"funding": {
+				"url": "https://github.com/fb55/domutils?sponsor=1"
+			}
+		},
+		"node_modules/electron-to-chromium": {
+			"version": "1.4.747",
+			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.747.tgz",
+			"integrity": "sha512-+FnSWZIAvFHbsNVmUxhEqWiaOiPMcfum1GQzlWCg/wLigVtshOsjXHyEFfmt6cFK6+HkS3QOJBv6/3OPumbBfw==",
+			"dev": true
+		},
+		"node_modules/emoji-regex": {
+			"version": "8.0.0",
+			"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+			"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+			"dev": true
+		},
+		"node_modules/entities": {
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+			"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+			"dev": true,
+			"engines": {
+				"node": ">=0.12"
+			},
+			"funding": {
+				"url": "https://github.com/fb55/entities?sponsor=1"
+			}
+		},
+		"node_modules/escalade": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+			"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+			"dev": true,
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/fill-range": {
+			"version": "7.1.1",
+			"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+			"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+			"dev": true,
+			"dependencies": {
+				"to-regex-range": "^5.0.1"
+			},
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/flatpickr": {
+			"version": "4.6.13",
+			"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
+			"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw=="
+		},
+		"node_modules/fraction.js": {
+			"version": "4.3.7",
+			"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+			"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+			"dev": true,
+			"engines": {
+				"node": "*"
+			},
+			"funding": {
+				"type": "patreon",
+				"url": "https://github.com/sponsors/rawify"
+			}
+		},
+		"node_modules/fsevents": {
+			"version": "2.3.2",
+			"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+			"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+			"dev": true,
+			"hasInstallScript": true,
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+			}
+		},
+		"node_modules/get-caller-file": {
+			"version": "2.0.5",
+			"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+			"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+			"dev": true,
+			"engines": {
+				"node": "6.* || 8.* || >= 10.*"
+			}
+		},
+		"node_modules/glob-parent": {
+			"version": "5.1.2",
+			"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+			"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+			"dev": true,
+			"dependencies": {
+				"is-glob": "^4.0.1"
+			},
+			"engines": {
+				"node": ">= 6"
+			}
+		},
+		"node_modules/iframe-resizer": {
+			"version": "4.3.11",
+			"resolved": "https://registry.npmjs.org/iframe-resizer/-/iframe-resizer-4.3.11.tgz",
+			"integrity": "sha512-5QtnsmfH11GDsuC7Gxd/eNzojudX3346Gb0E+Ku8ln8AtfSq+cWCZtnhCrthrtE7f1CI2/kwHkZ9G4sFYzHP7A==",
+			"engines": {
+				"node": ">=0.8.0"
+			},
+			"funding": {
+				"type": "individual",
+				"url": "https://github.com/davidjbradshaw/iframe-resizer/blob/master/FUNDING.md"
+			}
+		},
+		"node_modules/immutable": {
+			"version": "4.3.5",
+			"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
+			"integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
+			"dev": true
+		},
+		"node_modules/is-binary-path": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+			"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+			"dev": true,
+			"dependencies": {
+				"binary-extensions": "^2.0.0"
+			},
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/is-extglob": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+			"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+			"dev": true,
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/is-fullwidth-code-point": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+			"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+			"dev": true,
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/is-glob": {
+			"version": "4.0.3",
+			"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+			"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+			"dev": true,
+			"dependencies": {
+				"is-extglob": "^2.1.1"
+			},
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/is-number": {
+			"version": "7.0.0",
+			"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+			"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+			"dev": true,
+			"engines": {
+				"node": ">=0.12.0"
+			}
+		},
+		"node_modules/jquery": {
+			"version": "3.7.1",
+			"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
+			"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
+		},
+		"node_modules/jquery-ui-dist": {
+			"version": "1.13.2",
+			"resolved": "https://registry.npmjs.org/jquery-ui-dist/-/jquery-ui-dist-1.13.2.tgz",
+			"integrity": "sha512-oVDRd1NLtTbBwpRKAYdIRgpWVDzeBhfy7Gu0RmY6JEaZtmBq6kDn1pm5SgDiAotrnDS+RoTRXO6xvcNTxA9tOA==",
+			"dependencies": {
+				"jquery": ">=1.8.0 <4.0.0"
+			}
+		},
+		"node_modules/lilconfig": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz",
+			"integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==",
+			"dev": true,
+			"engines": {
+				"node": ">=14"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/antonk52"
+			}
+		},
+		"node_modules/lodash.memoize": {
+			"version": "4.1.2",
+			"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+			"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
+			"dev": true
+		},
+		"node_modules/lodash.uniq": {
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+			"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+			"dev": true
+		},
+		"node_modules/luxon": {
+			"version": "3.4.4",
+			"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
+			"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/mathjax": {
+			"version": "3.2.2",
+			"resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.2.tgz",
+			"integrity": "sha512-Bt+SSVU8eBG27zChVewOicYs7Xsdt40qm4+UpHyX7k0/O9NliPc+x77k1/FEsPsjKPZGJvtRZM1vO+geW0OhGw=="
+		},
+		"node_modules/mdn-data": {
+			"version": "2.0.30",
+			"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
+			"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
+			"dev": true
+		},
+		"node_modules/nanoid": {
+			"version": "3.3.8",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+			"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/ai"
+				}
+			],
+			"license": "MIT",
+			"bin": {
+				"nanoid": "bin/nanoid.cjs"
+			},
+			"engines": {
+				"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+			}
+		},
+		"node_modules/node-releases": {
+			"version": "2.0.14",
+			"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
+			"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
+			"dev": true
+		},
+		"node_modules/normalize-path": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+			"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+			"dev": true,
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/normalize-range": {
+			"version": "0.1.2",
+			"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+			"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+			"dev": true,
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/nth-check": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+			"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+			"dev": true,
+			"dependencies": {
+				"boolbase": "^1.0.0"
+			},
+			"funding": {
+				"url": "https://github.com/fb55/nth-check?sponsor=1"
+			}
+		},
+		"node_modules/picocolors": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+			"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+			"dev": true
+		},
+		"node_modules/picomatch": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+			"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+			"dev": true,
+			"engines": {
+				"node": ">=8.6"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/jonschlinkert"
+			}
+		},
+		"node_modules/postcss": {
+			"version": "8.4.38",
+			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
+			"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "opencollective",
+					"url": "https://opencollective.com/postcss/"
+				},
+				{
+					"type": "tidelift",
+					"url": "https://tidelift.com/funding/github/npm/postcss"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/ai"
+				}
+			],
+			"dependencies": {
+				"nanoid": "^3.3.7",
+				"picocolors": "^1.0.0",
+				"source-map-js": "^1.2.0"
+			},
+			"engines": {
+				"node": "^10 || ^12 || >=14"
+			}
+		},
+		"node_modules/postcss-calc": {
+			"version": "9.0.1",
+			"resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz",
+			"integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==",
+			"dev": true,
+			"dependencies": {
+				"postcss-selector-parser": "^6.0.11",
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.2.2"
+			}
+		},
+		"node_modules/postcss-colormin": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz",
+			"integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"caniuse-api": "^3.0.0",
+				"colord": "^2.9.3",
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-convert-values": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz",
+			"integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-discard-comments": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz",
+			"integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==",
+			"dev": true,
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-discard-duplicates": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz",
+			"integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==",
+			"dev": true,
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-discard-empty": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz",
+			"integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==",
+			"dev": true,
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-discard-overridden": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz",
+			"integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==",
+			"dev": true,
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-merge-longhand": {
+			"version": "6.0.5",
+			"resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz",
+			"integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0",
+				"stylehacks": "^6.1.1"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-merge-rules": {
+			"version": "6.1.1",
+			"resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.1.tgz",
+			"integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"caniuse-api": "^3.0.0",
+				"cssnano-utils": "^4.0.2",
+				"postcss-selector-parser": "^6.0.16"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-minify-font-values": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz",
+			"integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-minify-gradients": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz",
+			"integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==",
+			"dev": true,
+			"dependencies": {
+				"colord": "^2.9.3",
+				"cssnano-utils": "^4.0.2",
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-minify-params": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz",
+			"integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"cssnano-utils": "^4.0.2",
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-minify-selectors": {
+			"version": "6.0.4",
+			"resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.4.tgz",
+			"integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==",
+			"dev": true,
+			"dependencies": {
+				"postcss-selector-parser": "^6.0.16"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-charset": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz",
+			"integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==",
+			"dev": true,
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-display-values": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz",
+			"integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-positions": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz",
+			"integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-repeat-style": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz",
+			"integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-string": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz",
+			"integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-timing-functions": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz",
+			"integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-unicode": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz",
+			"integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-url": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz",
+			"integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-normalize-whitespace": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz",
+			"integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-ordered-values": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz",
+			"integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==",
+			"dev": true,
+			"dependencies": {
+				"cssnano-utils": "^4.0.2",
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-reduce-initial": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz",
+			"integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"caniuse-api": "^3.0.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-reduce-transforms": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz",
+			"integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-selector-parser": {
+			"version": "6.0.16",
+			"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz",
+			"integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==",
+			"dev": true,
+			"dependencies": {
+				"cssesc": "^3.0.0",
+				"util-deprecate": "^1.0.2"
+			},
+			"engines": {
+				"node": ">=4"
+			}
+		},
+		"node_modules/postcss-svgo": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz",
+			"integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==",
+			"dev": true,
+			"dependencies": {
+				"postcss-value-parser": "^4.2.0",
+				"svgo": "^3.2.0"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >= 18"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-unique-selectors": {
+			"version": "6.0.4",
+			"resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.4.tgz",
+			"integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==",
+			"dev": true,
+			"dependencies": {
+				"postcss-selector-parser": "^6.0.16"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/postcss-value-parser": {
+			"version": "4.2.0",
+			"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+			"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+			"dev": true
+		},
+		"node_modules/prettier": {
+			"version": "3.2.5",
+			"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
+			"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
+			"dev": true,
+			"bin": {
+				"prettier": "bin/prettier.cjs"
+			},
+			"engines": {
+				"node": ">=14"
+			},
+			"funding": {
+				"url": "https://github.com/prettier/prettier?sponsor=1"
+			}
+		},
+		"node_modules/readdirp": {
+			"version": "3.6.0",
+			"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+			"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+			"dev": true,
+			"dependencies": {
+				"picomatch": "^2.2.1"
+			},
+			"engines": {
+				"node": ">=8.10.0"
+			}
+		},
+		"node_modules/require-directory": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+			"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+			"dev": true,
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/rtlcss": {
+			"version": "4.1.1",
+			"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.1.1.tgz",
+			"integrity": "sha512-/oVHgBtnPNcggP2aVXQjSy6N1mMAfHg4GSag0QtZBlD5bdDgAHwr4pydqJGd+SUCu9260+Pjqbjwtvu7EMH1KQ==",
+			"dev": true,
+			"dependencies": {
+				"escalade": "^3.1.1",
+				"picocolors": "^1.0.0",
+				"postcss": "^8.4.21",
+				"strip-json-comments": "^3.1.1"
+			},
+			"bin": {
+				"rtlcss": "bin/rtlcss.js"
+			},
+			"engines": {
+				"node": ">=12.0.0"
+			}
+		},
+		"node_modules/sass": {
+			"version": "1.75.0",
+			"resolved": "https://registry.npmjs.org/sass/-/sass-1.75.0.tgz",
+			"integrity": "sha512-ShMYi3WkrDWxExyxSZPst4/okE9ts46xZmJDSawJQrnte7M1V9fScVB+uNXOVKRBt0PggHOwoZcn8mYX4trnBw==",
+			"dev": true,
+			"dependencies": {
+				"chokidar": ">=3.0.0 <4.0.0",
+				"immutable": "^4.0.0",
+				"source-map-js": ">=0.6.2 <2.0.0"
+			},
+			"bin": {
+				"sass": "sass.js"
+			},
+			"engines": {
+				"node": ">=14.0.0"
+			}
+		},
+		"node_modules/shortcut-buttons-flatpickr": {
+			"version": "0.4.0",
+			"resolved": "https://registry.npmjs.org/shortcut-buttons-flatpickr/-/shortcut-buttons-flatpickr-0.4.0.tgz",
+			"integrity": "sha512-JKmT4my3Hm1e18OvG4Q6RcFhN4WRqqpTMkHrvZ7fup/dp6aTIWGVCHdRYtASkp/FCzDlJh6iCLQ/VcwwNpAMoQ=="
+		},
+		"node_modules/sortablejs": {
+			"version": "1.15.2",
+			"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
+			"integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
+		},
+		"node_modules/source-map": {
+			"version": "0.6.1",
+			"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+			"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+			"dev": true,
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/source-map-js": {
+			"version": "1.2.0",
+			"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+			"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+			"dev": true,
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/source-map-support": {
+			"version": "0.5.21",
+			"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+			"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+			"dev": true,
+			"dependencies": {
+				"buffer-from": "^1.0.0",
+				"source-map": "^0.6.0"
+			}
+		},
+		"node_modules/string-width": {
+			"version": "4.2.3",
+			"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+			"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+			"dev": true,
+			"dependencies": {
+				"emoji-regex": "^8.0.0",
+				"is-fullwidth-code-point": "^3.0.0",
+				"strip-ansi": "^6.0.1"
+			},
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/strip-ansi": {
+			"version": "6.0.1",
+			"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+			"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+			"dev": true,
+			"dependencies": {
+				"ansi-regex": "^5.0.1"
+			},
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/strip-json-comments": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+			"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+			"dev": true,
+			"engines": {
+				"node": ">=8"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/sindresorhus"
+			}
+		},
+		"node_modules/style-mod": {
+			"version": "4.1.2",
+			"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
+			"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
+			"license": "MIT"
+		},
+		"node_modules/stylehacks": {
+			"version": "6.1.1",
+			"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
+			"integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==",
+			"dev": true,
+			"dependencies": {
+				"browserslist": "^4.23.0",
+				"postcss-selector-parser": "^6.0.16"
+			},
+			"engines": {
+				"node": "^14 || ^16 || >=18.0"
+			},
+			"peerDependencies": {
+				"postcss": "^8.4.31"
+			}
+		},
+		"node_modules/svgo": {
+			"version": "3.2.0",
+			"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz",
+			"integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==",
+			"dev": true,
+			"dependencies": {
+				"@trysound/sax": "0.2.0",
+				"commander": "^7.2.0",
+				"css-select": "^5.1.0",
+				"css-tree": "^2.3.1",
+				"css-what": "^6.1.0",
+				"csso": "^5.0.5",
+				"picocolors": "^1.0.0"
+			},
+			"bin": {
+				"svgo": "bin/svgo"
+			},
+			"engines": {
+				"node": ">=14.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/svgo"
+			}
+		},
+		"node_modules/terser": {
+			"version": "5.30.4",
+			"resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz",
+			"integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==",
+			"dev": true,
+			"dependencies": {
+				"@jridgewell/source-map": "^0.3.3",
+				"acorn": "^8.8.2",
+				"commander": "^2.20.0",
+				"source-map-support": "~0.5.20"
+			},
+			"bin": {
+				"terser": "bin/terser"
+			},
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/terser/node_modules/commander": {
+			"version": "2.20.3",
+			"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+			"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+			"dev": true
+		},
+		"node_modules/thememirror": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/thememirror/-/thememirror-2.0.1.tgz",
+			"integrity": "sha512-d5i6FVvWWPkwrm4cHLI3t9AT1OrkAt7Ig8dtdYSofgF7C/eiyNuq6zQzSTusWTde3jpW9WLvA9J/fzNKMUsd0w==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0"
+			}
+		},
+		"node_modules/to-regex-range": {
+			"version": "5.0.1",
+			"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+			"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+			"dev": true,
+			"dependencies": {
+				"is-number": "^7.0.0"
+			},
+			"engines": {
+				"node": ">=8.0"
+			}
+		},
+		"node_modules/update-browserslist-db": {
+			"version": "1.0.13",
+			"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
+			"integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "opencollective",
+					"url": "https://opencollective.com/browserslist"
+				},
+				{
+					"type": "tidelift",
+					"url": "https://tidelift.com/funding/github/npm/browserslist"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/ai"
+				}
+			],
+			"dependencies": {
+				"escalade": "^3.1.1",
+				"picocolors": "^1.0.0"
+			},
+			"bin": {
+				"update-browserslist-db": "cli.js"
+			},
+			"peerDependencies": {
+				"browserslist": ">= 4.21.0"
+			}
+		},
+		"node_modules/util-deprecate": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+			"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+			"dev": true
+		},
+		"node_modules/w3c-keyname": {
+			"version": "2.2.8",
+			"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+			"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+			"license": "MIT"
+		},
+		"node_modules/wrap-ansi": {
+			"version": "7.0.0",
+			"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+			"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+			"dev": true,
+			"dependencies": {
+				"ansi-styles": "^4.0.0",
+				"string-width": "^4.1.0",
+				"strip-ansi": "^6.0.0"
+			},
+			"engines": {
+				"node": ">=10"
+			},
+			"funding": {
+				"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+			}
+		},
+		"node_modules/y18n": {
+			"version": "5.0.8",
+			"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+			"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+			"dev": true,
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/yargs": {
+			"version": "17.7.2",
+			"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+			"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+			"dev": true,
+			"dependencies": {
+				"cliui": "^8.0.1",
+				"escalade": "^3.1.1",
+				"get-caller-file": "^2.0.5",
+				"require-directory": "^2.1.1",
+				"string-width": "^4.2.3",
+				"y18n": "^5.0.5",
+				"yargs-parser": "^21.1.1"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/yargs-parser": {
+			"version": "21.1.1",
+			"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+			"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+			"dev": true,
+			"engines": {
+				"node": ">=12"
+			}
+		}
+	},
+	"dependencies": {
+		"@codemirror/autocomplete": {
+			"version": "6.18.6",
+			"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
+			"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
+			"requires": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0"
+			}
+		},
+		"@codemirror/commands": {
+			"version": "6.8.1",
+			"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
+			"integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
+			"requires": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.4.0",
+				"@codemirror/view": "^6.27.0",
+				"@lezer/common": "^1.1.0"
+			}
+		},
+		"@codemirror/lang-css": {
+			"version": "6.3.1",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+			"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+			"requires": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@lezer/common": "^1.0.2",
+				"@lezer/css": "^1.1.7"
+			}
+		},
+		"@codemirror/lang-html": {
+			"version": "6.4.9",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
+			"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
+			"requires": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/lang-css": "^6.0.0",
+				"@codemirror/lang-javascript": "^6.0.0",
+				"@codemirror/language": "^6.4.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0",
+				"@lezer/css": "^1.1.0",
+				"@lezer/html": "^1.3.0"
+			}
+		},
+		"@codemirror/lang-javascript": {
+			"version": "6.2.3",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.3.tgz",
+			"integrity": "sha512-8PR3vIWg7pSu7ur8A07pGiYHgy3hHj+mRYRCSG8q+mPIrl0F02rgpGv+DsQTHRTc30rydOsf5PZ7yjKFg2Ackw==",
+			"requires": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/language": "^6.6.0",
+				"@codemirror/lint": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.17.0",
+				"@lezer/common": "^1.0.0",
+				"@lezer/javascript": "^1.0.0"
+			}
+		},
+		"@codemirror/lang-xml": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
+			"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
+			"requires": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/language": "^6.4.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/common": "^1.0.0",
+				"@lezer/xml": "^1.0.0"
+			}
+		},
+		"@codemirror/language": {
+			"version": "6.11.0",
+			"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.0.tgz",
+			"integrity": "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ==",
+			"requires": {
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.23.0",
+				"@lezer/common": "^1.1.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0",
+				"style-mod": "^4.0.0"
+			}
+		},
+		"@codemirror/lint": {
+			"version": "6.8.5",
+			"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
+			"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
+			"requires": {
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.35.0",
+				"crelt": "^1.0.5"
+			}
+		},
+		"@codemirror/search": {
+			"version": "6.5.10",
+			"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.10.tgz",
+			"integrity": "sha512-RMdPdmsrUf53pb2VwflKGHEe1XVM07hI7vV2ntgw1dmqhimpatSJKva4VA9h4TLUDOD4EIF02201oZurpnEFsg==",
+			"requires": {
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"crelt": "^1.0.5"
+			}
+		},
+		"@codemirror/state": {
+			"version": "6.5.2",
+			"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
+			"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
+			"requires": {
+				"@marijn/find-cluster-break": "^1.0.0"
+			}
+		},
+		"@codemirror/theme-one-dark": {
+			"version": "6.1.2",
+			"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz",
+			"integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==",
+			"requires": {
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0",
+				"@lezer/highlight": "^1.0.0"
+			}
+		},
+		"@codemirror/view": {
+			"version": "6.36.7",
+			"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.7.tgz",
+			"integrity": "sha512-kCWGW/chWGPgZqfZ36Um9Iz0X2IVpmCjg1P/qY6B6a2ecXtWRRAigmpJ6YgUQ5lTWXMyyVdfmpzhLZmsZQMbtg==",
+			"requires": {
+				"@codemirror/state": "^6.5.0",
+				"style-mod": "^4.1.0",
+				"w3c-keyname": "^2.2.4"
+			}
+		},
+		"@fortawesome/fontawesome-free": {
+			"version": "6.5.2",
+			"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
+			"integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q=="
+		},
+		"@jridgewell/gen-mapping": {
+			"version": "0.3.5",
+			"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
+			"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+			"dev": true,
+			"requires": {
+				"@jridgewell/set-array": "^1.2.1",
+				"@jridgewell/sourcemap-codec": "^1.4.10",
+				"@jridgewell/trace-mapping": "^0.3.24"
+			}
+		},
+		"@jridgewell/resolve-uri": {
+			"version": "3.1.2",
+			"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+			"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+			"dev": true
+		},
+		"@jridgewell/set-array": {
+			"version": "1.2.1",
+			"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+			"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+			"dev": true
+		},
+		"@jridgewell/source-map": {
+			"version": "0.3.6",
+			"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
+			"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
+			"dev": true,
+			"requires": {
+				"@jridgewell/gen-mapping": "^0.3.5",
+				"@jridgewell/trace-mapping": "^0.3.25"
+			}
+		},
+		"@jridgewell/sourcemap-codec": {
+			"version": "1.4.15",
+			"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+			"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
+			"dev": true
+		},
+		"@jridgewell/trace-mapping": {
+			"version": "0.3.25",
+			"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+			"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+			"dev": true,
+			"requires": {
+				"@jridgewell/resolve-uri": "^3.1.0",
+				"@jridgewell/sourcemap-codec": "^1.4.14"
+			}
+		},
+		"@lezer/common": {
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
+			"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="
+		},
+		"@lezer/css": {
+			"version": "1.1.11",
+			"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.11.tgz",
+			"integrity": "sha512-FuAnusbLBl1SEAtfN8NdShxYJiESKw9LAFysfea1T96jD3ydBn12oYjaSG1a04BQRIUd93/0D8e5CV1cUMkmQg==",
+			"requires": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0"
+			}
+		},
+		"@lezer/highlight": {
+			"version": "1.2.1",
+			"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
+			"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
+			"requires": {
+				"@lezer/common": "^1.0.0"
+			}
+		},
+		"@lezer/html": {
+			"version": "1.3.10",
+			"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
+			"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
+			"requires": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0"
+			}
+		},
+		"@lezer/javascript": {
+			"version": "1.5.1",
+			"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
+			"integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==",
+			"requires": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.1.3",
+				"@lezer/lr": "^1.3.0"
+			}
+		},
+		"@lezer/lr": {
+			"version": "1.4.2",
+			"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
+			"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
+			"requires": {
+				"@lezer/common": "^1.0.0"
+			}
+		},
+		"@lezer/xml": {
+			"version": "1.0.6",
+			"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
+			"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
+			"requires": {
+				"@lezer/common": "^1.2.0",
+				"@lezer/highlight": "^1.0.0",
+				"@lezer/lr": "^1.0.0"
+			}
+		},
+		"@marijn/find-cluster-break": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+			"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
+		},
+		"@openwebwork/codemirror-lang-pg": {
+			"version": "0.0.2",
+			"resolved": "https://registry.npmjs.org/@openwebwork/codemirror-lang-pg/-/codemirror-lang-pg-0.0.2.tgz",
+			"integrity": "sha512-xnipB2bydjDbcRVO9XpFNkhI9muoA6FzPqx6gRxQk10cXj8nzOn694gl7ATpn/xn5J5Y+7YpFAFTDqs0atgI6g==",
+			"requires": {
+				"@codemirror/language": "^6.11.0",
+				"@lezer/highlight": "^1.2.1",
+				"@lezer/lr": "^1.4.2"
+			}
+		},
+		"@openwebwork/pg-codemirror-editor": {
+			"version": "0.0.4",
+			"resolved": "https://registry.npmjs.org/@openwebwork/pg-codemirror-editor/-/pg-codemirror-editor-0.0.4.tgz",
+			"integrity": "sha512-aJ3/AmKc1Ck/6zKctETP29QnuKn4PHIWhEZNdCzi49zAI+Ls2wH0JQdPadoj3z4savAxhabsdYOu8WWV7vwx5g==",
+			"requires": {
+				"@codemirror/lang-html": "^6.4.9",
+				"@codemirror/lang-xml": "^6.1.0",
+				"@codemirror/theme-one-dark": "^6.1.2",
+				"@openwebwork/codemirror-lang-pg": "^0.0.2",
+				"@replit/codemirror-emacs": "^6.1.0",
+				"@replit/codemirror-vim": "^6.3.0",
+				"cm6-theme-basic-dark": "^0.2.0",
+				"cm6-theme-basic-light": "^0.2.0",
+				"cm6-theme-gruvbox-dark": "^0.2.0",
+				"cm6-theme-gruvbox-light": "^0.2.0",
+				"cm6-theme-material-dark": "^0.2.0",
+				"cm6-theme-nord": "^0.2.0",
+				"cm6-theme-solarized-dark": "^0.2.0",
+				"cm6-theme-solarized-light": "^0.2.0",
+				"codemirror": "^6.0.1",
+				"codemirror-lang-mt": "^0.0.2",
+				"codemirror-lang-perl": "^0.1.5",
+				"style-mod": "^4.1.2",
+				"thememirror": "^2.0.1"
+			}
+		},
+		"@popperjs/core": {
+			"version": "2.11.8",
+			"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+			"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+			"peer": true
+		},
+		"@replit/codemirror-emacs": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz",
+			"integrity": "sha512-74DITnht6Cs6sHg02PQ169IKb1XgtyhI9sLD0JeOFco6Ds18PT+dkD8+DgXBDokne9UIFKsBbKPnpFRAz60/Lw==",
+			"requires": {}
+		},
+		"@replit/codemirror-vim": {
+			"version": "6.3.0",
+			"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
+			"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
+			"requires": {}
+		},
+		"@trysound/sax": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
+			"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
+			"dev": true
+		},
+		"acorn": {
+			"version": "8.11.3",
+			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
+			"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+			"dev": true
+		},
+		"ansi-regex": {
+			"version": "5.0.1",
+			"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+			"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+			"dev": true
+		},
+		"ansi-styles": {
+			"version": "4.3.0",
+			"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+			"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+			"dev": true,
+			"requires": {
+				"color-convert": "^2.0.1"
+			}
+		},
+		"anymatch": {
+			"version": "3.1.3",
+			"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+			"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+			"dev": true,
+			"requires": {
+				"normalize-path": "^3.0.0",
+				"picomatch": "^2.0.4"
+			}
+		},
+		"autoprefixer": {
+			"version": "10.4.19",
+			"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
+			"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"caniuse-lite": "^1.0.30001599",
+				"fraction.js": "^4.3.7",
+				"normalize-range": "^0.1.2",
+				"picocolors": "^1.0.0",
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"binary-extensions": {
+			"version": "2.2.0",
+			"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+			"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+			"dev": true
+		},
+		"boolbase": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+			"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+			"dev": true
+		},
+		"bootstrap": {
+			"version": "5.3.3",
+			"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
+			"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
+			"requires": {}
+		},
+		"braces": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+			"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+			"dev": true,
+			"requires": {
+				"fill-range": "^7.1.1"
+			}
+		},
+		"browserslist": {
+			"version": "4.23.0",
+			"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz",
+			"integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==",
+			"dev": true,
+			"requires": {
+				"caniuse-lite": "^1.0.30001587",
+				"electron-to-chromium": "^1.4.668",
+				"node-releases": "^2.0.14",
+				"update-browserslist-db": "^1.0.13"
+			}
+		},
+		"buffer-from": {
+			"version": "1.1.2",
+			"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+			"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+			"dev": true
+		},
+		"caniuse-api": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
+			"integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.0.0",
+				"caniuse-lite": "^1.0.0",
+				"lodash.memoize": "^4.1.2",
+				"lodash.uniq": "^4.5.0"
+			}
+		},
+		"caniuse-lite": {
+			"version": "1.0.30001723",
+			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz",
+			"integrity": "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==",
+			"dev": true
+		},
+		"chokidar": {
+			"version": "3.6.0",
+			"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+			"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+			"dev": true,
+			"requires": {
+				"anymatch": "~3.1.2",
+				"braces": "~3.0.2",
+				"fsevents": "~2.3.2",
+				"glob-parent": "~5.1.2",
+				"is-binary-path": "~2.1.0",
+				"is-glob": "~4.0.1",
+				"normalize-path": "~3.0.0",
+				"readdirp": "~3.6.0"
+			}
+		},
+		"cliui": {
+			"version": "8.0.1",
+			"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+			"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+			"dev": true,
+			"requires": {
+				"string-width": "^4.2.0",
+				"strip-ansi": "^6.0.1",
+				"wrap-ansi": "^7.0.0"
+			}
+		},
+		"cm6-theme-basic-dark": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-basic-dark/-/cm6-theme-basic-dark-0.2.0.tgz",
+			"integrity": "sha512-+mNNJecRtxS/KkloMDCQF0oTrT6aFGRZTjnBcdT5UG1pcDO4Brq8l1+0KR/8dZ7hub2gOGOzoi3rGFD8GzlH7Q==",
+			"requires": {}
+		},
+		"cm6-theme-basic-light": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz",
+			"integrity": "sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA==",
+			"requires": {}
+		},
+		"cm6-theme-gruvbox-dark": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-gruvbox-dark/-/cm6-theme-gruvbox-dark-0.2.0.tgz",
+			"integrity": "sha512-xyqsG19qV+nb7ZHTMocSNWwZHMExfQxDm0FlbNMqEGKeQR96WryssXJH/IZZQudwrPpWU2dCoyOgMFhti2UTYA==",
+			"requires": {}
+		},
+		"cm6-theme-gruvbox-light": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-gruvbox-light/-/cm6-theme-gruvbox-light-0.2.0.tgz",
+			"integrity": "sha512-sc4dEMLU5y4F3QGLjwMQs1H3Q0a0ooXA1EvyWnknxLEGQVXwJrxkkV67gs1TqWASl2i63iomt4zyz5pkbfO1yg==",
+			"requires": {}
+		},
+		"cm6-theme-material-dark": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-material-dark/-/cm6-theme-material-dark-0.2.0.tgz",
+			"integrity": "sha512-H09JZihzg4w0mTtOqo5bQdxItkQWw+ergKlk7BSfwYjaR2nOi+wIN0R+ByAo7bON8GbFODvjTxH3EIqdhovFeA==",
+			"requires": {}
+		},
+		"cm6-theme-nord": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-nord/-/cm6-theme-nord-0.2.0.tgz",
+			"integrity": "sha512-jTh+5nvl+N/5CtTK7UVcrxDCj2AOStvbNM8uP6tx6amq4QaaLDlapjMw+MNzEkvxcPnHY+YM91tbklS2KNlR2w==",
+			"requires": {}
+		},
+		"cm6-theme-solarized-dark": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-solarized-dark/-/cm6-theme-solarized-dark-0.2.0.tgz",
+			"integrity": "sha512-FWtYHcX8NLzNSs21yGbkLF+q/5m2u80ug0JytKoI9nMZWPP5dcnsFYp1iZBEegLehiZnpv1qcmTsLTUG2KD39w==",
+			"requires": {}
+		},
+		"cm6-theme-solarized-light": {
+			"version": "0.2.0",
+			"resolved": "https://registry.npmjs.org/cm6-theme-solarized-light/-/cm6-theme-solarized-light-0.2.0.tgz",
+			"integrity": "sha512-Iw7Xv+9A6NlT7sRGlM2pOwD3ZBETkAqpb7c6O0LPj5kjwcK6C3k+mvjzaQt1gzfBErMmhL1HHuK07zICeXkE+w==",
+			"requires": {}
+		},
+		"codemirror": {
+			"version": "6.0.1",
+			"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
+			"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
+			"requires": {
+				"@codemirror/autocomplete": "^6.0.0",
+				"@codemirror/commands": "^6.0.0",
+				"@codemirror/language": "^6.0.0",
+				"@codemirror/lint": "^6.0.0",
+				"@codemirror/search": "^6.0.0",
+				"@codemirror/state": "^6.0.0",
+				"@codemirror/view": "^6.0.0"
+			}
+		},
+		"codemirror-lang-mt": {
+			"version": "0.0.2",
+			"resolved": "https://registry.npmjs.org/codemirror-lang-mt/-/codemirror-lang-mt-0.0.2.tgz",
+			"integrity": "sha512-m2q+EVgNeDxq3A8gCnoUGZvTuXvvZKlZliiqif4VAMPiu7dKJsaopvXZo8S1KH6cb2x9fJuKr5yUTnkxSLQZIQ==",
+			"requires": {
+				"@codemirror/lang-css": "^6.3.1",
+				"@codemirror/lang-html": "^6.4.9",
+				"@codemirror/lang-javascript": "^6.2.3",
+				"@codemirror/language": "^6.11.0",
+				"@lezer/highlight": "^1.2.1",
+				"@lezer/lr": "^1.4.2"
+			}
+		},
+		"codemirror-lang-perl": {
+			"version": "0.1.5",
+			"resolved": "https://registry.npmjs.org/codemirror-lang-perl/-/codemirror-lang-perl-0.1.5.tgz",
+			"integrity": "sha512-o0QBzsO4z+ZWaN7ueYnFVYWoFlFvvfgcgNA/dQLxYUDiKGSUY0R52UL/NqTO6swUVrR+O6JI3Xh1j/ed81JIwA==",
+			"requires": {
+				"@codemirror/language": "^6.11.0",
+				"@lezer/highlight": "^1.2.1",
+				"@lezer/lr": "^1.4.2"
+			}
+		},
+		"color-convert": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+			"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+			"dev": true,
+			"requires": {
+				"color-name": "~1.1.4"
+			}
+		},
+		"color-name": {
+			"version": "1.1.4",
+			"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+			"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+			"dev": true
+		},
+		"colord": {
+			"version": "2.9.3",
+			"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
+			"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
+			"dev": true
+		},
+		"commander": {
+			"version": "7.2.0",
+			"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+			"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+			"dev": true
+		},
+		"crelt": {
+			"version": "1.0.6",
+			"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+			"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
+		},
+		"css-declaration-sorter": {
+			"version": "7.2.0",
+			"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz",
+			"integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==",
+			"dev": true,
+			"requires": {}
+		},
+		"css-select": {
+			"version": "5.1.0",
+			"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
+			"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
+			"dev": true,
+			"requires": {
+				"boolbase": "^1.0.0",
+				"css-what": "^6.1.0",
+				"domhandler": "^5.0.2",
+				"domutils": "^3.0.1",
+				"nth-check": "^2.0.1"
+			}
+		},
+		"css-tree": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
+			"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
+			"dev": true,
+			"requires": {
+				"mdn-data": "2.0.30",
+				"source-map-js": "^1.0.1"
+			}
+		},
+		"css-what": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
+			"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
+			"dev": true
+		},
+		"cssesc": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+			"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+			"dev": true
+		},
+		"cssnano": {
+			"version": "6.1.2",
+			"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz",
+			"integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==",
+			"dev": true,
+			"requires": {
+				"cssnano-preset-default": "^6.1.2",
+				"lilconfig": "^3.1.1"
+			}
+		},
+		"cssnano-preset-default": {
+			"version": "6.1.2",
+			"resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz",
+			"integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"css-declaration-sorter": "^7.2.0",
+				"cssnano-utils": "^4.0.2",
+				"postcss-calc": "^9.0.1",
+				"postcss-colormin": "^6.1.0",
+				"postcss-convert-values": "^6.1.0",
+				"postcss-discard-comments": "^6.0.2",
+				"postcss-discard-duplicates": "^6.0.3",
+				"postcss-discard-empty": "^6.0.3",
+				"postcss-discard-overridden": "^6.0.2",
+				"postcss-merge-longhand": "^6.0.5",
+				"postcss-merge-rules": "^6.1.1",
+				"postcss-minify-font-values": "^6.1.0",
+				"postcss-minify-gradients": "^6.0.3",
+				"postcss-minify-params": "^6.1.0",
+				"postcss-minify-selectors": "^6.0.4",
+				"postcss-normalize-charset": "^6.0.2",
+				"postcss-normalize-display-values": "^6.0.2",
+				"postcss-normalize-positions": "^6.0.2",
+				"postcss-normalize-repeat-style": "^6.0.2",
+				"postcss-normalize-string": "^6.0.2",
+				"postcss-normalize-timing-functions": "^6.0.2",
+				"postcss-normalize-unicode": "^6.1.0",
+				"postcss-normalize-url": "^6.0.2",
+				"postcss-normalize-whitespace": "^6.0.2",
+				"postcss-ordered-values": "^6.0.2",
+				"postcss-reduce-initial": "^6.1.0",
+				"postcss-reduce-transforms": "^6.0.2",
+				"postcss-svgo": "^6.0.3",
+				"postcss-unique-selectors": "^6.0.4"
+			}
+		},
+		"cssnano-utils": {
+			"version": "4.0.2",
+			"resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz",
+			"integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==",
+			"dev": true,
+			"requires": {}
+		},
+		"csso": {
+			"version": "5.0.5",
+			"resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
+			"integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
+			"dev": true,
+			"requires": {
+				"css-tree": "~2.2.0"
+			},
+			"dependencies": {
+				"css-tree": {
+					"version": "2.2.1",
+					"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
+					"integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
+					"dev": true,
+					"requires": {
+						"mdn-data": "2.0.28",
+						"source-map-js": "^1.0.1"
+					}
+				},
+				"mdn-data": {
+					"version": "2.0.28",
+					"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
+					"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
+					"dev": true
+				}
+			}
+		},
+		"dom-serializer": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+			"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+			"dev": true,
+			"requires": {
+				"domelementtype": "^2.3.0",
+				"domhandler": "^5.0.2",
+				"entities": "^4.2.0"
+			}
+		},
+		"domelementtype": {
+			"version": "2.3.0",
+			"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+			"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+			"dev": true
+		},
+		"domhandler": {
+			"version": "5.0.3",
+			"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+			"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+			"dev": true,
+			"requires": {
+				"domelementtype": "^2.3.0"
+			}
+		},
+		"domutils": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+			"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+			"dev": true,
+			"requires": {
+				"dom-serializer": "^2.0.0",
+				"domelementtype": "^2.3.0",
+				"domhandler": "^5.0.3"
+			}
+		},
+		"electron-to-chromium": {
+			"version": "1.4.747",
+			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.747.tgz",
+			"integrity": "sha512-+FnSWZIAvFHbsNVmUxhEqWiaOiPMcfum1GQzlWCg/wLigVtshOsjXHyEFfmt6cFK6+HkS3QOJBv6/3OPumbBfw==",
+			"dev": true
+		},
+		"emoji-regex": {
+			"version": "8.0.0",
+			"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+			"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+			"dev": true
+		},
+		"entities": {
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+			"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+			"dev": true
+		},
+		"escalade": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+			"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+			"dev": true
+		},
+		"fill-range": {
+			"version": "7.1.1",
+			"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+			"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+			"dev": true,
+			"requires": {
+				"to-regex-range": "^5.0.1"
+			}
+		},
+		"flatpickr": {
+			"version": "4.6.13",
+			"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
+			"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw=="
+		},
+		"fraction.js": {
+			"version": "4.3.7",
+			"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+			"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+			"dev": true
+		},
+		"fsevents": {
+			"version": "2.3.2",
+			"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+			"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+			"dev": true,
+			"optional": true
+		},
+		"get-caller-file": {
+			"version": "2.0.5",
+			"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+			"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+			"dev": true
+		},
+		"glob-parent": {
+			"version": "5.1.2",
+			"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+			"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+			"dev": true,
+			"requires": {
+				"is-glob": "^4.0.1"
+			}
+		},
+		"iframe-resizer": {
+			"version": "4.3.11",
+			"resolved": "https://registry.npmjs.org/iframe-resizer/-/iframe-resizer-4.3.11.tgz",
+			"integrity": "sha512-5QtnsmfH11GDsuC7Gxd/eNzojudX3346Gb0E+Ku8ln8AtfSq+cWCZtnhCrthrtE7f1CI2/kwHkZ9G4sFYzHP7A=="
+		},
+		"immutable": {
+			"version": "4.3.5",
+			"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
+			"integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
+			"dev": true
+		},
+		"is-binary-path": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+			"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+			"dev": true,
+			"requires": {
+				"binary-extensions": "^2.0.0"
+			}
+		},
+		"is-extglob": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+			"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+			"dev": true
+		},
+		"is-fullwidth-code-point": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+			"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+			"dev": true
+		},
+		"is-glob": {
+			"version": "4.0.3",
+			"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+			"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+			"dev": true,
+			"requires": {
+				"is-extglob": "^2.1.1"
+			}
+		},
+		"is-number": {
+			"version": "7.0.0",
+			"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+			"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+			"dev": true
+		},
+		"jquery": {
+			"version": "3.7.1",
+			"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
+			"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
+		},
+		"jquery-ui-dist": {
+			"version": "1.13.2",
+			"resolved": "https://registry.npmjs.org/jquery-ui-dist/-/jquery-ui-dist-1.13.2.tgz",
+			"integrity": "sha512-oVDRd1NLtTbBwpRKAYdIRgpWVDzeBhfy7Gu0RmY6JEaZtmBq6kDn1pm5SgDiAotrnDS+RoTRXO6xvcNTxA9tOA==",
+			"requires": {
+				"jquery": ">=1.8.0 <4.0.0"
+			}
+		},
+		"lilconfig": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz",
+			"integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==",
+			"dev": true
+		},
+		"lodash.memoize": {
+			"version": "4.1.2",
+			"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+			"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
+			"dev": true
+		},
+		"lodash.uniq": {
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+			"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+			"dev": true
+		},
+		"luxon": {
+			"version": "3.4.4",
+			"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
+			"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA=="
+		},
+		"mathjax": {
+			"version": "3.2.2",
+			"resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.2.tgz",
+			"integrity": "sha512-Bt+SSVU8eBG27zChVewOicYs7Xsdt40qm4+UpHyX7k0/O9NliPc+x77k1/FEsPsjKPZGJvtRZM1vO+geW0OhGw=="
+		},
+		"mdn-data": {
+			"version": "2.0.30",
+			"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
+			"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
+			"dev": true
+		},
+		"nanoid": {
+			"version": "3.3.8",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+			"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
+			"dev": true
+		},
+		"node-releases": {
+			"version": "2.0.14",
+			"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
+			"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
+			"dev": true
+		},
+		"normalize-path": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+			"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+			"dev": true
+		},
+		"normalize-range": {
+			"version": "0.1.2",
+			"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+			"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+			"dev": true
+		},
+		"nth-check": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+			"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+			"dev": true,
+			"requires": {
+				"boolbase": "^1.0.0"
+			}
+		},
+		"picocolors": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+			"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+			"dev": true
+		},
+		"picomatch": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+			"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+			"dev": true
+		},
+		"postcss": {
+			"version": "8.4.38",
+			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
+			"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
+			"dev": true,
+			"requires": {
+				"nanoid": "^3.3.7",
+				"picocolors": "^1.0.0",
+				"source-map-js": "^1.2.0"
+			}
+		},
+		"postcss-calc": {
+			"version": "9.0.1",
+			"resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz",
+			"integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==",
+			"dev": true,
+			"requires": {
+				"postcss-selector-parser": "^6.0.11",
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-colormin": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz",
+			"integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"caniuse-api": "^3.0.0",
+				"colord": "^2.9.3",
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-convert-values": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz",
+			"integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-discard-comments": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz",
+			"integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==",
+			"dev": true,
+			"requires": {}
+		},
+		"postcss-discard-duplicates": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz",
+			"integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==",
+			"dev": true,
+			"requires": {}
+		},
+		"postcss-discard-empty": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz",
+			"integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==",
+			"dev": true,
+			"requires": {}
+		},
+		"postcss-discard-overridden": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz",
+			"integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==",
+			"dev": true,
+			"requires": {}
+		},
+		"postcss-merge-longhand": {
+			"version": "6.0.5",
+			"resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz",
+			"integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0",
+				"stylehacks": "^6.1.1"
+			}
+		},
+		"postcss-merge-rules": {
+			"version": "6.1.1",
+			"resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.1.tgz",
+			"integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"caniuse-api": "^3.0.0",
+				"cssnano-utils": "^4.0.2",
+				"postcss-selector-parser": "^6.0.16"
+			}
+		},
+		"postcss-minify-font-values": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz",
+			"integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-minify-gradients": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz",
+			"integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==",
+			"dev": true,
+			"requires": {
+				"colord": "^2.9.3",
+				"cssnano-utils": "^4.0.2",
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-minify-params": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz",
+			"integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"cssnano-utils": "^4.0.2",
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-minify-selectors": {
+			"version": "6.0.4",
+			"resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.4.tgz",
+			"integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==",
+			"dev": true,
+			"requires": {
+				"postcss-selector-parser": "^6.0.16"
+			}
+		},
+		"postcss-normalize-charset": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz",
+			"integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==",
+			"dev": true,
+			"requires": {}
+		},
+		"postcss-normalize-display-values": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz",
+			"integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-normalize-positions": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz",
+			"integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-normalize-repeat-style": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz",
+			"integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-normalize-string": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz",
+			"integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-normalize-timing-functions": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz",
+			"integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-normalize-unicode": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz",
+			"integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-normalize-url": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz",
+			"integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-normalize-whitespace": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz",
+			"integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-ordered-values": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz",
+			"integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==",
+			"dev": true,
+			"requires": {
+				"cssnano-utils": "^4.0.2",
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-reduce-initial": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz",
+			"integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"caniuse-api": "^3.0.0"
+			}
+		},
+		"postcss-reduce-transforms": {
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz",
+			"integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0"
+			}
+		},
+		"postcss-selector-parser": {
+			"version": "6.0.16",
+			"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz",
+			"integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==",
+			"dev": true,
+			"requires": {
+				"cssesc": "^3.0.0",
+				"util-deprecate": "^1.0.2"
+			}
+		},
+		"postcss-svgo": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz",
+			"integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==",
+			"dev": true,
+			"requires": {
+				"postcss-value-parser": "^4.2.0",
+				"svgo": "^3.2.0"
+			}
+		},
+		"postcss-unique-selectors": {
+			"version": "6.0.4",
+			"resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.4.tgz",
+			"integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==",
+			"dev": true,
+			"requires": {
+				"postcss-selector-parser": "^6.0.16"
+			}
+		},
+		"postcss-value-parser": {
+			"version": "4.2.0",
+			"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+			"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+			"dev": true
+		},
+		"prettier": {
+			"version": "3.2.5",
+			"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
+			"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
+			"dev": true
+		},
+		"readdirp": {
+			"version": "3.6.0",
+			"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+			"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+			"dev": true,
+			"requires": {
+				"picomatch": "^2.2.1"
+			}
+		},
+		"require-directory": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+			"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+			"dev": true
+		},
+		"rtlcss": {
+			"version": "4.1.1",
+			"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.1.1.tgz",
+			"integrity": "sha512-/oVHgBtnPNcggP2aVXQjSy6N1mMAfHg4GSag0QtZBlD5bdDgAHwr4pydqJGd+SUCu9260+Pjqbjwtvu7EMH1KQ==",
+			"dev": true,
+			"requires": {
+				"escalade": "^3.1.1",
+				"picocolors": "^1.0.0",
+				"postcss": "^8.4.21",
+				"strip-json-comments": "^3.1.1"
+			}
+		},
+		"sass": {
+			"version": "1.75.0",
+			"resolved": "https://registry.npmjs.org/sass/-/sass-1.75.0.tgz",
+			"integrity": "sha512-ShMYi3WkrDWxExyxSZPst4/okE9ts46xZmJDSawJQrnte7M1V9fScVB+uNXOVKRBt0PggHOwoZcn8mYX4trnBw==",
+			"dev": true,
+			"requires": {
+				"chokidar": ">=3.0.0 <4.0.0",
+				"immutable": "^4.0.0",
+				"source-map-js": ">=0.6.2 <2.0.0"
+			}
+		},
+		"shortcut-buttons-flatpickr": {
+			"version": "0.4.0",
+			"resolved": "https://registry.npmjs.org/shortcut-buttons-flatpickr/-/shortcut-buttons-flatpickr-0.4.0.tgz",
+			"integrity": "sha512-JKmT4my3Hm1e18OvG4Q6RcFhN4WRqqpTMkHrvZ7fup/dp6aTIWGVCHdRYtASkp/FCzDlJh6iCLQ/VcwwNpAMoQ=="
+		},
+		"sortablejs": {
+			"version": "1.15.2",
+			"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
+			"integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
+		},
+		"source-map": {
+			"version": "0.6.1",
+			"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+			"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+			"dev": true
+		},
+		"source-map-js": {
+			"version": "1.2.0",
+			"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+			"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+			"dev": true
+		},
+		"source-map-support": {
+			"version": "0.5.21",
+			"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+			"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+			"dev": true,
+			"requires": {
+				"buffer-from": "^1.0.0",
+				"source-map": "^0.6.0"
+			}
+		},
+		"string-width": {
+			"version": "4.2.3",
+			"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+			"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+			"dev": true,
+			"requires": {
+				"emoji-regex": "^8.0.0",
+				"is-fullwidth-code-point": "^3.0.0",
+				"strip-ansi": "^6.0.1"
+			}
+		},
+		"strip-ansi": {
+			"version": "6.0.1",
+			"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+			"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+			"dev": true,
+			"requires": {
+				"ansi-regex": "^5.0.1"
+			}
+		},
+		"strip-json-comments": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+			"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+			"dev": true
+		},
+		"style-mod": {
+			"version": "4.1.2",
+			"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
+			"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="
+		},
+		"stylehacks": {
+			"version": "6.1.1",
+			"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
+			"integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==",
+			"dev": true,
+			"requires": {
+				"browserslist": "^4.23.0",
+				"postcss-selector-parser": "^6.0.16"
+			}
+		},
+		"svgo": {
+			"version": "3.2.0",
+			"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz",
+			"integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==",
+			"dev": true,
+			"requires": {
+				"@trysound/sax": "0.2.0",
+				"commander": "^7.2.0",
+				"css-select": "^5.1.0",
+				"css-tree": "^2.3.1",
+				"css-what": "^6.1.0",
+				"csso": "^5.0.5",
+				"picocolors": "^1.0.0"
+			}
+		},
+		"terser": {
+			"version": "5.30.4",
+			"resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz",
+			"integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==",
+			"dev": true,
+			"requires": {
+				"@jridgewell/source-map": "^0.3.3",
+				"acorn": "^8.8.2",
+				"commander": "^2.20.0",
+				"source-map-support": "~0.5.20"
+			},
+			"dependencies": {
+				"commander": {
+					"version": "2.20.3",
+					"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+					"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+					"dev": true
+				}
+			}
+		},
+		"thememirror": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/thememirror/-/thememirror-2.0.1.tgz",
+			"integrity": "sha512-d5i6FVvWWPkwrm4cHLI3t9AT1OrkAt7Ig8dtdYSofgF7C/eiyNuq6zQzSTusWTde3jpW9WLvA9J/fzNKMUsd0w==",
+			"requires": {}
+		},
+		"to-regex-range": {
+			"version": "5.0.1",
+			"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+			"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+			"dev": true,
+			"requires": {
+				"is-number": "^7.0.0"
+			}
+		},
+		"update-browserslist-db": {
+			"version": "1.0.13",
+			"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
+			"integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
+			"dev": true,
+			"requires": {
+				"escalade": "^3.1.1",
+				"picocolors": "^1.0.0"
+			}
+		},
+		"util-deprecate": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+			"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+			"dev": true
+		},
+		"w3c-keyname": {
+			"version": "2.2.8",
+			"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+			"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
+		},
+		"wrap-ansi": {
+			"version": "7.0.0",
+			"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+			"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+			"dev": true,
+			"requires": {
+				"ansi-styles": "^4.0.0",
+				"string-width": "^4.1.0",
+				"strip-ansi": "^6.0.0"
+			}
+		},
+		"y18n": {
+			"version": "5.0.8",
+			"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+			"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+			"dev": true
+		},
+		"yargs": {
+			"version": "17.7.2",
+			"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+			"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+			"dev": true,
+			"requires": {
+				"cliui": "^8.0.1",
+				"escalade": "^3.1.1",
+				"get-caller-file": "^2.0.5",
+				"require-directory": "^2.1.1",
+				"string-width": "^4.2.3",
+				"y18n": "^5.0.5",
+				"yargs-parser": "^21.1.1"
+			}
+		},
+		"yargs-parser": {
+			"version": "21.1.1",
+			"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+			"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+			"dev": true
+		}
+	}
 }
diff --git a/htdocs/package.json b/htdocs/package.json
index ddbb13e751..fb477d5362 100644
--- a/htdocs/package.json
+++ b/htdocs/package.json
@@ -1,50 +1,50 @@
 {
-    "name": "webwork.javascript_package_manager",
-    "description": "Third party javascript for WeBWorK",
-    "license": "GPL-2.0+",
-    "scripts": {
-        "generate-assets": "node generate-assets",
-        "prepare": "npm run generate-assets",
-        "prettier-format": "prettier --ignore-path=../.gitignore --write \"**/*.{js,css,scss,html}\" \"../**/*.dist.yml\"",
-        "prettier-check": "prettier --ignore-path=../.gitignore --check \"**/*.{js,css,scss,html}\" \"../**/*.dist.yml\""
-    },
-    "repository": {
-        "type": "git",
-        "url": "https://github.com/openwebwork/webwork2"
-    },
-    "dependencies": {
-        "@fortawesome/fontawesome-free": "^6.5.2",
-        "bootstrap": "~5.3.3",
-        "codemirror": "^5.65.15",
-        "flatpickr": "^4.6.13",
-        "iframe-resizer": "^4.3.11",
-        "jquery": "^3.7.1",
-        "jquery-ui-dist": "^1.13.2",
-        "luxon": "^3.4.4",
-        "mathjax": "^3.2.2",
-        "shortcut-buttons-flatpickr": "^0.4.0",
-        "sortablejs": "^1.15.2"
-    },
-    "devDependencies": {
-        "autoprefixer": "^10.4.19",
-        "chokidar": "^3.6.0",
-        "cssnano": "^6.1.2",
-        "postcss": "^8.4.38",
-        "prettier": "^3.2.5",
-        "rtlcss": "^4.1.1",
-        "sass": "^1.75.0",
-        "terser": "^5.30.4",
-        "yargs": "^17.7.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"
-    ]
+	"name": "webwork.javascript_package_manager",
+	"description": "Third party javascript for WeBWorK",
+	"license": "GPL-2.0+",
+	"scripts": {
+		"generate-assets": "node generate-assets",
+		"prepare": "npm run generate-assets",
+		"prettier-format": "prettier --ignore-path=../.gitignore --write \"**/*.{js,css,scss,html}\" \"../**/*.dist.yml\"",
+		"prettier-check": "prettier --ignore-path=../.gitignore --check \"**/*.{js,css,scss,html}\" \"../**/*.dist.yml\""
+	},
+	"repository": {
+		"type": "git",
+		"url": "https://github.com/openwebwork/webwork2"
+	},
+	"dependencies": {
+		"@fortawesome/fontawesome-free": "^6.5.2",
+		"@openwebwork/pg-codemirror-editor": "^0.0.4",
+		"bootstrap": "~5.3.3",
+		"flatpickr": "^4.6.13",
+		"iframe-resizer": "^4.3.11",
+		"jquery": "^3.7.1",
+		"jquery-ui-dist": "^1.13.2",
+		"luxon": "^3.4.4",
+		"mathjax": "^3.2.2",
+		"shortcut-buttons-flatpickr": "^0.4.0",
+		"sortablejs": "^1.15.2"
+	},
+	"devDependencies": {
+		"autoprefixer": "^10.4.19",
+		"chokidar": "^3.6.0",
+		"cssnano": "^6.1.2",
+		"postcss": "^8.4.38",
+		"prettier": "^3.2.5",
+		"rtlcss": "^4.1.1",
+		"sass": "^1.75.0",
+		"terser": "^5.30.4",
+		"yargs": "^17.7.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/htdocs/themes/math4-green/_theme-colors.scss b/htdocs/themes/math4-green/_theme-colors.scss
index f577945b9f..89fa61322f 100644
--- a/htdocs/themes/math4-green/_theme-colors.scss
+++ b/htdocs/themes/math4-green/_theme-colors.scss
@@ -1,17 +1,3 @@
-/* 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 bootstrap scss functions (so you can manipulate colors, SVGs, calc, etc)
 @import '../../node_modules/bootstrap/scss/functions';
 
diff --git a/htdocs/themes/math4-red/_theme-colors.scss b/htdocs/themes/math4-red/_theme-colors.scss
index e1f057edb6..b34e6f2682 100644
--- a/htdocs/themes/math4-red/_theme-colors.scss
+++ b/htdocs/themes/math4-red/_theme-colors.scss
@@ -1,17 +1,3 @@
-/* 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 bootstrap scss functions (so you can manipulate colors, SVGs, calc, etc)
 @import '../../node_modules/bootstrap/scss/functions';
 
diff --git a/htdocs/themes/math4-yellow/_theme-colors.scss b/htdocs/themes/math4-yellow/_theme-colors.scss
index b718e81b27..cbc8f078bb 100644
--- a/htdocs/themes/math4-yellow/_theme-colors.scss
+++ b/htdocs/themes/math4-yellow/_theme-colors.scss
@@ -1,17 +1,3 @@
-/* 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 bootstrap scss functions (so you can manipulate colors, SVGs, calc, etc)
 @import '../../node_modules/bootstrap/scss/functions';
 
diff --git a/htdocs/themes/math4/_theme-colors.scss b/htdocs/themes/math4/_theme-colors.scss
index ad5d04eef1..bd57046fb7 100644
--- a/htdocs/themes/math4/_theme-colors.scss
+++ b/htdocs/themes/math4/_theme-colors.scss
@@ -1,17 +1,3 @@
-/* WeBWorK Online Homework Delivery System
-* Copyright © 2000-2024 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 bootstrap scss functions (so you can manipulate colors, SVGs, calc, etc)
 @import '../../node_modules/bootstrap/scss/functions';
 
diff --git a/htdocs/themes/math4/bootstrap.scss b/htdocs/themes/math4/bootstrap.scss
index 4e94098660..72cbc9cbd0 100644
--- a/htdocs/themes/math4/bootstrap.scss
+++ b/htdocs/themes/math4/bootstrap.scss
@@ -1,17 +1,3 @@
-/* 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';
 
diff --git a/lib/Caliper/Entity.pm b/lib/Caliper/Entity.pm
index 13d9561a79..78883b765e 100644
--- a/lib/Caliper/Entity.pm
+++ b/lib/Caliper/Entity.pm
@@ -372,7 +372,7 @@ sub problem_set_attempt {
 	my $extensions = { 'attempt_score' => $score, };
 
 	if ($version_id) {
-		$extensions->{'gateway_score'} = grade_gateway($db, $problem_set_user, $problem_set_user->set_id, $user_id);
+		$extensions->{'gateway_score'} = grade_gateway($db, $problem_set_user->set_id, $user_id);
 	}
 
 	my $problem_set_attempt = {
diff --git a/lib/Caliper/Sensor.pm b/lib/Caliper/Sensor.pm
index 84c3b80cda..0cee244c4d 100644
--- a/lib/Caliper/Sensor.pm
+++ b/lib/Caliper/Sensor.pm
@@ -7,7 +7,7 @@ use WeBWorK::CourseEnvironment;
 use WeBWorK::DB;
 use WeBWorK::Debug;
 use Data::Dumper;
-use JSON;
+use Mojo::JSON  qw(encode_json);
 use Time::HiRes qw/gettimeofday/;
 use Date::Format;
 
@@ -67,7 +67,7 @@ sub sendEvents {
 			'data'        => $event_chunk,
 		};
 
-		my $json_payload = JSON->new->canonical->encode($envelope);
+		my $json_payload = encode_json($envelope);
 		# debug("Caliper event json_payload: " . $json_payload);
 
 		my $HTTPRequest = HTTP::Request->new(
diff --git a/lib/FormatRenderedProblem.pm b/lib/FormatRenderedProblem.pm
index c681e25249..1c7f0194f3 100644
--- a/lib/FormatRenderedProblem.pm
+++ b/lib/FormatRenderedProblem.pm
@@ -1,17 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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
 
@@ -24,12 +10,12 @@ package FormatRenderedProblem;
 use strict;
 use warnings;
 
-use JSON;
 use Digest::SHA qw(sha1_base64);
-use Mojo::Util qw(xml_escape);
+use Mojo::Util  qw(xml_escape);
+use Mojo::JSON  qw(encode_json);
 use Mojo::DOM;
 
-use WeBWorK::Utils qw(getAssetURL);
+use WeBWorK::Utils                       qw(getAssetURL);
 use WeBWorK::Utils::LanguageAndDirection qw(get_lang_and_dir get_problem_lang_and_dir);
 
 sub formatRenderedProblem {
@@ -187,8 +173,8 @@ sub formatRenderedProblem {
 			if $ws->c->current_route eq 'render_rpc' && ($ws->c->param('displayMode') // '') eq 'PTX';
 	}
 
-	# 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 eq 'ARRAY';
+	# Make sure $rh_result->{debug_messages} an array reference as saveGradeToLTI might add to it.
+	$rh_result->{debug_messages} = [] unless ref $rh_result->{debug_messages} eq 'ARRAY';
 
 	$forbidGradePassback = 1 if !$forbidGradePassback && !$submitMode;
 
@@ -227,7 +213,7 @@ sub formatRenderedProblem {
 		$output->{pg_version} = $ce->{PG_VERSION};
 
 		# Convert to JSON and render.
-		return $ws->c->render(data => JSON->new->utf8(1)->encode($output));
+		return $ws->c->render(data => encode_json($output));
 	}
 
 	# Setup arnd render the appropriate template in the templates/RPCRenderFormats folder depending on the outputformat.
@@ -280,6 +266,7 @@ sub formatRenderedProblem {
 		showCorrectAnswersButton     => $ws->{inputs_ref}{showCorrectAnswersButton}     // '',
 		showCorrectAnswersOnlyButton => $ws->{inputs_ref}{showCorrectAnswersOnlyButton} // 0,
 		showFooter                   => $ws->{inputs_ref}{showFooter}                   // '',
+		problem_data                 => encode_json($rh_result->{PERSISTENCE_HASH}),
 		pretty_print                 => \&pretty_print
 	);
 
@@ -429,7 +416,6 @@ sub pretty_print {
 			# 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")
diff --git a/lib/HardcopyRenderedProblem.pm b/lib/HardcopyRenderedProblem.pm
index 76380647d0..8786cd033d 100644
--- a/lib/HardcopyRenderedProblem.pm
+++ b/lib/HardcopyRenderedProblem.pm
@@ -1,17 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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
 
@@ -29,7 +15,7 @@ use warnings;
 use File::Path;
 use String::ShellQuote;
 use Archive::Zip qw(:ERROR_CODES);
-use Mojo::File qw(path tempdir);
+use Mojo::File   qw(path tempdir);
 use XML::LibXML;
 
 sub hardcopyRenderedProblem {
@@ -265,8 +251,7 @@ sub write_problem_tex {
 				$correctTeX .=
 					"\\item\n\$\\displaystyle "
 					. ($rh_result->{answers}{$_}{correct_ans_latex_string}
-						|| "\\text{$rh_result->{answers}{$_}{correct_ans}}")
-					. "\$\n";
+						|| "\\text{$rh_result->{answers}{$_}{correct_ans}}") . "\$\n";
 			}
 
 			$correctTeX .= "\\end{itemize}}\\par\n";
diff --git a/lib/Mojolicious/WeBWorK.pm b/lib/Mojolicious/WeBWorK.pm
index 0e6eeeb016..c9315547c4 100644
--- a/lib/Mojolicious/WeBWorK.pm
+++ b/lib/Mojolicious/WeBWorK.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package Mojolicious::WeBWorK;
 use Mojo::Base 'Mojolicious', -signatures, -async_await;
 
@@ -28,9 +13,9 @@ use Mojo::JSON qw(encode_json);
 
 use WeBWorK;
 use WeBWorK::CourseEnvironment;
-use WeBWorK::Utils::Logs qw(writeTimingLogEntry);
+use WeBWorK::Utils::Logs   qw(writeTimingLogEntry);
 use WeBWorK::Utils::Routes qw(setup_content_generator_routes);
-use WeBWorK::Utils::Files qw(path_is_subdir);
+use WeBWorK::Utils::Files  qw(path_is_subdir);
 
 sub startup ($app) {
 	# Set up logging.
@@ -171,15 +156,14 @@ sub startup ($app) {
 
 	$app->hook(
 		after_dispatch => sub ($c) {
-			$SIG{__WARN__} = $c->stash->{orig_sig_warn} if defined $c->stash->{orig_sig_warn};
+			$SIG{__WARN__} = ref($c->stash->{orig_sig_warn}) eq 'CODE' ? $c->stash->{orig_sig_warn} : 'DEFAULT';
 
 			if ($c->isa('WeBWorK::ContentGenerator') && $c->ce) {
 				$c->authen->store_session if $c->authen;
 				writeTimingLogEntry(
 					$c->ce,
 					'[' . $c->url_for . ']',
-					sprintf('runTime = %.3f sec', $c->timing->elapsed('content_generator_rendering')) . ' '
-						. $c->ce->{dbLayoutName}
+					sprintf('runTime = %.3f sec', $c->timing->elapsed('content_generator_rendering'))
 				);
 			}
 		}
diff --git a/lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm b/lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm
index f4975d65aa..00640afa51 100644
--- a/lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm
+++ b/lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm
@@ -1,18 +1,3 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package Mojolicious::WeBWorK::Tasks::AchievementNotification;
 use Mojo::Base 'Minion::Job', -signatures;
 
@@ -39,7 +24,7 @@ sub run ($job, $mail_data) {
 
 	$job->{language_handle} = WeBWorK::Localize::getLoc($ce->{language} || 'en');
 
-	my $db = WeBWorK::DB->new($ce->{dbLayout});
+	my $db = WeBWorK::DB->new($ce);
 	return $job->fail($job->maketext('Could not obtain database connection for [_1].', $courseID))
 		unless $db;
 
@@ -71,7 +56,7 @@ sub send_achievement_notification ($job, $ce, $db, $mail_data) {
 	$compartment->share_from('main',
 		[qw(%Encode:: %Mojo::Base:: %Mojo::Exception:: %Mojo::Template:: %WeBWorK::SafeTemplate::)]);
 
-	# Since the WeBWorK::SafeTemplate module can not add "no warnings 'ambiguous'", those warnings must be prevented
+	# Since the WeBWorK::SafeTemplate module cannot add "no warnings 'ambiguous'", those warnings must be prevented
 	# with the following $SIG{__WARN__} handler.
 	local $SIG{__WARN__} = sub {
 		my $warning = shift;
diff --git a/lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm b/lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm
index 83d3001a9e..e0d9e5b866 100644
--- a/lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm
+++ b/lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm
@@ -1,18 +1,3 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package Mojolicious::WeBWorK::Tasks::LTIMassUpdate;
 use Mojo::Base 'Minion::Job', -signatures;
 
@@ -36,7 +21,7 @@ sub run ($job, $userID = '', $setID = '') {
 
 	$job->{language_handle} = WeBWorK::Localize::getLoc($ce->{language} || 'en');
 
-	my $db = WeBWorK::DB->new($ce->{dbLayout});
+	my $db = WeBWorK::DB->new($ce);
 	return $job->fail($job->maketext('Could not obtain database connection.')) unless $db;
 
 	my @messages;
diff --git a/lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm b/lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm
index 3f9c177e19..388d2338ff 100644
--- a/lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm
+++ b/lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm
@@ -1,18 +1,3 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package Mojolicious::WeBWorK::Tasks::SendInstructorEmail;
 use Mojo::Base 'Minion::Job', -signatures;
 
@@ -36,7 +21,7 @@ sub run ($job, $mail_data) {
 
 	$job->{language_handle} = WeBWorK::Localize::getLoc($ce->{language} || 'en');
 
-	my $db = WeBWorK::DB->new($ce->{dbLayout});
+	my $db = WeBWorK::DB->new($ce);
 	return $job->fail($job->maketext('Could not obtain database connection.')) unless $db;
 
 	my @result_messages = eval { $job->mail_message_to_recipients($ce, $db, $mail_data) };
diff --git a/lib/WeBWorK.pm b/lib/WeBWorK.pm
index 0ac5eeba66..a80dd817ce 100644
--- a/lib/WeBWorK.pm
+++ b/lib/WeBWorK.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK;
 use Mojo::Base -signatures, -async_await;
 
@@ -62,28 +47,19 @@ async sub dispatch ($c) {
 	# Note that this is Time::HiRes's time, which gives floating point values.
 	$c->submitTime(time);
 
-	my $method   = $c->req->method;
-	my $location = $c->location;
-	my $uri      = $c->url_for;
-	my $args     = $c->req->params->to_string || '';
+	my $method = $c->req->method;
+	my $uri    = $c->url_for;
+	my $args   = $c->req->params->to_string || '';
 
 	debug("\n\n===> Begin " . __PACKAGE__ . "::dispatch() <===\n\n");
-	debug("Hi, I'm the new dispatcher!\n");
 	debug(("-" x 80) . "\n");
 
-	debug("Okay, I got some basic information:\n");
-	debug("The site location is $location\n");
 	debug("The request method is $method\n");
 	debug("The URI is $uri\n");
 	debug("The argument string is $args\n");
 	debug(('-' x 80) . "\n");
 
-	my ($path) = $uri =~ m/$location(.*)/;
-	$path .= '/' if $path !~ m(/$);
-	debug("The path is $path\n");
-
 	debug("The current route is " . $c->current_route . "\n");
-	debug("Here is some information about this route:\n");
 
 	my $displayModule = ref $c;
 	my %routeCaptures = %{ $c->stash->{'mojo.captures'} };
@@ -96,8 +72,6 @@ async sub dispatch ($c) {
 
 	debug(('-' x 80) . "\n");
 
-	debug("Now we want to look at the parameters we got.\n");
-
 	debug("The raw params:\n");
 	for my $key ($c->param) {
 		# Make it so we dont debug plain text passwords
@@ -122,7 +96,6 @@ async sub dispatch ($c) {
 	$c->initializeRoute(\%routeCaptures) if $c->can('initializeRoute');
 
 	# Create Course Environment
-	debug("We need to get a course environment (with or without a courseID!)\n");
 	my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $routeCaptures{courseID} }) };
 	$@ and die "Failed to initialize course environment: $@\n";
 	debug("Here's the course environment: $ce\n");
@@ -134,17 +107,17 @@ async sub dispatch ($c) {
 
 	my @uploads = @{ $c->req->uploads };
 
-	foreach my $u (@uploads) {
+	for my $u (@uploads) {
 		# Make sure it's a "real" upload.
 		next unless $u->filename;
 
 		# Store the upload.
-		my $upload = WeBWorK::Upload->store($u, dir => $ce->{webworkDirs}{uploadCache});
+		my $upload = WeBWorK::Upload->store($u, $ce->{webworkDirs}{uploadCache});
 
-		# Store the upload ID and hash in the file upload field.
-		my $id   = $upload->id;
-		my $hash = $upload->hash;
-		$c->param($u->name => "$id $hash");
+		# Store the upload temporary file location and hash in the file upload field.
+		my $tmpFile = $upload->tmpFile;
+		my $hash    = $upload->hash;
+		$c->param($u->name => "$tmpFile $hash");
 	}
 
 	# Create these out here. They should fail if they don't have the right information.
@@ -164,13 +137,15 @@ async sub dispatch ($c) {
 	if ($routeCaptures{courseID}) {
 		debug("We got a courseID from the route, now we can do some stuff:\n");
 
+		# This route could have the courseID set, but does not need authentication.
+		return 1 if $c->current_route eq 'saml2_metadata';
+
 		return (0, 'This course does not exist.')
 			unless (-e $ce->{courseDirs}{root}
 				|| -e "$ce->{webwork_courses_dir}/$ce->{admin_course_id}/archives/$routeCaptures{courseID}.tar.gz");
 		return (0, 'This course has been archived and closed.') unless -e $ce->{courseDirs}{root};
 
-		debug("...we can create a database object...\n");
-		my $db = WeBWorK::DB->new($ce->{dbLayout});
+		my $db = WeBWorK::DB->new($ce);
 		debug("(here's the DB handle: $db)\n");
 		$c->db($db);
 
@@ -227,6 +202,7 @@ async sub dispatch ($c) {
 				# current server time during a gateway quiz, and that definitely should not revoke proctor
 				# authorization.
 				delete $c->authen->session->{proctor_authorization_granted};
+				delete $c->authen->session->{acting_proctor};
 			}
 			return 1;
 		} else {
diff --git a/lib/WeBWorK/AchievementEvaluator.pm b/lib/WeBWorK/AchievementEvaluator.pm
index fc21a716ce..249d34ea22 100644
--- a/lib/WeBWorK/AchievementEvaluator.pm
+++ b/lib/WeBWorK/AchievementEvaluator.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementEvaluator;
 use Mojo::Base 'Exporter', -signatures;
 
@@ -24,7 +9,7 @@ use Mojo::Base 'Exporter', -signatures;
 
 use DateTime;
 
-use WeBWorK::Utils qw(sortAchievements nfreeze_base64 thaw_base64);
+use WeBWorK::Utils                    qw(sortAchievements nfreeze_base64 thaw_base64);
 use WeBWorK::Utils::ProblemProcessing qw(compute_unreduced_score);
 use WeBWorK::Utils::Tags;
 use WeBWorK::WWSafe;
diff --git a/lib/WeBWorK/AchievementItems.pm b/lib/WeBWorK/AchievementItems.pm
index 8081c7bd6e..f54d407e67 100644
--- a/lib/WeBWorK/AchievementItems.pm
+++ b/lib/WeBWorK/AchievementItems.pm
@@ -1,22 +1,7 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems;
 use Mojo::Base -signatures;
 
-use WeBWorK::Utils qw(thaw_base64);
+use WeBWorK::Utils qw(nfreeze_base64 thaw_base64);
 
 # List of available achievement items.  Make sure to add any new items to this list. Furthermore, the elements in this
 # list have to match the class name of the achievement item classes loaded below.
@@ -27,11 +12,14 @@ use constant ITEMS => [ qw(
 	HalfCreditProb
 	FullCreditProb
 	ReducedCred
+	NoReducedCred
 	ExtendDueDate
+	ExtendReducedDate
 	DoubleSet
 	ResurrectHW
 	Surprise
 	SuperExtendDueDate
+	SuperExtendReducedDate
 	HalfCreditSet
 	FullCreditSet
 	AddNewTestGW
@@ -41,65 +29,134 @@ use constant ITEMS => [ qw(
 
 =head2 NAME
 
-This is the base class for achievement times.  This defines an interface for all of the achievement items.  Each
-achievement item will have a name, a description, a method for creating an html form to get its inputs called print_form
-and a method for applying those inputs called use_item.
+This is the base class for achievement times.  This defines an interface for all of the achievement items.
+Each achievement item will have an id, a name, a description, and the three methods can_use (checks if the
+item can be used on the given set), print_form (prints the form to use the item), and use_item.
 
 Note: the ID has to match the name of the class.
 
+The global method UserItems returns an array of all achievement items available to the given user.  If no
+set is included, a list of all earned achievement items is return. If provided a set and corresponding problem
+or test version records, a list of items usable on the current set and records paired with an input form to
+use the item is returned. This method will also process any posts to use the achievement item.
+
 =cut
 
-sub id          ($c) { return $c->{id}; }
-sub name        ($c) { return $c->{name}; }
-sub description ($c) { return $c->{description}; }
+sub id          ($self) { return $self->{id}; }
+sub name        ($self) { return $self->{name}; }
+sub count       ($self) { return $self->{count}; }
+sub description ($self) { return $self->{description}; }
+
+# Method to find all achievement items available to the given user.
+# If $set is undefined return an array reference of all earned items.
+# If $set is defined, return an array reference of the usable items
+# for the given $set and problem or test versions records. Each item
+# is paired with its input form to use the item.
+sub UserItems ($c, $userName, $set, $records) {
+	my $db = $c->db;
+	my $ce = $c->ce;
 
-# This is a global method that returns all of the provided users items.
-sub UserItems ($userName, $db, $ce) {
-	# return unless the user has global achievement data
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
+	# Return unless achievement items are enabled.
+	return unless $ce->{achievementsEnabled} && $ce->{achievementItemsEnabled};
 
-	return unless ($globalUserAchievement->frozen_hash);
+	# When acting as another user, achievement items can be listed but not used.
+	return if $set && $userName ne $c->param('user');
 
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
+	# Return unless the user has global achievement data.
+	my $globalUserAchievement = $c->{globalData} // $db->getGlobalUserAchievement($userName);
+	return unless $globalUserAchievement && $globalUserAchievement->frozen_hash;
+
+	my $globalData  = thaw_base64($globalUserAchievement->frozen_hash);
+	my $use_item_id = $c->param('use_achievement_item_id') // '';
 	my @items;
 
-	# Get a new item object for each type of item.
 	for my $item (@{ +ITEMS }) {
-		push(@items, [ "WeBWorK::AchievementItems::$item"->new, $globalData->{$item} ])
-			if ($globalData->{$item});
+		next unless $globalData->{$item};
+		my $achievementItem = "WeBWorK::AchievementItems::$item"->new;
+		$achievementItem->{count} = $globalData->{$item};
+
+		# Return list of achievements items if $set is not defined.
+		unless ($set) {
+			push(@items, $achievementItem);
+			next;
+		}
+		next unless $achievementItem->can_use($set, $records);
+
+		# Use the achievement item.
+		if ($use_item_id eq $item) {
+			my $message = $achievementItem->use_item($set, $records, $c);
+			if ($message) {
+				$globalData->{$item}--;
+				$achievementItem->{count}--;
+				$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
+				$db->putGlobalUserAchievement($globalUserAchievement);
+				$c->addgoodmessage($c->maketext('[_1] successfuly used. [_2]', $achievementItem->name, $message));
+			}
+		}
+
+		push(@items, [ $achievementItem, $use_item_id ? '' : $achievementItem->print_form($set, $records, $c) ]);
 	}
 
+	# If an achievement item has been used, double check if the achievement items can still be used
+	# since the item count could now be zero or an achievement item has altered the set/records.
+	# Input forms are also built here to account for any possible change.
+	if ($set && $use_item_id) {
+		my @new_items;
+		for (@items) {
+			my $item = $_->[0];
+			next unless $item->{count} && $item->can_use($set, $records);
+			push(@new_items, [ $item, $item->print_form($set, $records, $c) ]);
+		}
+		return \@new_items;
+	}
 	return \@items;
 }
 
+# Method that returns a string with the achievement name and number of remaining items.
+# This should only be called if count != 0.
+sub remaining_title ($self, $c) {
+	if ($self->count > 0) {
+		return $c->maketext('[_1] ([_2] remaining)', $c->maketext($self->name), $self->count);
+	} else {
+		return $c->maketext('[_1] (unlimited reusability)', $c->maketext($self->name));
+	}
+}
+
 # Utility method for outputing a form row with a label and popup menu.
 # The id, label_text, and values are required parameters.
 sub form_popup_menu_row ($c, %options) {
 	my %params = (
-		id                  => '',
-		label_text          => '',
-		label_attr          => {},
-		values              => [],
-		menu_attr           => {},
-		menu_container_attr => {},
-		add_container       => 1,
+		id            => '',
+		first_item    => '',
+		label_text    => '',
+		label_attr    => {},
+		values        => [],
+		menu_attr     => {},
+		add_container => 1,
 		%options
 	);
 
-	$params{label_attr}{class}          //= 'col-4 col-form-label';
-	$params{menu_attr}{class}           //= 'form-select';
-	$params{menu_container_attr}{class} //= 'col-8';
-
-	my $row_contents = $c->c(
-		$c->label_for($params{id} => $params{label_text}, %{ $params{label_attr} }),
-		$c->tag(
-			'div',
-			%{ $params{menu_container_attr} },
-			$c->select_field($params{id} => $params{values}, id => $params{id}, %{ $params{menu_attr} })
-		)
-	)->join('');
+	$params{label_attr}{class} //= 'col-form-label';
+	$params{menu_attr}{class}  //= 'form-select';
+
+	unshift(@{ $params{values} }, [ $params{first_item} => '', disabled => undef, selected => undef ])
+		if $params{first_item};
+
+	my $row_contents = $c->tag(
+		'div',
+		class => 'form-floating',
+		$c->c(
+			$c->select_field(
+				$params{id} => $params{values},
+				id          => $params{id},
+				required    => undef,
+				%{ $params{menu_attr} }
+			),
+			$c->label_for($params{id} => $params{label_text}, %{ $params{label_attr} })
+		)->join('')
+	);
 
-	return $params{add_container} ? $c->tag('div', class => 'row mb-3', $row_contents) : $row_contents;
+	return $params{add_container} ? $c->tag('div', class => 'my-3', $row_contents) : $row_contents;
 }
 
 END {
@@ -110,15 +167,18 @@ END {
 	use WeBWorK::AchievementItems::DuplicateProb;
 	use WeBWorK::AchievementItems::ExtendDueDateGW;
 	use WeBWorK::AchievementItems::ExtendDueDate;
+	use WeBWorK::AchievementItems::ExtendReducedDate;
 	use WeBWorK::AchievementItems::FullCreditProb;
 	use WeBWorK::AchievementItems::FullCreditSet;
 	use WeBWorK::AchievementItems::HalfCreditProb;
 	use WeBWorK::AchievementItems::HalfCreditSet;
 	use WeBWorK::AchievementItems::ReducedCred;
+	use WeBWorK::AchievementItems::NoReducedCred;
 	use WeBWorK::AchievementItems::ResetIncorrectAttempts;
 	use WeBWorK::AchievementItems::ResurrectGW;
 	use WeBWorK::AchievementItems::ResurrectHW;
 	use WeBWorK::AchievementItems::SuperExtendDueDate;
+	use WeBWorK::AchievementItems::SuperExtendReducedDate;
 	use WeBWorK::AchievementItems::Surprise;
 }
 
diff --git a/lib/WeBWorK/AchievementItems/AddNewTestGW.pm b/lib/WeBWorK/AchievementItems/AddNewTestGW.pm
index 6674cb17f6..9eadafbe69 100644
--- a/lib/WeBWorK/AchievementItems/AddNewTestGW.pm
+++ b/lib/WeBWorK/AchievementItems/AddNewTestGW.pm
@@ -1,26 +1,10 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::AddNewTestGW;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to allow students to take an additional version of a test within its test version interval
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
-use WeBWorK::Utils::DateTime qw(before between);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
+use WeBWorK::Utils           qw(x);
+use WeBWorK::Utils::DateTime qw(between);
 
 sub new ($class) {
 	return bless {
@@ -33,60 +17,34 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my $db = $c->db;
-
-	my @openGateways;
-
-	# Find the template sets of open gateway quizzes.
-	for my $set (@$sets) {
-		push(@openGateways, [ format_set_name_display($set->set_id) => $set->set_id ])
-			if $set->assignment_type =~ /gateway/
-			&& $set->set_id !~ /,v\d+$/
-			&& between($set->open_date, $set->due_date);
-	}
-
-	return unless @openGateways;
+sub can_use ($self, $set, $records) {
+	return
+		$set->assignment_type =~ /gateway/
+		&& $set->set_id !~ /,v\d+$/
+		&& between($set->open_date, $set->due_date)
+		&& $set->versions_per_interval > 0;
+}
 
-	return $c->c(
-		$c->tag('p', $c->maketext('Add a new version for which test?')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'adtgw_gw_id',
-			label_text => $c->maketext('Test Name'),
-			values     => \@openGateways,
-			menu_attr  => { dir => 'ltr' }
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Increase the number of versions from [_1] to [_2] for this test.',
+			$set->versions_per_interval,
+			$set->versions_per_interval + 1
 		)
-	)->join('');
+	);
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('adtgw_gw_id');
-	return 'You need to input a Test Name' unless defined $setID;
-
-	my $set     = $db->getMergedSet($userName, $setID);
-	my $userSet = $db->getUserSet($userName, $setID);
-	return q{Couldn't find that set!} unless $set && $userSet;
-
-	# Add an additional version per interval to the set.
-	$userSet->versions_per_interval($set->versions_per_interval + 1) unless $set->versions_per_interval == 0;
+sub use_item ($self, $set, $records, $c) {
+	# Increase the number of versions per interval by 1.
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
+	$set->versions_per_interval($set->versions_per_interval + 1);
+	$userSet->versions_per_interval($set->versions_per_interval);
 	$db->putUserSet($userSet);
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	return $c->maketext('One additional test version added to this test.');
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/DoubleProb.pm b/lib/WeBWorK/AchievementItems/DoubleProb.pm
index 57f6e3b948..15ca7dc072 100644
--- a/lib/WeBWorK/AchievementItems/DoubleProb.pm
+++ b/lib/WeBWorK/AchievementItems/DoubleProb.pm
@@ -1,28 +1,10 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::DoubleProb;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to make a problem worth double.
 
-use Mojo::JSON qw(encode_json);
-
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x);
 use WeBWorK::Utils::DateTime qw(after);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
 
 sub new ($class) {
 	return bless {
@@ -32,84 +14,48 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	# Construct a dropdown with open sets and another with problems.
-	# Javascript ensures the appropriate problems are shown for the selected set.
-
-	my (@openSets, @initialProblemIDs);
-
-	for my $i (0 .. $#$sets) {
-		if (after($sets->[$i]->open_date)
-			&& $sets->[$i]->assignment_type eq 'default'
-			&& @{ $setProblemIds->{ $sets->[$i]->set_id } })
-		{
-			push(
-				@openSets,
-				[
-					format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id,
-					data => { problem_ids => encode_json($setProblemIds->{ $sets->[$i]->set_id }) }
-				]
-			);
-			@initialProblemIDs = @{ $setProblemIds->{ $sets->[$i]->set_id } } unless @initialProblemIDs;
-		}
-	}
-
-	return unless @openSets;
-
-	return $c->c(
-		$c->tag(
-			'p',
-			$c->maketext(
-				'Please choose the set name and problem number of the question which should have its weight doubled.')
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'dbp_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr', data => { problems => 'dbp_problem_id' } }
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id                  => 'dbp_problem_id',
-			label_text          => $c->maketext('Problem Number'),
-			values              => \@initialProblemIDs,
-			menu_container_attr => { class => 'col-3' }
-		)
-	)->join('');
+sub can_use ($self, $set, $records) {
+	return $set->assignment_type eq 'default' && after($set->open_date);
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('dbp_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
+sub print_form ($self, $set, $records, $c) {
+	return WeBWorK::AchievementItems::form_popup_menu_row(
+		$c,
+		id         => 'dbp_problem_id',
+		label_text => $c->maketext('Problem number to double weight'),
+		first_item => $c->maketext('Choose problem to double its weight.'),
+		values     => [
+			map { [ $c->maketext('Problem [_1] ([_2] to [_3])', $_->problem_id, $_->value, 2 * $_->value) =>
+					$_->problem_id ] } @$records
+		],
+	);
+}
 
+sub use_item ($self, $set, $records, $c) {
 	my $problemID = $c->param('dbp_problem_id');
-	return 'You need to input a Problem Number' unless $problemID;
+	unless ($problemID) {
+		$c->addbadmessage($c->maketext('Select problem to double its weight with the [_1].', $self->name));
+		return '';
+	}
 
-	my $globalproblem = $db->getMergedProblem($userName, $setID, $problemID);
-	my $problem       = $db->getUserProblem($userName, $setID, $problemID);
-	return 'There was an error accessing that problem.' unless $globalproblem && $problem;
+	my $problem;
+	for (@$records) {
+		if ($_->problem_id == $problemID) {
+			$problem = $_;
+			last;
+		}
+	}
+	return '' unless $problem;
 
 	# Double the value of the problem.
-	$problem->value($globalproblem->value * 2);
-	$db->putUserProblem($problem);
-
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	my $db          = $c->db;
+	my $userProblem = $db->getUserProblem($problem->user_id, $problem->set_id, $problem->problem_id);
+	my $orig_value  = $problem->value;
+	$problem->value($orig_value * 2);
+	$userProblem->value($problem->value);
+	$db->putUserProblem($userProblem);
+
+	return $c->maketext('Problem [_1] weight increased from [_2] to [_3].', $problemID, $orig_value, $problem->value);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/DoubleSet.pm b/lib/WeBWorK/AchievementItems/DoubleSet.pm
index bde65cd065..b91629f717 100644
--- a/lib/WeBWorK/AchievementItems/DoubleSet.pm
+++ b/lib/WeBWorK/AchievementItems/DoubleSet.pm
@@ -1,26 +1,10 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::DoubleSet;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to make a homework set worth twice as much
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x);
 use WeBWorK::Utils::DateTime qw(after);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
 
 sub new ($class) {
 	return bless {
@@ -30,62 +14,37 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my @openSets;
+sub can_use ($self, $set, $records) {
+	return $set->assignment_type eq 'default' && after($set->open_date);
+}
 
-	for my $i (0 .. $#$sets) {
-		push(@openSets, [ format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id ])
-			if (after($sets->[$i]->open_date) && $sets->[$i]->assignment_type eq 'default');
+sub print_form ($self, $set, $records, $c) {
+	my $total = 0;
+	for my $problem (@$records) {
+		$total += $problem->value;
 	}
-
-	return unless @openSets;
-
-	return $c->c(
-		$c->tag('p', $c->maketext('Choose the set which you would like to be worth twice as much.')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'dub_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr' }
-		)
-	)->join('');
+	return $c->tag('p',
+		$c->maketext(q(Increase this assignment's total number of points from [_1] to [_2].), $total, 2 * $total));
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('dub_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
-
-	my $set = $db->getMergedSet($userName, $setID);
-	return q{Couldn't find that set!} unless $set;
-
-	my @probIDs = $db->listUserProblems($userName, $setID);
-
-	for my $probID (@probIDs) {
-		my $globalproblem = $db->getMergedProblem($userName, $setID, $probID);
-		my $problem       = $db->getUserProblem($userName, $setID, $probID);
-
-		# Double the problem value.
-		$problem->value($globalproblem->value * 2);
-		$db->putUserProblem($problem);
+sub use_item ($self, $set, $records, $c) {
+	my $db        = $c->db;
+	my $old_value = 0;
+	my $new_value = 0;
+
+	my %userProblems =
+		map { $_->problem_id => $_ } $db->getUserProblemsWhere({ user_id => $set->user_id, set_id => $set->set_id });
+	for my $problem (@$records) {
+		my $userProblem = $userProblems{ $problem->problem_id };
+		$old_value += $problem->value;
+		$problem->value(2 * $problem->value);
+		$userProblem->value($problem->value);
+		$new_value += $userProblem->value;
+		$db->putUserProblem($userProblem);
 	}
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	return $c->maketext(q(Assignment's total point value increased from [_1] points to [_2] points),
+		$old_value, $new_value);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/DuplicateProb.pm b/lib/WeBWorK/AchievementItems/DuplicateProb.pm
index e7841b86a8..20c90d726d 100644
--- a/lib/WeBWorK/AchievementItems/DuplicateProb.pm
+++ b/lib/WeBWorK/AchievementItems/DuplicateProb.pm
@@ -1,28 +1,10 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::DuplicateProb;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to turn one problem into another problem
 
-use Mojo::JSON qw(encode_json);
-
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x);
 use WeBWorK::Utils::DateTime qw(between);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
 
 sub new ($class) {
 	return bless {
@@ -32,109 +14,57 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	# Show open sets and allow for a choice of two problems from the set.
-	# Javascript ensures the appropriate problems are shown for the selected set.
-
-	my (@openSets, @initialProblemIDs);
-
-	for my $i (0 .. $#$sets) {
-		if (between($sets->[$i]->open_date, $sets->[$i]->due_date)
-			&& $sets->[$i]->assignment_type eq 'default'
-			&& @{ $setProblemIds->{ $sets->[$i]->set_id } })
-		{
-			push(
-				@openSets,
-				[
-					format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id,
-					data => { problem_ids => encode_json($setProblemIds->{ $sets->[$i]->set_id }) }
-				]
-			);
-			@initialProblemIDs = @{ $setProblemIds->{ $sets->[$i]->set_id } } unless @initialProblemIDs;
-		}
-	}
-
-	return unless @openSets;
+sub can_use ($self, $set, $records) {
+	return $set->assignment_type eq 'default' && between($set->open_date, $set->due_date);
+}
 
+sub print_form ($self, $set, $records, $c) {
 	return $c->c(
-		$c->tag(
-			'p',
-			$c->maketext(
-				'Please choose the set, the problem you would like to copy, '
-					. 'and the problem you would like to copy it to.'
-			)
+		$c->tag('p', $c->maketext('Replaces the second problem with a copy of the first.')),
+		WeBWorK::AchievementItems::form_popup_menu_row(
+			$c,
+			id         => 'clone_source_problem_id',
+			label_text => $c->maketext('Problem number to copy'),
+			first_item => $c->maketext('Choose problem to copy from.'),
+			values     => [ map { [ $c->maketext('Problem [_1]', $_->problem_id) => $_->problem_id ] } @$records ],
 		),
 		WeBWorK::AchievementItems::form_popup_menu_row(
 			$c,
-			id         => 'tran_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => {
-				dir  => 'ltr',
-				data => { problems => 'tran_problem_id', problems2 => 'tran_problem_id2' }
-			}
+			id         => 'clone_dest_problem_id',
+			label_text => $c->maketext('Problem number to replace'),
+			first_item => $c->maketext('Choose problem to replace.'),
+			values     => [ map { [ $c->maketext('Problem [_1]', $_->problem_id) => $_->problem_id ] } @$records ],
 		),
-		$c->tag(
-			'div',
-			class => 'row mb-3',
-			$c->c(
-				WeBWorK::AchievementItems::form_popup_menu_row(
-					$c,
-					id                  => 'tran_problem_id',
-					values              => \@initialProblemIDs,
-					label_text          => $c->maketext('Copy this Problem'),
-					menu_container_attr => { class => 'col-2 ps-0' },
-					add_container       => 0
-				),
-				WeBWorK::AchievementItems::form_popup_menu_row(
-					$c,
-					id                  => 'tran_problem_id2',
-					values              => \@initialProblemIDs,
-					label_text          => $c->maketext('To this Problem'),
-					menu_container_attr => { class => 'col-2 ps-0' },
-					add_container       => 0
-				)
-			)->join('')
-		)
 	)->join('');
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('tran_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
-
-	my $problemID = $c->param('tran_problem_id');
-	return 'You need to input a Problem Number' unless $problemID;
-
-	my $problemID2 = $c->param('tran_problem_id2');
-	return 'You need to input a Problem Number' unless $problemID2;
-
-	return 'You need to pick 2 different problems!' if $problemID == $problemID2;
-
-	my $problem  = $db->getMergedProblem($userName, $setID, $problemID);
-	my $problem2 = $db->getUserProblem($userName, $setID, $problemID2);
-	return 'There was an error accessing those problems.' unless $problem && $problem2;
+sub use_item ($self, $set, $records, $c) {
+	my $sourceID = $c->param('clone_source_problem_id');
+	my $destID   = $c->param('clone_dest_problem_id');
+	unless ($sourceID) {
+		$c->addbadmessage($c->maketext('Select problem to clone with the [_1].', $self->name));
+		return '';
+	}
+	unless ($destID) {
+		$c->addbadmessage($c->maketext('Select problem to replace with the [_1].', $self->name));
+		return '';
+	}
 
-	# Set the source of the second problem to that of the first problem.
-	$problem2->source_file($problem->source_file);
-	$db->putUserProblem($problem2);
+	my ($sourceProblem, $destProblem);
+	for (@$records) {
+		$sourceProblem = $_ if $_->problem_id == $sourceID;
+		$destProblem   = $_ if $_->problem_id == $destID;
+		last if $sourceProblem && $destProblem;
+	}
+	return '' unless $sourceProblem && $destProblem;
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
+	my $db          = $c->db;
+	my $userProblem = $db->getUserProblem($destProblem->user_id, $destProblem->set_id, $destProblem->problem_id);
+	$destProblem->source_file($sourceProblem->source_file);
+	$userProblem->source_file($destProblem->source_file);
+	$db->putUserProblem($userProblem);
 
-	return;
+	return $c->maketext("Problem [_1] replaced with problem [_2].", $destID, $sourceID);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/ExtendDueDate.pm b/lib/WeBWorK/AchievementItems/ExtendDueDate.pm
index 7b3214021f..2391ab916d 100644
--- a/lib/WeBWorK/AchievementItems/ExtendDueDate.pm
+++ b/lib/WeBWorK/AchievementItems/ExtendDueDate.pm
@@ -1,87 +1,76 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::ExtendDueDate;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to extend a close date by 24 hours.
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
-use WeBWorK::Utils::DateTime qw(between);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
+use WeBWorK::Utils           qw(x);
+use WeBWorK::Utils::DateTime qw(after between);
+
+use constant ONE_DAY => 86400;
 
 sub new ($class) {
 	return bless {
 		id          => 'ExtendDueDate',
 		name        => x('Tunic of Extension'),
-		description => x('Adds 24 hours to the close date of a homework.')
+		description => x(
+			'Adds 24 hours to the close date of a homework. '
+				. 'This will randomize problem details if used after the original close date.'
+		)
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my @openSets;
-
-	for my $i (0 .. $#$sets) {
-		push(@openSets, [ format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id ])
-			if (between($sets->[$i]->open_date, $sets->[$i]->due_date) && $sets->[$i]->assignment_type eq 'default');
-	}
-
-	return unless @openSets;
+sub can_use ($self, $set, $records) {
+	return $set->assignment_type eq 'default' && between($set->open_date, $set->due_date + ONE_DAY);
+}
 
-	return $c->c(
-		$c->tag('p', $c->maketext('Choose the set whose close date you would like to extend.')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'ext_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr' }
+sub print_form ($self, $set, $records, $c) {
+	my $randomization_statement = after($set->due_date) ? $c->maketext('All problems will be rerandomized.') : '';
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Extend the close date of this assignment to [_1] (an additional 24 hours). [_2]',
+			$c->formatDateTime($set->due_date + ONE_DAY, $c->ce->{studentDateDisplayFormat}),
+			$randomization_statement
 		)
-	)->join('');
+	);
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('ext_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
-
-	my $set     = $db->getMergedSet($userName, $setID);
-	my $userSet = $db->getUserSet($userName, $setID);
-	return q{Couldn't find that set!} unless $set && $userSet;
+sub use_item ($self, $set, $records, $c) {
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
+
+	# Change the seed for all of the problems if the set is currently closed.
+	if (after($set->due_date)) {
+		my %userProblems =
+			map { $_->problem_id => $_ }
+			$db->getUserProblemsWhere({ user_id => $set->user_id, set_id => $set->set_id });
+		for my $problem (@$records) {
+			my $userProblem = $userProblems{ $problem->problem_id };
+			$userProblem->problem_seed($userProblem->problem_seed % 2**31 + 1);
+			$problem->problem_seed($userProblem->problem_seed);
+			$db->putUserProblem($userProblem);
+		}
+	}
 
-	# Add time to the reduced scoring date, due date, and answer date.
-	$userSet->reduced_scoring_date($set->reduced_scoring_date() + 86400) if $set->reduced_scoring_date;
-	$userSet->due_date($set->due_date() + 86400);
-	$userSet->answer_date($set->answer_date() + 86400);
+	# Add time to the reduced scoring date if it was defined in the first place
+	if ($set->reduced_scoring_date) {
+		$set->reduced_scoring_date($set->reduced_scoring_date + ONE_DAY);
+		$userSet->reduced_scoring_date($set->reduced_scoring_date);
+	}
+	# Add time to the close date
+	$set->due_date($set->due_date + ONE_DAY);
+	$userSet->due_date($set->due_date);
+	# This may require also extending the answer date.
+	if ($set->due_date > $set->answer_date) {
+		$set->answer_date($set->due_date);
+		$userSet->answer_date($set->answer_date);
+	}
 	$db->putUserSet($userSet);
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	return $c->maketext(
+		'Close date of this assignment extended by 24 hours to [_1].',
+		$c->formatDateTime($set->due_date, $c->ce->{studentDateDisplayFormat})
+	);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/ExtendDueDateGW.pm b/lib/WeBWorK/AchievementItems/ExtendDueDateGW.pm
index 710824605e..6900d1f828 100644
--- a/lib/WeBWorK/AchievementItems/ExtendDueDateGW.pm
+++ b/lib/WeBWorK/AchievementItems/ExtendDueDateGW.pm
@@ -1,26 +1,12 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::ExtendDueDateGW;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to extend the close date on a test
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x);
 use WeBWorK::Utils::DateTime qw(between);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
+
+use constant ONE_DAY => 86400;
 
 sub new ($class) {
 	return bless {
@@ -31,76 +17,53 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my $db = $c->db;
-
-	my @openGateways;
-
-	# Find the template sets for open tests.
-	for my $set (@$sets) {
-		push(@openGateways, [ format_set_name_display($set->set_id) => $set->set_id ])
-			if $set->assignment_type =~ /gateway/
-			&& $set->set_id !~ /,v\d+$/
-			&& between($set->open_date, $set->due_date);
-	}
-
-	return unless @openGateways;
+sub can_use ($self, $set, $records) {
+	return
+		$set->assignment_type =~ /gateway/
+		&& $set->set_id !~ /,v\d+$/
+		&& between($set->open_date, $set->due_date + ONE_DAY);
+}
 
-	return $c->c(
-		$c->tag('p', $c->maketext('Extend the close date for which test?')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'eddgw_gw_id',
-			label_text => $c->maketext('Test Name'),
-			values     => \@openGateways,
-			menu_attr  => { dir => 'ltr' }
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Extend the close date of this test to [_1] (an additional 24 hours).',
+			$c->formatDateTime($set->due_date + ONE_DAY, $c->ce->{studentDateDisplayFormat})
 		)
-	)->join('');
+	);
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('eddgw_gw_id');
-	return 'You need to input a Test Name' unless defined $setID;
-
-	my $set     = $db->getMergedSet($userName, $setID);
-	my $userSet = $db->getUserSet($userName, $setID);
-	return q{Couldn't find that set!} unless $set && $userSet;
+sub use_item ($self, $set, $records, $c) {
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
 
 	# Add time to the reduced scoring date, due date, and answer date.
-	$userSet->reduced_scoring_date($set->reduced_scoring_date() + 86400)
-		if defined($set->reduced_scoring_date()) && $set->reduced_scoring_date();
-	$userSet->due_date($set->due_date() + 86400);
-	$userSet->answer_date($set->answer_date() + 86400);
+	if ($set->reduced_scoring_date) {
+		$set->reduced_scoring_date($set->reduced_scoring_date + ONE_DAY);
+		$userSet->reduced_scoring_date($set->reduced_scoring_date);
+	}
+	$set->due_date($set->due_date + ONE_DAY);
+	$userSet->due_date($set->due_date);
+	$set->answer_date($set->answer_date + ONE_DAY);
+	$userSet->answer_date($set->answer_date);
 	$db->putUserSet($userSet);
 
+	# FIXME: Should we add time to each test version, as adding 24 hours to a 1 hour long test
+	# isn't reasonable. Disabling this for now, will revisit later.
 	# Add time to the reduced scoring date, due date, and answer date for all versions.
-	my @versions = $db->listSetVersions($userName, $setID);
-
-	for my $version (@versions) {
-		$set = $db->getSetVersion($userName, $setID, $version);
-		$set->reduced_scoring_date($set->reduced_scoring_date() + 86400)
-			if defined($set->reduced_scoring_date()) && $set->reduced_scoring_date();
-		$set->due_date($set->due_date() + 86400);
-		$set->answer_date($set->answer_date() + 86400);
-		$db->putSetVersion($set);
-	}
-
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	#my @versions = $db->listSetVersions($userName, $setID);
+	#for my $version (@versions) {
+	#	$set = $db->getSetVersion($userName, $setID, $version);
+	#	$set->reduced_scoring_date($set->reduced_scoring_date() + ONE_DAY)
+	#		if defined($set->reduced_scoring_date()) && $set->reduced_scoring_date();
+	#	$set->due_date($set->due_date() + ONE_DAY);
+	#	$set->answer_date($set->answer_date() + ONE_DAY);
+	#	$db->putSetVersion($set);
+	#}
+
+	return $c->maketext('Close date of this test extended by 24 hours to [_1].',
+		$c->formatDateTime($set->due_date, $c->ce->{studentDateDisplayFormat}));
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/ExtendReducedDate.pm b/lib/WeBWorK/AchievementItems/ExtendReducedDate.pm
new file mode 100644
index 0000000000..f6e24bb3b3
--- /dev/null
+++ b/lib/WeBWorK/AchievementItems/ExtendReducedDate.pm
@@ -0,0 +1,69 @@
+package WeBWorK::AchievementItems::ExtendReducedDate;
+use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
+
+# Item to extend a close date by 24 hours.
+
+use WeBWorK::Utils           qw(x);
+use WeBWorK::Utils::DateTime qw(between);
+
+use constant ONE_DAY => 86400;
+
+sub new ($class) {
+	return bless {
+		id          => 'ExtendReducedDate',
+		name        => x('Scroll of Extension'),
+		description => x(
+			'Adds 24 hours to the reduced scoring date of an assignment.  You will have to resubmit '
+				. 'any problems that have already been penalized to earn full credit.  You cannot '
+				. 'extend the reduced scoring date beyond the due date of an assignment.'
+		)
+	}, $class;
+}
+
+sub can_use ($self, $set, $records) {
+	return 0
+		unless $set->assignment_type eq 'default'
+		&& $set->enable_reduced_scoring
+		&& $set->reduced_scoring_date
+		&& $set->reduced_scoring_date < $set->due_date;
+
+	$self->{new_date} = $set->reduced_scoring_date + ONE_DAY;
+	$self->{new_date} = $set->due_date if $set->due_date < $self->{new_date};
+	return between($set->open_date, $self->{new_date});
+}
+
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag(
+		'p',
+		$c->maketext(
+			q{This item won't work unless your instructor enables the reduced scoring feature.  }
+				. 'Let your instructor know that you recieved this message.'
+		)
+	) unless $c->{ce}->{pg}{ansEvalDefaults}{enableReducedScoring};
+
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Extend the reduced scoring date to [_1] (an additional 24 hours).',
+			$c->formatDateTime($self->{new_date}, $c->ce->{studentDateDisplayFormat})
+		)
+	);
+}
+
+sub use_item ($self, $set, $records, $c) {
+	return '' unless $c->{ce}->{pg}{ansEvalDefaults}{enableReducedScoring};
+
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
+
+	$set->reduced_scoring_date($self->{new_date});
+	$userSet->reduced_scoring_date($set->reduced_scoring_date);
+	$db->putUserSet($userSet);
+
+	return $c->maketext(
+		'Reduced scoring date of this assignment extended by 24 hours to [_1].',
+		$c->formatDateTime($self->{new_date}, $c->ce->{studentDateDisplayFormat})
+	);
+}
+
+1;
diff --git a/lib/WeBWorK/AchievementItems/FullCreditProb.pm b/lib/WeBWorK/AchievementItems/FullCreditProb.pm
index 3bb8b38758..50e15639b5 100644
--- a/lib/WeBWorK/AchievementItems/FullCreditProb.pm
+++ b/lib/WeBWorK/AchievementItems/FullCreditProb.pm
@@ -1,28 +1,10 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::FullCreditProb;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to give full credit on a single problem
 
-use Mojo::JSON qw(encode_json);
-
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x wwRound);
 use WeBWorK::Utils::DateTime qw(after);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
 
 sub new ($class) {
 	return bless {
@@ -32,84 +14,57 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	# Construct a dropdown with open sets and another with problems.
-	# Javascript ensures the appropriate problems are shown for the selected set.
-
-	my (@openSets, @initialProblemIDs);
-
-	for my $i (0 .. $#$sets) {
-		if (after($sets->[$i]->open_date)
-			&& $sets->[$i]->assignment_type eq 'default'
-			&& @{ $setProblemIds->{ $sets->[$i]->set_id } })
-		{
-			push(
-				@openSets,
-				[
-					format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id,
-					data => { problem_ids => encode_json($setProblemIds->{ $sets->[$i]->set_id }) }
-				]
-			);
-			@initialProblemIDs = @{ $setProblemIds->{ $sets->[$i]->set_id } } unless @initialProblemIDs;
-		}
-	}
+sub can_use ($self, $set, $records) {
+	return 0
+		unless $set->assignment_type eq 'default'
+		&& after($set->open_date);
 
-	return unless @openSets;
+	my @problems = grep { $_->status < 1 } @$records;
+	return 0 unless @problems;
 
-	return $c->c(
-		$c->tag(
-			'p',
-			$c->maketext(
-				'Please choose the set name and problem number of the question which should be given full credit.')
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'fcp_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr', data => { problems => 'fcp_problem_id' } }
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id                  => 'fcp_problem_id',
-			label_text          => $c->maketext('Problem Number'),
-			values              => \@initialProblemIDs,
-			menu_container_attr => { class => 'col-3' }
-		)
-	)->join('');
+	$self->{usableProblems} = \@problems;
+	return 1;
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('fcp_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
+sub print_form ($self, $set, $records, $c) {
+	return WeBWorK::AchievementItems::form_popup_menu_row(
+		$c,
+		id         => 'full_cred_problem_id',
+		label_text => $c->maketext('Problem number to give full credit'),
+		first_item => $c->maketext('Choose problem to give full credit.'),
+		values     => [
+			map { [ $c->maketext('Problem [_1] ([_2]% to 100%)', $_->problem_id, 100 * wwRound(2, $_->status)) =>
+					$_->problem_id ] } @{ $self->{usableProblems} }
+		],
+	);
+}
 
-	my $problemID = $c->param('fcp_problem_id');
-	return 'You need to input a Problem Number' unless $problemID;
+sub use_item ($self, $set, $records, $c) {
+	my $problemID = $c->param('full_cred_problem_id');
+	unless ($problemID) {
+		$c->addbadmessage($c->maketext('Select problem to give full credit with the [_1].', $self->name));
+		return '';
+	}
 
-	my $problem = $db->getUserProblem($userName, $setID, $problemID);
-	return 'There was an error accessing that problem.' unless $problem;
+	my $problem;
+	for (@$records) {
+		if ($_->problem_id == $problemID) {
+			$problem = $_;
+			last;
+		}
+	}
+	return '' unless $problem;
 
-	# Set the status and sub_status of the problem to one.
+	# Increase status to 100%.
+	my $db          = $c->db;
+	my $userProblem = $db->getUserProblem($problem->user_id, $problem->set_id, $problem->problem_id);
 	$problem->status(1);
 	$problem->sub_status(1);
-	$db->putUserProblem($problem);
-
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
+	$userProblem->status(1);
+	$userProblem->sub_status(1);
+	$db->putUserProblem($userProblem);
 
-	return;
+	return $c->maketext('Problem number [_1] given full credit.', $problemID);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/FullCreditSet.pm b/lib/WeBWorK/AchievementItems/FullCreditSet.pm
index b29469c69f..6c9065fa48 100644
--- a/lib/WeBWorK/AchievementItems/FullCreditSet.pm
+++ b/lib/WeBWorK/AchievementItems/FullCreditSet.pm
@@ -1,26 +1,10 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::FullCreditSet;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to give half credit on all problems in a homework set.
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x wwRound);
 use WeBWorK::Utils::DateTime qw(after);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
 
 sub new ($class) {
 	return bless {
@@ -30,59 +14,40 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my @openSets;
+sub can_use ($self, $set, $records) {
+	return 0
+		unless $set->assignment_type eq 'default'
+		&& after($set->open_date);
 
-	for my $i (0 .. $#$sets) {
-		push(@openSets, [ format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id ])
-			if (after($sets->[$i]->open_date) && $sets->[$i]->assignment_type eq 'default');
+	my $total = 0;
+	my $grade = 0;
+	for my $problem (@$records) {
+		$grade += $problem->status * $problem->value;
+		$total += $problem->value;
 	}
+	$self->{old_grade} = 100 * wwRound(2, $grade / $total);
+	return $self->{old_grade} == 100 ? 0 : 1;
+}
 
-	return unless @openSets;
-
-	return $c->c(
-		$c->tag('p', $c->maketext('Please choose the set for which all problems should be given full credit.')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'fcs_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr' }
-		)
-	)->join('');
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag('p', $c->maketext(q(Increase this assignment's grade from [_1]% to 100%.), $self->{old_grade}));
 }
 
-sub use_item ($self, $userName, $c) {
+sub use_item ($self, $set, $records, $c) {
 	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
 
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('fcs_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
-
-	my @probIDs = $db->listUserProblems($userName, $setID);
-
-	for my $probID (@probIDs) {
-		my $problem = $db->getUserProblem($userName, $setID, $probID);
-
-		# Set status and sub_status to 1.
+	my %userProblems =
+		map { $_->problem_id => $_ } $db->getUserProblemsWhere({ user_id => $set->user_id, set_id => $set->set_id });
+	for my $problem (@$records) {
+		my $userProblem = $userProblems{ $problem->problem_id };
 		$problem->status(1);
 		$problem->sub_status(1);
-		$db->putUserProblem($problem);
+		$userProblem->status(1);
+		$userProblem->sub_status(1);
+		$db->putUserProblem($userProblem);
 	}
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	return $c->maketext(q(Assignment's grade increased from [_1]% to 100%.), $self->{old_grade});
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/HalfCreditProb.pm b/lib/WeBWorK/AchievementItems/HalfCreditProb.pm
index 748ce57619..307e3d32af 100644
--- a/lib/WeBWorK/AchievementItems/HalfCreditProb.pm
+++ b/lib/WeBWorK/AchievementItems/HalfCreditProb.pm
@@ -1,118 +1,74 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::HalfCreditProb;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to give half credit on a single problem.
 
-use Mojo::JSON qw(encode_json);
-
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x wwRound);
 use WeBWorK::Utils::DateTime qw(after);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
 
 sub new ($class) {
 	return bless {
 		id          => 'HalfCreditProb',
 		name        => x('Lesser Rod of Revelation'),
-		description => x('Increases the score of a single problem by 50%, to a maximum of 100%.')
+		description => x('Increases the grade of a single problem by 50%, to a maximum of 100%.')
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	# Construct a dropdown with open sets and another with problems.
-	# Javascript ensures the appropriate problems are shown for the selected set.
-
-	my (@openSets, @initialProblemIDs);
-
-	for my $i (0 .. $#$sets) {
-		if (after($sets->[$i]->open_date)
-			&& $sets->[$i]->assignment_type eq 'default'
-			&& @{ $setProblemIds->{ $sets->[$i]->set_id } })
-		{
-			push(
-				@openSets,
-				[
-					format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id,
-					data => { problem_ids => encode_json($setProblemIds->{ $sets->[$i]->set_id }) }
-				]
-			);
-			@initialProblemIDs = @{ $setProblemIds->{ $sets->[$i]->set_id } } unless @initialProblemIDs;
-		}
-	}
-
-	return unless @openSets;
+sub can_use($self, $set, $records) {
+	return 0
+		unless $set->assignment_type eq 'default'
+		&& after($set->open_date);
 
-	return $c->c(
-		$c->tag(
-			'p',
-			$c->maketext(
-				'Please choose the assignment name and problem number of the question to add half credit to.')
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'hcp_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr', data => { problems => 'hcp_problem_id' } }
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id                  => 'hcp_problem_id',
-			values              => \@initialProblemIDs,
-			label_text          => $c->maketext('Problem Number'),
-			menu_container_attr => { class => 'col-3' }
-		)
-	)->join('');
+	$self->{unfinishedProblems} = [ grep { $_->status < 1 } @$records ];
+	return @{ $self->{unfinishedProblems} } ? 1 : 0;
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('hcp_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
-
-	my $problemID = $c->param('hcp_problem_id');
-	return 'You need to input a Problem Number' unless $problemID;
-
-	my $problem = $db->getUserProblem($userName, $setID, $problemID);
-	return 'There was an error accessing that problem.' unless $problem;
-
-	# Add .5 to grade with max of 1
-	my $new_status = $problem->status + 0.5;
-	$new_status = 1 if $new_status > 1;
-	$problem->status($new_status);
-	$problem->sub_status($new_status);
-
-	$db->putUserProblem($problem);
+sub print_form ($self, $set, $records, $c) {
+	return WeBWorK::AchievementItems::form_popup_menu_row(
+		$c,
+		id         => 'half_cred_problem_id',
+		label_text => $c->maketext('Problem number to increase grade by 50%'),
+		first_item => $c->maketext('Choose problem to increase grade by 50%.'),
+		values     => [
+			map { [
+				$c->maketext(
+					'Problem [_1] ([_2]% to [_3]%)',
+					$_->problem_id,
+					100 * wwRound(2, $_->status),
+					100 * wwRound(2, $_->status < 0.5 ? $_->status + 0.5 : 1)
+				) => $_->problem_id
+			] } @{ $self->{unfinishedProblems} }
+		],
+	);
+}
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
+sub use_item ($self, $set, $records, $c) {
+	my $problemID = $c->param('half_cred_problem_id');
+	unless ($problemID) {
+		$c->addbadmessage($c->maketext('Select problem to increase its grade by 50% with the [_1].', $self->name));
+		return '';
+	}
 
-	return;
+	my $problem;
+	for (@$records) {
+		if ($_->problem_id == $problemID) {
+			$problem = $_;
+			last;
+		}
+	}
+	return '' unless $problem;
+
+	# Increase status to 100%.
+	my $db          = $c->db;
+	my $userProblem = $db->getUserProblem($problem->user_id, $problem->set_id, $problem->problem_id);
+	$problem->status($problem->status > 0.5 ? 1 : $problem->status + 0.5);
+	$problem->sub_status($problem->status);
+	$userProblem->status($problem->status);
+	$userProblem->sub_status($problem->status);
+	$db->putUserProblem($userProblem);
+
+	return $c->maketext('Problem number [_1] grade increased to [_2]%.', $problemID,
+		100 * wwRound(2, $problem->status));
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/HalfCreditSet.pm b/lib/WeBWorK/AchievementItems/HalfCreditSet.pm
index ba01a455bd..37496e9bdd 100644
--- a/lib/WeBWorK/AchievementItems/HalfCreditSet.pm
+++ b/lib/WeBWorK/AchievementItems/HalfCreditSet.pm
@@ -1,26 +1,10 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::HalfCreditSet;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to give half credit on all problems in a homework set.
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x wwRound);
 use WeBWorK::Utils::DateTime qw(after);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
 
 sub new ($class) {
 	return bless {
@@ -30,64 +14,49 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my @openSets;
-
-	for my $i (0 .. $#$sets) {
-		push(@openSets, [ format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id ])
-			if (after($sets->[$i]->open_date) && $sets->[$i]->assignment_type eq 'default');
+sub can_use($self, $set, $records) {
+	return 0
+		unless $set->assignment_type eq 'default'
+		&& after($set->open_date);
+
+	my $total     = 0;
+	my $old_grade = 0;
+	my $new_grade = 0;
+	for my $problem (@$records) {
+		$old_grade += $problem->status * $problem->value;
+		$new_grade += ($problem->status > 0.5 ? 1 : $problem->status + 0.5) * $problem->value;
+		$total     += $problem->value;
 	}
+	$self->{old_grade} = 100 * wwRound(2, $old_grade / $total);
+	$self->{new_grade} = 100 * wwRound(2, $new_grade / $total);
+	return $self->{old_grade} == 100 ? 0 : 1;
+}
 
-	return unless @openSets;
-
-	return $c->c(
-		$c->tag(
-			'p', $c->maketext('Please choose the assignment for which all problems should have half credit added.')
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'hcs_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr' }
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag(
+		'p',
+		$c->maketext(
+			q(Increase this assignment's grade from [_1]% to [_2]%.),
+			$self->{old_grade}, $self->{new_grade}
 		)
-	)->join('');
+	);
 }
 
-sub use_item ($self, $userName, $c) {
+sub use_item ($self, $set, $records, $c) {
 	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
 
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('hcs_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
-
-	my @probIDs = $db->listUserProblems($userName, $setID);
-
-	for my $probID (@probIDs) {
-		my $problem = $db->getUserProblem($userName, $setID, $probID);
-
-		# Add .5 to grade with max of 1.
-		my $new_status = $problem->status + 0.5;
-		$new_status = 1 if $new_status > 1;
-		$problem->status($new_status);
-		$problem->sub_status($new_status);
-
-		$db->putUserProblem($problem);
+	my %userProblems =
+		map { $_->problem_id => $_ } $db->getUserProblemsWhere({ user_id => $set->user_id, set_id => $set->set_id });
+	for my $problem (@$records) {
+		my $userProblem = $userProblems{ $problem->problem_id };
+		$problem->status($problem->status > 0.5 ? 1 : $problem->status + 0.5);
+		$problem->sub_status($problem->status);
+		$userProblem->status($problem->status);
+		$userProblem->sub_status($problem->status);
+		$db->putUserProblem($userProblem);
 	}
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	return $c->maketext(q(Assignment's grade increased from [_1] to [_2].), $self->{old_grade}, $self->{new_grade});
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/NoReducedCred.pm b/lib/WeBWorK/AchievementItems/NoReducedCred.pm
new file mode 100644
index 0000000000..9a8b124a78
--- /dev/null
+++ b/lib/WeBWorK/AchievementItems/NoReducedCred.pm
@@ -0,0 +1,65 @@
+package WeBWorK::AchievementItems::NoReducedCred;
+use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
+
+# Item to remove reduce credit scoring period from a set.
+# Reduced scoring needs to be enabled for this item to be useful.
+
+use WeBWorK::Utils           qw(x);
+use WeBWorK::Utils::DateTime qw(between);
+
+sub new ($class) {
+	return bless {
+		id          => 'NoReducedCred',
+		name        => x('Potion of Power'),
+		description => x(
+			'Remove reduced scoring penalties from an open assignemnt.  You will have to resubmit '
+				. 'any problems that have already been penalized to earn full credit on them.'
+		)
+	}, $class;
+}
+
+sub can_use ($self, $set, $records) {
+	return 0
+		unless $set->assignment_type eq 'default'
+		&& $set->enable_reduced_scoring
+		&& $set->reduced_scoring_date
+		&& $set->reduced_scoring_date < $set->due_date
+		&& between($set->open_date, $set->due_date);
+}
+
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag(
+		'p',
+		$c->maketext(
+			q{This item won't work unless your instructor enables the reduced scoring feature.  }
+				. 'Let your instructor know that you recieved this message.'
+		)
+	) unless $c->{ce}->{pg}{ansEvalDefaults}{enableReducedScoring};
+
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Remove the reduced scoring penalty from this assignment. Problems submitted before '
+				. 'the close date on [_1] will earn full credit. Any problems that have already been '
+				. 'penalized will have to be resubmitted for full credit.',
+			$c->formatDateTime($set->due_date, $c->ce->{studentDateDisplayFormat})
+		)
+	);
+}
+
+sub use_item ($self, $set, $records, $c) {
+	return '' unless $c->{ce}->{pg}{ansEvalDefaults}{enableReducedScoring};
+
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
+
+	$set->enable_reduced_scoring(0);
+	$set->reduced_scoring_date($set->due_date);
+	$userSet->enable_reduced_scoring(0);
+	$userSet->reduced_scoring_date($set->due_date);
+	$db->putUserSet($userSet);
+
+	return $c->maketext('Reduced scoring penalty removed.');
+}
+
+1;
diff --git a/lib/WeBWorK/AchievementItems/ReducedCred.pm b/lib/WeBWorK/AchievementItems/ReducedCred.pm
index 089dbfe410..340d960815 100644
--- a/lib/WeBWorK/AchievementItems/ReducedCred.pm
+++ b/lib/WeBWorK/AchievementItems/ReducedCred.pm
@@ -1,27 +1,13 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::ReducedCred;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to extend a close date by 24 hours for reduced credit
 # Reduced scoring needs to be enabled for this item to work.
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
-use WeBWorK::Utils::DateTime qw(between);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
+use WeBWorK::Utils           qw(x);
+use WeBWorK::Utils::DateTime qw(after between);
+
+use constant ONE_DAY => 86400;
 
 sub new ($class) {
 	return bless {
@@ -29,69 +15,83 @@ sub new ($class) {
 		name        => x('Ring of Reduction'),
 		description => x(
 			'Enable reduced scoring for a homework set.  This will allow you to submit answers '
-				. 'for partial credit for 24 hours after the close date.'
+				. 'for partial credit for 24 hours after the close date. '
+				. 'This will randomize problem details if used after the original close date.'
 		)
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my @openSets;
-
-	for my $i (0 .. $#$sets) {
-		push(@openSets, [ format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id ])
-			if (between($sets->[$i]->open_date, $sets->[$i]->due_date) && $sets->[$i]->assignment_type eq 'default');
-	}
+sub can_use ($self, $set, $records) {
+	return $set->assignment_type eq 'default' && between($set->open_date, $set->due_date + ONE_DAY);
+}
 
-	return unless @openSets;
+sub print_form ($self, $set, $records, $c) {
+	my $ce = $c->ce;
 
-	return $c->c(
-		$c->tag('p', $c->maketext('Choose the set which you would like to enable partial credit for.')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'red_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr' }
+	return $c->tag(
+		'p',
+		$c->maketext(
+			q{This item won't work unless your instructor enables the reduced scoring feature.  }
+				. 'Let your instructor know that you received this message.'
+		)
+	) unless $ce->{pg}{ansEvalDefaults}{enableReducedScoring};
+
+	my $randomization_statement = after($set->due_date) ? $c->maketext('All problems will be rerandomized.') : '';
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Extend the close date of this assignment to [_1] (an additional 24 hours).  Any submissions during '
+				. 'this additional time will be reduced and are worth [_2]% of their full value. [_3]',
+			$c->formatDateTime($set->due_date + ONE_DAY, $ce->{studentDateDisplayFormat}),
+			100 * $ce->{pg}{ansEvalDefaults}{reducedScoringValue},
+			$randomization_statement
 		)
-	)->join('');
+	);
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
+sub use_item ($self, $set, $records, $c) {
 	my $ce = $c->ce;
+	my $db = $c->db;
 
-	# Validate data
-
-	return q{This item won't work unless your instructor enables the reduced scoring feature.  }
-		. 'Let your instructor know that you recieved this message.'
-		unless $ce->{pg}{ansEvalDefaults}{reducedScoringPeriod};
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return "No achievement data?!?!?!" unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('red_set_id');
-	return "You need to input a Set Name" unless defined $setID;
-
-	my $set     = $db->getMergedSet($userName, $setID);
-	my $userSet = $db->getUserSet($userName, $setID);
-	return "Couldn't find that set!" unless $set && $userSet;
+	# Still need to double check reduced scoring is enabled.
+	return '' unless $ce->{pg}{ansEvalDefaults}{enableReducedScoring};
+
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
+
+	# Change the seed for all of the problems if the set is currently closed.
+	if (after($set->due_date)) {
+		my %userProblems =
+			map { $_->problem_id => $_ }
+			$db->getUserProblemsWhere({ user_id => $set->user_id, set_id => $set->set_id });
+		for my $problem (@$records) {
+			my $userProblem = $userProblems{ $problem->problem_id };
+			$userProblem->problem_seed($userProblem->problem_seed % 2**31 + 1);
+			$problem->problem_seed($userProblem->problem_seed);
+			$db->putUserProblem($userProblem);
+		}
+	}
 
-	# Enable reduced scoring on the set and add the reduced scoring period to the due date.
-	my $additionalTime = 60 * $ce->{pg}{ansEvalDefaults}{reducedScoringPeriod};
+	# Either there is already a valid reduced scoring date, or set the reduced scoring date to the close date.
+	unless ($set->reduced_scoring_date && $set->reduced_scoring_date < $set->due_date) {
+		$set->reduced_scoring_date($set->due_date);
+		$userSet->reduced_scoring_date($set->reduced_scoring_date);
+	}
+	$set->enable_reduced_scoring(1);
 	$userSet->enable_reduced_scoring(1);
-	$userSet->reduced_scoring_date($set->due_date());
-	$userSet->due_date($set->due_date() + $additionalTime);
-	$userSet->answer_date($set->answer_date() + $additionalTime);
+	# Add time to the close date
+	$set->due_date($set->due_date + ONE_DAY);
+	$userSet->due_date($set->due_date);
+	# This may require also extending the answer date.
+	if ($set->due_date > $set->answer_date) {
+		$set->answer_date($set->due_date);
+		$userSet->answer_date($set->answer_date);
+	}
 	$db->putUserSet($userSet);
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	return $c->maketext(
+		'Close date of this assignment extended by 24 hours to [_1].',
+		$c->formatDateTime($set->due_date, $ce->{studentDateDisplayFormat})
+	);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/ResetIncorrectAttempts.pm b/lib/WeBWorK/AchievementItems/ResetIncorrectAttempts.pm
index 7f63b37e1f..cd0ef5a330 100644
--- a/lib/WeBWorK/AchievementItems/ResetIncorrectAttempts.pm
+++ b/lib/WeBWorK/AchievementItems/ResetIncorrectAttempts.pm
@@ -1,28 +1,10 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::ResetIncorrectAttempts;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to reset number of incorrect attempts.
 
-use Mojo::JSON qw(encode_json);
-
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x);
 use WeBWorK::Utils::DateTime qw(between);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
 
 sub new ($class) {
 	return bless {
@@ -32,85 +14,56 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	# Construct a dropdown with open sets and another with problems.
-	# Javascript ensures the appropriate problems are shown for the selected set.
-
-	my (@openSets, @initialProblemIDs);
-
-	for my $i (0 .. $#$sets) {
-		if (between($sets->[$i]->open_date, $sets->[$i]->due_date)
-			&& $sets->[$i]->assignment_type eq 'default'
-			&& @{ $setProblemIds->{ $sets->[$i]->set_id } })
-		{
-			push(
-				@openSets,
-				[
-					format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id,
-					data => { problem_ids => encode_json($setProblemIds->{ $sets->[$i]->set_id }) }
-				]
-			);
-			@initialProblemIDs = @{ $setProblemIds->{ $sets->[$i]->set_id } } unless @initialProblemIDs;
-		}
-	}
+sub can_use ($self, $set, $records) {
+	return 0
+		unless $set->assignment_type eq 'default'
+		&& between($set->open_date, $set->due_date);
 
-	return unless @openSets;
-
-	return $c->c(
-		$c->tag(
-			'p',
-			$c->maketext(
-				'Please choose the set name and problem number of the question which '
-					. 'should have its incorrect attempt count reset.'
-			)
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'ria_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr', data => { problems => 'ria_problem_id' } }
-		),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id                  => 'ria_problem_id',
-			label_text          => $c->maketext('Problem Number'),
-			values              => \@initialProblemIDs,
-			menu_container_attr => { class => 'col-3' }
-		)
-	)->join('');
+	$self->{usableProblems} = [ grep { $_->max_attempts > 0 && $_->num_incorrect > 0 && $_->status < 1 } @$records ];
+	return @{ $self->{usableProblems} } ? 1 : 0;
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('ria_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
+sub print_form ($self, $set, $records, $c) {
+	return WeBWorK::AchievementItems::form_popup_menu_row(
+		$c,
+		id         => 'reset_attempts_problem_id',
+		label_text => $c->maketext('Problem number to reset incorrect attempts'),
+		first_item => $c->maketext('Choose problem to reset incorrect attempts.'),
+		values     => [
+			map { [
+				$c->maketext('Problem [_1] ([_2] of [_3] used)',
+					$_->problem_id, $_->num_incorrect, $_->max_attempts) => $_->problem_id
+			] } @{ $self->{usableProblems} }
+		],
+	);
+}
 
-	my $problemID = $c->param('ria_problem_id');
-	return 'You need to input a Problem Number' unless $problemID;
+# use_item is called after print_form returns a non-empty form.
+# So we can assume that $set and $records have already been validated.
+sub use_item ($self, $set, $records, $c) {
+	my $problemID = $c->param('reset_attempts_problem_id');
+	unless ($problemID) {
+		$c->addbadmessage($c->maketext('Select problem to reset with the [_1].', $self->name));
+		return '';
+	}
 
-	my $problem = $db->getUserProblem($userName, $setID, $problemID);
-	return 'There was an error accessing that problem.' unless $problem;
+	my $problem;
+	for (@$records) {
+		if ($_->problem_id == $problemID) {
+			$problem = $_;
+			last;
+		}
+	}
+	return '' unless $problem;
 
 	# Set the number of incorrect attempts to zero.
+	my $db          = $c->db;
+	my $userProblem = $db->getUserProblem($problem->user_id, $problem->set_id, $problem->problem_id);
 	$problem->num_incorrect(0);
-	$db->putUserProblem($problem);
-
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
+	$userProblem->num_incorrect(0);
+	$db->putUserProblem($userProblem);
 
-	return;
+	return $c->maketext('Reset the number of attempts on problem [_1].', $problemID);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/ResurrectGW.pm b/lib/WeBWorK/AchievementItems/ResurrectGW.pm
index beb9563f36..00b99ca4ce 100644
--- a/lib/WeBWorK/AchievementItems/ResurrectGW.pm
+++ b/lib/WeBWorK/AchievementItems/ResurrectGW.pm
@@ -1,26 +1,12 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::ResurrectGW;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to extend the due date on a gateway
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x);
 use WeBWorK::Utils::DateTime qw(after);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
+
+use constant ONE_DAY => 86400;
 
 sub new ($class) {
 	return bless {
@@ -33,63 +19,43 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my $db = $c->db;
-
-	my @closed_gateway_sets;
-
-	# Find the template sets of gateway quizzes.
-	for my $set (@$sets) {
-		push(@closed_gateway_sets, [ format_set_name_display($set->set_id) => $set->set_id ])
-			if $set->assignment_type =~ /gateway/
-			&& $set->set_id !~ /,v\d+$/
-			&& (after($set->due_date)
-				|| ($set->reduced_scoring_date && after($set->reduced_scoring_date)));
-	}
-
-	return unless @closed_gateway_sets;
+sub can_use($self, $set, $records) {
+	return $set->assignment_type =~ /gateway/
+		&& (after($set->due_date) || ($set->reduced_scoring_date && after($set->reduced_scoring_date)));
+	# TODO: Check if a new version can be created, and only allow using this reward in that case.
+}
 
-	return $c->c(
-		$c->tag('p', $c->maketext('Resurrect which test?')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'resgw_gw_id',
-			label_text => $c->maketext('Test Name'),
-			values     => \@closed_gateway_sets,
-			menu_attr  => { dir => 'ltr' }
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Reopen this test for the next 24 hours. This item does not allow you to take any additional '
+				. 'versions of the test.'
 		)
-	)->join('');
+	);
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('resgw_gw_id');
-	return 'You need to input a Test Name' unless defined $setID;
-
-	my $set = $db->getUserSet($userName, $setID);
-	return q{Couldn't find that set!} unless $set;
+sub use_item ($self, $set, $records, $c) {
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
 
 	# Add time to the reduced scoring date, due date, and answer date.
-	$set->reduced_scoring_date(time + 86400) if defined($set->reduced_scoring_date()) && $set->reduced_scoring_date();
-	$set->due_date(time + 86400);
-	$set->answer_date(time + 86400);
-	$db->putUserSet($set);
-
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
+	if ($set->reduced_scoring_date) {
+		$set->reduced_scoring_date(time + ONE_DAY);
+		$userSet->reduced_scoring_date($set->reduced_scoring_date);
+	}
+	$set->due_date(time + ONE_DAY);
+	$userSet->due_date($set->due_date);
+	if ($set->due_date > $set->answer_date) {
+		$set->answer_date(time + ONE_DAY);
+		$userSet->answer_date($set->answer_date);
+	}
+	$db->putUserSet($userSet);
 
-	return;
+	return $c->maketext(
+		'This assignment has been reopened and will now close on [_1].',
+		$c->formatDateTime($set->due_date, $c->ce->{studentDateDisplayFormat})
+	);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/ResurrectHW.pm b/lib/WeBWorK/AchievementItems/ResurrectHW.pm
index 6cc97529fb..da8364822f 100644
--- a/lib/WeBWorK/AchievementItems/ResurrectHW.pm
+++ b/lib/WeBWorK/AchievementItems/ResurrectHW.pm
@@ -1,26 +1,12 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::ResurrectHW;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to resurrect a homework for 24 hours
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
+use WeBWorK::Utils           qw(x);
 use WeBWorK::Utils::DateTime qw(after);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
+
+use constant ONE_DAY => 86400;
 
 sub new ($class) {
 	return bless {
@@ -30,70 +16,48 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	# List all of the sets that are closed or past their reduced scoring date.
-
-	my @closedSets;
-
-	for my $i (0 .. $#$sets) {
-		push(@closedSets, [ format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id ])
-			if $sets->[$i]->assignment_type eq 'default'
-			&& (after($sets->[$i]->due_date)
-				|| ($sets->[$i]->reduced_scoring_date && after($sets->[$i]->reduced_scoring_date)));
-	}
-
-	return unless @closedSets;
-
-	return $c->c(
-		$c->tag('p', $c->maketext('Choose the set which you would like to resurrect.')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'res_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@closedSets,
-			menu_attr  => { dir => 'ltr' }
-		)
-	)->join('');
+sub can_use($self, $set, $records) {
+	return $set->assignment_type eq 'default'
+		&& (after($set->due_date) || ($set->reduced_scoring_date && after($set->reduced_scoring_date)));
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('res_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
-
-	my $set = $db->getUserSet($userName, $setID);
-	return q{Couldn't find that set!} unless $set;
-
-	# Set a new reduced scoring date, close date, and answer date for the student.
-	$set->reduced_scoring_date(time + 86400);
-	$set->due_date(time + 86400);
-	$set->answer_date(time + 86400);
-	$db->putUserSet($set);
-
-	my @probIDs = $db->listUserProblems($userName, $setID);
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag('p',
+		$c->maketext('Reopen this homework assignment for the next 24 hours. All problems will be rerandomized.'));
+}
 
-	# Change the seed for all of the problems in the set.
-	for my $probID (@probIDs) {
-		my $problem = $db->getUserProblem($userName, $setID, $probID);
-		$problem->problem_seed($problem->problem_seed + 100);
-		$db->putUserProblem($problem);
+sub use_item ($self, $set, $records, $c) {
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
+
+	# Change the seed for all of the problems since the set is currently closed.
+	my %userProblems =
+		map { $_->problem_id => $_ } $db->getUserProblemsWhere({ user_id => $set->user_id, set_id => $set->set_id });
+	for my $problem (@$records) {
+		my $userProblem = $userProblems{ $problem->problem_id };
+		$userProblem->problem_seed($userProblem->problem_seed % 2**31 + 1);
+		$problem->problem_seed($userProblem->problem_seed);
+		$db->putUserProblem($userProblem);
 	}
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
+	# Add time to the reduced scoring date if it was defined in the first place
+	if ($set->reduced_scoring_date) {
+		$set->reduced_scoring_date(time + ONE_DAY);
+		$userSet->reduced_scoring_date($set->reduced_scoring_date);
+	}
+	# Add time to the close date
+	$set->due_date(time + ONE_DAY);
+	$userSet->due_date($set->due_date);
+	# This may require also extending the answer date.
+	if ($set->due_date > $set->answer_date) {
+		$set->answer_date($set->due_date);
+		$userSet->answer_date($set->answer_date);
+	}
+	$db->putUserSet($userSet);
 
-	return;
+	return $c->maketext(
+		'This assignment has been reopened and will now close on [_1]. Problems have been rerandomized.',
+		$c->formatDateTime($set->due_date, $c->ce->{studentDateDisplayFormat}));
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/SuperExtendDueDate.pm b/lib/WeBWorK/AchievementItems/SuperExtendDueDate.pm
index 2c2216caa7..7932e6de20 100644
--- a/lib/WeBWorK/AchievementItems/SuperExtendDueDate.pm
+++ b/lib/WeBWorK/AchievementItems/SuperExtendDueDate.pm
@@ -1,87 +1,76 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::SuperExtendDueDate;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
 # Item to extend a close date by 48 hours.
 
-use WeBWorK::Utils qw(x nfreeze_base64 thaw_base64);
-use WeBWorK::Utils::DateTime qw(between);
-use WeBWorK::Utils::Sets qw(format_set_name_display);
+use WeBWorK::Utils           qw(x);
+use WeBWorK::Utils::DateTime qw(after between);
+
+use constant TWO_DAYS => 172800;
 
 sub new ($class) {
 	return bless {
 		id          => 'SuperExtendDueDate',
 		name        => x('Robe of Longevity'),
-		description => x('Adds 48 hours to the close date of a homework.')
+		description => x(
+			'Adds 48 hours to the close date of a homework. '
+				. 'This will randomize problem details if used after the original close date.'
+		)
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	my @openSets;
-
-	for my $i (0 .. $#$sets) {
-		push(@openSets, [ format_set_name_display($sets->[$i]->set_id) => $sets->[$i]->set_id ])
-			if (between($sets->[$i]->open_date, $sets->[$i]->due_date) && $sets->[$i]->assignment_type eq 'default');
-	}
-
-	return unless @openSets;
+sub can_use ($self, $set, $records) {
+	return $set->assignment_type eq 'default' && between($set->open_date, $set->due_date + TWO_DAYS);
+}
 
-	return $c->c(
-		$c->tag('p', $c->maketext('Choose the set whose close date you would like to extend.')),
-		WeBWorK::AchievementItems::form_popup_menu_row(
-			$c,
-			id         => 'super_ext_set_id',
-			label_text => $c->maketext('Set Name'),
-			values     => \@openSets,
-			menu_attr  => { dir => 'ltr' }
+sub print_form ($self, $set, $records, $c) {
+	my $randomization_statement = after($set->due_date) ? $c->maketext('All problems will be rerandomized.') : '';
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Extend the close date of this assignment to [_1] (an additional 48 hours). [_2]',
+			$c->formatDateTime($set->due_date + TWO_DAYS, $c->ce->{studentDateDisplayFormat}),
+			$randomization_statement
 		)
-	)->join('');
+	);
 }
 
-sub use_item ($self, $userName, $c) {
-	my $db = $c->db;
-	my $ce = $c->ce;
-
-	# Validate data
-
-	my $globalUserAchievement = $db->getGlobalUserAchievement($userName);
-	return 'No achievement data?!?!?!' unless $globalUserAchievement->frozen_hash;
-
-	my $globalData = thaw_base64($globalUserAchievement->frozen_hash);
-	return "You are $self->{id} trying to use an item you don't have" unless $globalData->{ $self->{id} };
-
-	my $setID = $c->param('super_ext_set_id');
-	return 'You need to input a Set Name' unless defined $setID;
-
-	my $set     = $db->getMergedSet($userName, $setID);
-	my $userSet = $db->getUserSet($userName, $setID);
-	return q{Couldn't find that set!} unless $set && $userSet;
+sub use_item ($self, $set, $records, $c) {
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
+
+	# Change the seed for all of the problems if the set is currently closed.
+	if (after($set->due_date)) {
+		my %userProblems =
+			map { $_->problem_id => $_ }
+			$db->getUserProblemsWhere({ user_id => $set->user_id, set_id => $set->set_id });
+		for my $problem (@$records) {
+			my $userProblem = $userProblems{ $problem->problem_id };
+			$userProblem->problem_seed($userProblem->problem_seed % 2**31 + 1);
+			$problem->problem_seed($userProblem->problem_seed);
+			$db->putUserProblem($userProblem);
+		}
+	}
 
-	# Add time to the reduced scoring date, due date, and answer date.
-	$userSet->reduced_scoring_date($set->reduced_scoring_date() + 172800) if $set->reduced_scoring_date;
-	$userSet->due_date($set->due_date() + 172800);
-	$userSet->answer_date($set->answer_date() + 172800);
+	# Add time to the reduced scoring date if it was defined in the first place
+	if ($set->reduced_scoring_date) {
+		$set->reduced_scoring_date($set->reduced_scoring_date + TWO_DAYS);
+		$userSet->reduced_scoring_date($set->reduced_scoring_date);
+	}
+	# Add time to the close date
+	$set->due_date($set->due_date + TWO_DAYS);
+	$userSet->due_date($set->due_date);
+	# This may require also extending the answer date.
+	if ($set->due_date > $set->answer_date) {
+		$set->answer_date($set->due_date);
+		$userSet->answer_date($set->answer_date);
+	}
 	$db->putUserSet($userSet);
 
-	$globalData->{ $self->{id} }--;
-	$globalUserAchievement->frozen_hash(nfreeze_base64($globalData));
-	$db->putGlobalUserAchievement($globalUserAchievement);
-
-	return;
+	return $c->maketext(
+		'Close date of this assignment extended by 48 hours to [_1].',
+		$c->formatDateTime($set->due_date, $c->ce->{studentDateDisplayFormat})
+	);
 }
 
 1;
diff --git a/lib/WeBWorK/AchievementItems/SuperExtendReducedDate.pm b/lib/WeBWorK/AchievementItems/SuperExtendReducedDate.pm
new file mode 100644
index 0000000000..3f2361b835
--- /dev/null
+++ b/lib/WeBWorK/AchievementItems/SuperExtendReducedDate.pm
@@ -0,0 +1,69 @@
+package WeBWorK::AchievementItems::SuperExtendReducedDate;
+use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
+
+# Item to extend a close date by 48 hours.
+
+use WeBWorK::Utils           qw(x);
+use WeBWorK::Utils::DateTime qw(between);
+
+use constant TWO_DAYS => 172800;
+
+sub new ($class) {
+	return bless {
+		id          => 'SuperExtendReducedDate',
+		name        => x('Scroll of Longevity'),
+		description => x(
+			'Adds 48 hours to the reduced scoring date of an assignment.  You will have to resubmit '
+				. 'any problems that have already been penalized to earn full credit.  You cannot '
+				. 'extend the reduced scoring date beyond the due date of an assignment.'
+		)
+	}, $class;
+}
+
+sub can_use ($self, $set, $records) {
+	return 0
+		unless $set->assignment_type eq 'default'
+		&& $set->enable_reduced_scoring
+		&& $set->reduced_scoring_date
+		&& $set->reduced_scoring_date < $set->due_date;
+
+	$self->{new_date} = $set->reduced_scoring_date + TWO_DAYS;
+	$self->{new_date} = $set->due_date if $set->due_date < $self->{new_date};
+	return between($set->open_date, $self->{new_date});
+}
+
+sub print_form ($self, $set, $records, $c) {
+	return $c->tag(
+		'p',
+		$c->maketext(
+			q{This item won't work unless your instructor enables the reduced scoring feature.  }
+				. 'Let your instructor know that you recieved this message.'
+		)
+	) unless $c->{ce}->{pg}{ansEvalDefaults}{enableReducedScoring};
+
+	return $c->tag(
+		'p',
+		$c->maketext(
+			'Extend the reduced scoring date to [_1] (an additional 48 hours).',
+			$c->formatDateTime($self->{new_date}, $c->ce->{studentDateDisplayFormat})
+		)
+	);
+}
+
+sub use_item ($self, $set, $records, $c) {
+	return '' unless $c->{ce}->{pg}{ansEvalDefaults}{enableReducedScoring};
+
+	my $db      = $c->db;
+	my $userSet = $db->getUserSet($set->user_id, $set->set_id);
+
+	$set->reduced_scoring_date($self->{new_date});
+	$userSet->reduced_scoring_date($set->reduced_scoring_date);
+	$db->putUserSet($userSet);
+
+	return $c->maketext(
+		'Reduced scoring date of this assignment extended by 48 hours to [_1].',
+		$c->formatDateTime($self->{new_date}, $c->ce->{studentDateDisplayFormat})
+	);
+}
+
+1;
diff --git a/lib/WeBWorK/AchievementItems/Surprise.pm b/lib/WeBWorK/AchievementItems/Surprise.pm
index 299634df28..b598b75352 100644
--- a/lib/WeBWorK/AchievementItems/Surprise.pm
+++ b/lib/WeBWorK/AchievementItems/Surprise.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::AchievementItems::Surprise;
 use Mojo::Base 'WeBWorK::AchievementItems', -signatures;
 
@@ -28,10 +13,18 @@ sub new ($class) {
 	}, $class;
 }
 
-sub print_form ($self, $sets, $setProblemIds, $c) {
-	# The form opens the file "suprise_message.txt" in the achievements
-	# folder and prints the contents of the file.
+# Override to not print number of items that remain.
+sub remaining_title ($self, $c) {
+	return $c->maketext($self->name);
+}
 
+sub can_use ($self, $set, $records) { return 1; }
+
+sub print_form ($self, $set, $records, $c) {
+	$self->{hideUseButton} = 1;
+
+	# The form opens the file "surprise_message.txt" in the achievements
+	# folder and prints the contents of the file.
 	open my $MESSAGE, '<', "$c->{ce}{courseDirs}{achievements}/surprise_message.txt"
 		or return $c->tag('p', $c->maketext(q{I couldn't find the file [ACHIEVEMENT_DIR]/surprise_message.txt!}));
 	local $/ = undef;
@@ -41,7 +34,7 @@ sub print_form ($self, $sets, $setProblemIds, $c) {
 	return $c->tag('div', $c->b($message));
 }
 
-sub use_item ($self, $userName, $c) {
+sub use_item ($self, $set, $records, $c) {
 	# This doesn't do anything.
 }
 
diff --git a/lib/WeBWorK/Authen.pm b/lib/WeBWorK/Authen.pm
index df77a42cfb..626eff938c 100644
--- a/lib/WeBWorK/Authen.pm
+++ b/lib/WeBWorK/Authen.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen;
 
 =head1 NAME
@@ -52,10 +37,10 @@ use warnings;
 
 use Date::Format;
 use Scalar::Util qw(weaken);
-use Mojo::Util qw(b64_encode b64_decode);
+use Mojo::Util   qw(b64_encode b64_decode);
 
 use WeBWorK::Debug;
-use WeBWorK::Utils qw(x runtime_use);
+use WeBWorK::Utils       qw(x runtime_use utf8Crypt);
 use WeBWorK::Utils::Logs qw(writeCourseLog);
 use WeBWorK::Utils::TOTP;
 use WeBWorK::Localize;
@@ -94,29 +79,21 @@ sub class {
 	my ($ce, $type) = @_;
 
 	if (exists $ce->{authen}{$type}) {
-		if (ref $ce->{authen}{$type} eq "ARRAY") {
+		if (ref $ce->{authen}{$type} eq 'ARRAY') {
 			my $authen_type = shift @{ $ce->{authen}{$type} };
-			if (ref($authen_type) eq "HASH") {
-				if (exists $authen_type->{ $ce->{dbLayoutName} }) {
-					return $authen_type->{ $ce->{dbLayoutName} };
-				} elsif (exists $authen_type->{"*"}) {
-					return $authen_type->{"*"};
-				} else {
-					die "authentication type '$type' in the course environment has no entry for db layout '",
-						$ce->{dbLayoutName}, "' and no default entry (*)";
-				}
+			if (ref($authen_type) eq 'HASH') {
+				# Basic backwards compatibility.
+				return $authen_type->{'*'}        if exists $authen_type->{'*'};
+				return $authen_type->{sql_single} if exists $authen_type->{sql_single};
+				die 'Unsupported authentication module format in the course environment.';
 			} else {
 				return $authen_type;
 			}
-		} elsif (ref $ce->{authen}{$type} eq "HASH") {
-			if (exists $ce->{authen}{$type}{ $ce->{dbLayoutName} }) {
-				return $ce->{authen}{$type}{ $ce->{dbLayoutName} };
-			} elsif (exists $ce->{authen}{$type}{"*"}) {
-				return $ce->{authen}{$type}{"*"};
-			} else {
-				die "authentication type '$type' in the course environment has no entry for db layout '",
-					$ce->{dbLayoutName}, "' and no default entry (*)";
-			}
+		} elsif (ref $ce->{authen}{$type} eq 'HASH') {
+			# Basic backwards compatibility.
+			return $ce->{authen}{$type}{'*'}        if exists $ce->{authen}{$type}{'*'};
+			return $ce->{authen}{$type}{sql_single} if exists $ce->{authen}{$type}{sql_single};
+			die 'Unsupported authentication module format in the course environment.';
 		} else {
 			return $ce->{authen}{$type};
 		}
@@ -130,9 +107,7 @@ sub call_next_authen_method {
 	my $c    = $self->{c};
 	my $ce   = $c->{ce};
 
-	my $user_authen_module =
-		WeBWorK::Authen::class($ce, $ce->{courseName} eq $ce->{admin_course_id} ? 'admin_module' : 'user_module');
-
+	my $user_authen_module = class($ce, $ce->{courseName} eq $ce->{admin_course_id} ? 'admin_module' : 'user_module');
 	if (!defined $user_authen_module || $user_authen_module eq '') {
 		$self->{error} = $c->maketext(
 			"No authentication method found for your request.  If this recurs, please speak with your instructor.");
@@ -633,7 +608,7 @@ sub checkPassword {
 	my $Password = $db->getPassword($userID);
 	if (defined $Password) {
 		# Check against the password in the database.
-		my $possibleCryptPassword = crypt $possibleClearPassword, $Password->password;
+		my $possibleCryptPassword = utf8Crypt($possibleClearPassword, $Password->password);
 		my $dbPassword            = $Password->password;
 		# This next line explicitly insures that blank or null passwords from the database can never succeed in matching
 		# an entered password.  This also rejects cases when the database has a crypted password which matches a
diff --git a/lib/WeBWorK/Authen/Basic_TheLastOption.pm b/lib/WeBWorK/Authen/Basic_TheLastOption.pm
index 4acaf4f4d7..4d431b4e10 100644
--- a/lib/WeBWorK/Authen/Basic_TheLastOption.pm
+++ b/lib/WeBWorK/Authen/Basic_TheLastOption.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::Basic_TheLastOption;
 use base qw/WeBWorK::Authen/;
 
diff --git a/lib/WeBWorK/Authen/CAS.pm b/lib/WeBWorK/Authen/CAS.pm
index 338fd5f2ee..1c3280dd82 100644
--- a/lib/WeBWorK/Authen/CAS.pm
+++ b/lib/WeBWorK/Authen/CAS.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::CAS;
 use base qw/WeBWorK::Authen/;
 
diff --git a/lib/WeBWorK/Authen/Cosign.pm b/lib/WeBWorK/Authen/Cosign.pm
index 161792f4bb..3bdae8976c 100644
--- a/lib/WeBWorK/Authen/Cosign.pm
+++ b/lib/WeBWorK/Authen/Cosign.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::Cosign;
 use base qw/WeBWorK::Authen/;
 
diff --git a/lib/WeBWorK/Authen/LDAP.pm b/lib/WeBWorK/Authen/LDAP.pm
index af2e5b83ef..b408d24a83 100644
--- a/lib/WeBWorK/Authen/LDAP.pm
+++ b/lib/WeBWorK/Authen/LDAP.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::LDAP;
 use base qw/WeBWorK::Authen/;
 
@@ -20,7 +5,7 @@ use strict;
 use warnings;
 
 use WeBWorK::Debug qw(debug);
-use Net::LDAP qw(LDAP_INVALID_CREDENTIALS);
+use Net::LDAP      qw(LDAP_INVALID_CREDENTIALS);
 
 sub checkPassword {
 	my ($self, $userID, $possibleClearPassword) = @_;
diff --git a/lib/WeBWorK/Authen/LTI/GradePassback.pm b/lib/WeBWorK/Authen/LTI/GradePassback.pm
new file mode 100644
index 0000000000..f1f91f4d48
--- /dev/null
+++ b/lib/WeBWorK/Authen/LTI/GradePassback.pm
@@ -0,0 +1,170 @@
+package WeBWorK::Authen::LTI::GradePassback;
+use Mojo::Base 'Exporter', -signatures, -async_await;
+
+=head1 NAME
+
+WeBWorK::Authen::LTI::GradePassback - Grade passback utilities for LTI authentication
+
+=cut
+
+use WeBWorK::Utils::DateTime qw(after before);
+use WeBWorK::Utils::Sets     qw(grade_set grade_gateway);
+
+our @EXPORT_OK = qw(massUpdate passbackGradeOnSubmit getSetPassbackScore);
+
+# These must be required and not used, and must be after the exports are defined above.
+# Otherwise this will create a circular dependency with the SubmitGrade modules.
+require WeBWorK::Authen::LTIAdvanced::SubmitGrade;
+require WeBWorK::Authen::LTIAdvantage::SubmitGrade;
+
+# Perform a mass update of all grades.  This is all user grades for course grade mode and all user set grades for
+# homework grade mode if $manual_update is false.  Otherwise what is updated is determined by a combination of the grade
+# mode and the useriD and setID parameters.  Note that the only required parameter is $c which should be a
+# WeBWorK::Controller object with a valid course environment and database.
+sub massUpdate ($c, $manual_update = 0, $userID = undef, $setID = undef) {
+	my $ce = $c->ce;
+	my $db = $c->db;
+
+	# Sanity check.
+	unless (ref($ce)) {
+		warn('course environment is not defined');
+		return;
+	}
+	unless (ref($db)) {
+		warn('database reference is not defined');
+		return;
+	}
+
+	# Only run an automatic update if the time interval has passed.
+	if (!$manual_update) {
+		my $lastUpdate     = $db->getSettingValue('LTILastUpdate') || 0;
+		my $updateInterval = $ce->{LTIMassUpdateInterval} // -1;
+		return unless ($updateInterval != -1 && time - $lastUpdate > $updateInterval);
+		$db->setSettingValue('LTILastUpdate', time);
+	}
+
+	# Send warning if debug_lti_grade_passback is set.
+	if ($ce->{debug_lti_grade_passback}) {
+		if ($setID && $userID && $ce->{LTIGradeMode} eq 'homework') {
+			warn "LTI Mass Update: Queueing grade update for user $userID and set $setID.\n";
+		} elsif ($setID && $ce->{LTIGradeMode} eq 'homework') {
+			warn "LTI Mass Update: Queueing grade update for all users assigned to set $setID.\n";
+		} elsif ($userID) {
+			warn "LTI Mass Update: Queueing grade update of all sets assigned to user $userID.\n";
+		} else {
+			warn "LTI Mass Update: Queueing grade update for all sets and users.\n";
+		}
+	}
+
+	$c->minion->enqueue(lti_mass_update => [ $userID, $setID ], { notes => { courseID => $ce->{courseName} } });
+
+	return;
+}
+
+async sub passbackGradeOnSubmit ($c, $userID, $set) {
+	my $ce = $c->ce;
+
+	my $LMSname = $ce->{LTI}{ $ce->{LTIVersion} }{LMS_name};
+
+	if ($ce->{LTIGradeOnSubmit}) {
+		my $LTIGradeResult = 0;
+
+		my $grader =
+			$ce->{LTIVersion} eq 'v1p1'
+			? WeBWorK::Authen::LTIAdvanced::SubmitGrade->new($c)
+			: WeBWorK::Authen::LTIAdvantage::SubmitGrade->new($c);
+
+		if ($ce->{LTIGradeMode} eq 'course') {
+			$LTIGradeResult = await $grader->submit_course_grade($userID, $set);
+		} elsif ($ce->{LTIGradeMode} eq 'homework') {
+			$LTIGradeResult = await $grader->submit_set_grade($userID, $set->set_id, $set);
+		}
+		if ($LTIGradeResult == 0) {
+			return $c->maketext('Your score was not successfully sent to [_1].', $LMSname);
+		} elsif ($LTIGradeResult > 0) {
+			return $c->maketext('Your score was successfully sent to [_1].', $LMSname);
+		} elsif ($LTIGradeResult < 0) {
+			return $c->maketext('Your score will be sent to [_1] at a later time.', $LMSname);
+		}
+	} elsif ($ce->{LTIMassUpdateInterval} > 0) {
+		if ($ce->{LTIMassUpdateInterval} < 120) {
+			return $c->maketext('Scores are sent to [_1] every [quant,_2,second].',
+				$LMSname, $ce->{LTIMassUpdateInterval});
+		} elsif ($ce->{LTIMassUpdateInterval} < 7200) {
+			return $c->maketext('Scores are sent to [_1] every [quant,_2,minute].',
+				$LMSname, int($ce->{LTIMassUpdateInterval} / 60 + 0.99));
+		} else {
+			return $c->maketext('Scores are sent to [_1] every [quant,_2,hour].',
+				$LMSname, int($ce->{LTIMassUpdateInterval} / 3600 + 0.9999));
+		}
+	}
+}
+
+sub setAttempted ($problems, $setVersions = undef) {
+	return 0 unless ref($problems) eq 'ARRAY';
+
+	# If this is a test with set versions, then it counts as "attempted" if there is more than one set version.
+	return 1 if ref($setVersions) eq 'ARRAY' && @$setVersions > 1;
+
+	for (@$problems) {
+		return 1 if $_->attempted || $_->status > 0;
+	}
+	return 0;
+}
+
+sub earliestGatewayDate ($ce, $userSet, $setVersions) {
+	# If there are no versions, use the template's date.
+	return getLTISendScoresAfterDate($userSet, $ce) unless ref($setVersions) eq 'ARRAY';
+
+	# Otherwise, use the earliest date among versions.
+	my $earliest_date = -1;
+	for (@$setVersions) {
+		my $versionedSetDate = getLTISendScoresAfterDate($_, $ce);
+		$earliest_date = $versionedSetDate if $earliest_date == -1 || $versionedSetDate < $earliest_date;
+	}
+	return $earliest_date;
+}
+
+sub getLTISendScoresAfterDate ($set, $ce) {
+	if ($ce->{LTISendScoresAfterDate} eq 'open_date') {
+		return $set->open_date;
+	} elsif ($ce->{LTISendScoresAfterDate} eq 'reduced_scoring_date') {
+		return ($ce->{pg}{ansEvalDefaults}{enableReducedScoring}
+				&& $set->enable_reduced_scoring
+				&& $set->reduced_scoring_date) ? $set->reduced_scoring_date : $set->due_date;
+	} elsif ($ce->{LTISendScoresAfterDate} eq 'due_date') {
+		return $set->due_date;
+	} elsif ($ce->{LTISendScoresAfterDate} eq 'answer_date') {
+		return $set->answer_date;
+	}
+}
+
+# Returns a reference to hash with the keys totalRight, total, and score if the
+# set has met the conditions for grade pass back to occur, and undef otherwise.
+sub getSetPassbackScore ($db, $ce, $userID, $userSet, $gradingSubmission = 0) {
+	my ($totalRight, $total, $problemRecords, $setVersions) =
+		$userSet->assignment_type =~ /gateway/
+		? grade_gateway($db, $userSet->set_id, $userID)
+		: grade_set($db, $userSet, $userID);
+
+	my $return = { totalRight => $totalRight, total => $total, score => $total ? $totalRight / $total : 0 };
+
+	return $return if $gradingSubmission && $ce->{LTISendGradesEarlyThreshold} eq 'attempted';
+
+	my $criticalDate =
+		$ce->{LTISendScoresAfterDate} ne 'never'
+		? ($userSet->assignment_type =~ /gateway/
+			? earliestGatewayDate($ce, $userSet, $setVersions)
+			: getLTISendScoresAfterDate($userSet, $ce))
+		: undef;
+
+	return $return
+		if ($criticalDate && after($criticalDate))
+		|| ($ce->{LTISendGradesEarlyThreshold} eq 'attempted' && setAttempted($problemRecords, $setVersions))
+		|| ($ce->{LTISendGradesEarlyThreshold} ne 'attempted'
+			&& $return->{score} >= $ce->{LTISendGradesEarlyThreshold});
+
+	return;
+}
+
+1;
diff --git a/lib/WeBWorK/Authen/LTI/MassUpdate.pm b/lib/WeBWorK/Authen/LTI/MassUpdate.pm
deleted file mode 100644
index 103498a765..0000000000
--- a/lib/WeBWorK/Authen/LTI/MassUpdate.pm
+++ /dev/null
@@ -1,71 +0,0 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
-package WeBWorK::Authen::LTI::MassUpdate;
-use Mojo::Base 'Exporter', -signatures;
-
-=head1 NAME
-
-WeBWorK::Authen::LTI::MassUpdate - Mass update grades to the LMS with LTI authentication
-
-=cut
-
-our @EXPORT_OK = qw(mass_update);
-
-# Perform a mass update of all grades.  This is all user grades for course grade mode and all user set grades for
-# homework grade mode if $manual_update is false.  Otherwise what is updated is determined by a combination of the grade
-# mode and the useriD and setID parameters.  Note that the only required parameter is $c which should be a
-# WeBWorK::Controller object with a valid course environment and database.
-sub mass_update ($c, $manual_update = 0, $userID = undef, $setID = undef) {
-	my $ce = $c->ce;
-	my $db = $c->db;
-
-	# Sanity check.
-	unless (ref($ce)) {
-		warn('course environment is not defined');
-		return;
-	}
-	unless (ref($db)) {
-		warn('database reference is not defined');
-		return;
-	}
-
-	# Only run an automatic update if the time interval has passed.
-	if (!$manual_update) {
-		my $lastUpdate     = $db->getSettingValue('LTILastUpdate') || 0;
-		my $updateInterval = $ce->{LTIMassUpdateInterval} // -1;
-		return unless ($updateInterval != -1 && time - $lastUpdate > $updateInterval);
-		$db->setSettingValue('LTILastUpdate', time);
-	}
-
-	# Send warning if debug_lti_grade_passback is set.
-	if ($ce->{debug_lti_grade_passback}) {
-		if ($setID && $userID && $ce->{LTIGradeMode} eq 'homework') {
-			warn "LTI Mass Update: Queueing grade update for user $userID and set $setID.\n";
-		} elsif ($setID && $ce->{LTIGradeMode} eq 'homework') {
-			warn "LTI Mass Update: Queueing grade update for all users assigned to set $setID.\n";
-		} elsif ($userID) {
-			warn "LTI Mass Update: Queueing grade update of all sets assigned to user $userID.\n";
-		} else {
-			warn "LTI Mass Update: Queueing grade update for all sets and users.\n";
-		}
-	}
-
-	$c->minion->enqueue(lti_mass_update => [ $userID, $setID ], { notes => { courseID => $ce->{courseName} } });
-
-	return;
-}
-
-1;
diff --git a/lib/WeBWorK/Authen/LTIAdvanced.pm b/lib/WeBWorK/Authen/LTIAdvanced.pm
index 8234abbc02..0bd4706b3a 100644
--- a/lib/WeBWorK/Authen/LTIAdvanced.pm
+++ b/lib/WeBWorK/Authen/LTIAdvanced.pm
@@ -1,18 +1,3 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::LTIAdvanced;
 use base qw/WeBWorK::Authen/;
 
@@ -33,36 +18,13 @@ use URI::Escape;
 use Net::OAuth;
 
 use WeBWorK::Debug;
-use WeBWorK::Utils::DateTime qw(formatDateTime);
+use WeBWorK::Utils::DateTime   qw(formatDateTime);
 use WeBWorK::Utils::Instructor qw(assignSetToUser);
 use WeBWorK::Localize;
 use WeBWorK::Authen::LTIAdvanced::Nonce;
 
 $Net::OAuth::PROTOCOL_VERSION = Net::OAuth::PROTOCOL_VERSION_1_0A;
 
-=head1 CONSTRUCTOR
-
-=over
-
-=item new($c)
-
-Instantiates a new WeBWorK::Authen object for the given WeBWorK::Controller ($c).
-
-=cut
-
-sub new {
-	my ($invocant, $c) = @_;
-	my $class = ref($invocant) || $invocant;
-	my $self  = { c => $c, };
-	#initialize
-	bless $self, $class;
-	return $self;
-}
-
-=back
-
-=cut
-
 ## this is only overridden for debug logging
 #sub verify {
 #	debug("BEGIN LTIAdvanced VERIFY");
@@ -238,7 +200,7 @@ sub get_credentials {
 			[ 'oauth_signature',    'oauth_signature' ],
 			[ 'oauth_nonce',        'oauth_nonce' ],
 			[ 'oauth_timestamp',    'oauth_timestamp' ],
-			[ 'section',            'custom_section' ],
+			[ 'section',            'context_label' ],
 			[ 'recitation',         'custom_recitation' ],
 		);
 
diff --git a/lib/WeBWorK/Authen/LTIAdvanced/Nonce.pm b/lib/WeBWorK/Authen/LTIAdvanced/Nonce.pm
index 883f954a43..0a94c57ba3 100644
--- a/lib/WeBWorK/Authen/LTIAdvanced/Nonce.pm
+++ b/lib/WeBWorK/Authen/LTIAdvanced/Nonce.pm
@@ -1,18 +1,3 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::LTIAdvanced::Nonce;
 
 use strict;
diff --git a/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm b/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm
index a3ec411f47..26aa71071d 100644
--- a/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm
+++ b/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm
@@ -1,18 +1,3 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::LTIAdvanced::SubmitGrade;
 
 =head1 NAME
@@ -29,8 +14,9 @@ use UUID::Tiny ':std';
 use Digest::SHA qw(sha1_base64);
 
 use WeBWorK::Debug;
-use WeBWorK::Utils qw(wwRound);
-use WeBWorK::Utils::Sets qw(grade_set grade_gateway grade_all_sets);
+use WeBWorK::Utils                      qw(wwRound);
+use WeBWorK::Utils::Sets                qw(grade_all_sets);
+use WeBWorK::Authen::LTI::GradePassback qw(getSetPassbackScore);
 
 # This package contains utilities for submitting grades to the LMS
 sub new ($invocant, $c, $post_processing_mode = 0) {
@@ -115,7 +101,7 @@ sub update_sourcedid ($self, $userID) {
 
 # Computes and submits the course grade for userID to the LMS.
 # The course grade is the average of all sets assigned to the user.
-async sub submit_course_grade ($self, $userID) {
+async sub submit_course_grade ($self, $userID, $submittedSet = undef) {
 	my $c  = $self->{c};
 	my $ce = $c->{ce};
 	my $db = $c->{db};
@@ -123,16 +109,36 @@ async sub submit_course_grade ($self, $userID) {
 	my $user = $db->getUser($userID);
 	return 0 unless $user;
 
-	$self->warning("submitting all grades for user: $userID")
+	$self->warning("Preparing to submit overall course grade to LMS for user $userID.")
 		if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode};
-	$self->warning("lis_source_did is not available for user: $userID")
-		if !$user->lis_source_did && ($ce->{debug_lti_grade_passback} || $self->{post_processing_mode});
 
-	return await $self->submit_grade($user->lis_source_did, scalar(grade_all_sets($db, $userID)));
+	unless ($user->lis_source_did) {
+		$self->warning("lis_source_did is not available for this user")
+			if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode};
+		return 0;
+	}
+
+	if ($submittedSet && !getSetPassbackScore($db, $ce, $userID, $submittedSet, 1)) {
+		$self->warning("Set's critical date has not yet passed, and user has not yet met the threshold to send set's "
+				. 'score early. Not submitting grade.');
+		return -1;
+	}
+
+	my ($courseTotalRight, $courseTotal, $includedSets) = grade_all_sets($db, $ce, $userID, \&getSetPassbackScore);
+	if (@$includedSets) {
+		$self->warning(
+			"Submitting overall score for user $userID for sets: " . join(', ', map { $_->set_id } @$includedSets))
+			if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode};
+		my $score = $courseTotal ? $courseTotalRight / $courseTotal : 0;
+		return await $self->submit_grade($user->lis_source_did, $score);
+	} else {
+		$self->warning("No sets for user $userID meet criteria to be included in course grade calculation.");
+		return -1;
+	}
 }
 
 # Computes and submits the set grade for $userID and $setID to the LMS.  For gateways the best score is used.
-async sub submit_set_grade ($self, $userID, $setID) {
+async sub submit_set_grade ($self, $userID, $setID, $submittedSet = undef) {
 	my $c  = $self->{c};
 	my $ce = $c->{ce};
 	my $db = $c->{db};
@@ -140,21 +146,24 @@ async sub submit_set_grade ($self, $userID, $setID) {
 	my $user = $db->getUser($userID);
 	return 0 unless $user;
 
-	my $userSet = $db->getMergedSet($userID, $setID);
-
-	$self->warning("Submitting grade for user $userID and set $setID.")
+	$self->warning("Preparing to submit grade to LMS for user $userID and set $setID.")
 		if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode};
-	$self->warning('lis_source_did is not available for this set.')
-		if !$userSet->lis_source_did && ($ce->{debug_lti_grade_passback} || $self->{post_processing_mode});
-
-	return await $self->submit_grade(
-		$userSet->lis_source_did,
-		scalar(
-			$userSet->assignment_type =~ /gateway/
-			? grade_gateway($db, $userSet, $userSet->set_id, $userID)
-			: grade_set($db, $userSet, $userID, 0)
-		)
-	);
+
+	my $userSet = $submittedSet // $db->getMergedSet($userID, $setID);
+	unless ($userSet->lis_source_did) {
+		$self->warning('lis_source_did is not available for this set.')
+			if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode};
+		return 0;
+	}
+
+	my $score = getSetPassbackScore($db, $ce, $userID, $userSet, !$self->{post_processing_mode});
+	unless ($score) {
+		$self->warning("Set's critical date has not yet passed, and user has not yet met the threshold to send set's "
+				. 'score early. Not submitting grade.');
+		return -1;
+	}
+
+	return await $self->submit_grade($userSet->lis_source_did, $score->{score});
 }
 
 # Submits a score of $score to the lms with $sourcedid as the identifier.
@@ -165,9 +174,6 @@ async sub submit_grade ($self, $sourcedid, $score) {
 
 	$score = wwRound(2, $score);
 
-	# Fail gracefully.  Some users, like instructors, may not actually have a sourcedid.
-	return 0 if !$sourcedid;
-
 	my $request_url = $db->getSettingValue('lis_outcome_service_url');
 	if (!$request_url) {
 		$self->warning('Cannot send/retrieve grades to/from the LMS, no lis_outcome_service_url');
@@ -278,50 +284,47 @@ EOS
 			$content =~ /\s*(\w+)\s*<\/imsx_codeMajor>/;
 			my $message = $1;
 			if ($message ne 'success') {
-				$self->warning(
-					'Unable to retrieve prior grade from LMS. Note that if your server time is not correct, '
-						. 'this may fail for reasons which are less than obvious from the error messages. Error: '
-						. $message);
-				debug('Unable to retrieve prior grade from LMS. Note that if your server time is not correct, '
-						. 'this may fail for reasons which are less than obvious from the error messages. Error: '
-						. $message);
+				$self->warning('Unable to retrieve prior grade from LMS. Error: ' . $message);
+				debug('Unable to retrieve prior grade from LMS. Error: ' . $message);
 				return 0;
 			} else {
-				my $oldScore;
+				my $priorScore;
 				# Possibly no score yet.
 				if ($content =~ //) {
-					$oldScore = '';
+					$priorScore = '';
 				} else {
 					$content =~ /\s*(\S+)\s*<\/textString>/;
-					$oldScore = $1;
+					$priorScore = $1;
 				}
-				# Do not update the score if no change.
-				if ($oldScore eq 'success') {
-					# Blackboard seems to return this when there is no prior grade.
-					# See: https://webwork.maa.org/moodle/mod/forum/discuss.php?d=5002
-					debug("LMS grade will be updated. sourcedid: $sourcedid; Old score: $oldScore; New score: $score")
-						if $ce->{debug_lti_grade_passback};
-				} elsif ($oldScore ne '' && abs($score - $oldScore) < 0.001) {
+
+				# Blackboard seems to return this when there is no prior grade.
+				# See: https://webwork.maa.org/moodle/mod/forum/discuss.php?d=5002
+				$priorScore = '' if $priorScore eq 'success';
+
+				# Do not update the score if there is no significant change. Note that the cases where the webwork score
+				# is exactly 1 and the LMS score is not exactly 1, and the case where the webwork score is 0 and the LMS
+				# score is not set are considered significant changes.
+				if (abs($score - ($priorScore || 0)) < 0.001
+					&& ($score != 1 || $priorScore == 1)
+					&& ($score != 0 || $priorScore ne ''))
+				{
 					# LMS has essentially the same score, no reason to update it
-					debug("LMS grade will NOT be updated - grade unchanges. Old score: $oldScore; New score: $score")
+					debug('LMS grade will NOT be updated - grade has not significantly changed. '
+							. "Old score: $priorScore; New score: $score")
 						if $ce->{debug_lti_grade_passback};
-					$self->warning('LMS grade will NOT be updated - grade unchanged. '
-							. "Old score: $oldScore; New score: $score")
+					$self->warning('LMS grade will NOT be updated - grade has not significantly changed. '
+							. "Old score: $priorScore; New score: $score")
 						if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode};
 					return 1;
 				} else {
-					debug("LMS grade will be updated. sourcedid: $sourcedid; Old score: $oldScore; New score: $score")
+					debug("LMS grade will be updated. sourcedid: $sourcedid; Old score: $priorScore; New score: $score")
 						if $ce->{debug_lti_grade_passback};
 				}
 			}
 		} else {
-			$self->warning('Unable to retrieve prior grade from LMS. Note that if your server time is not correct, '
-					. 'this may fail for reasons which are less than obvious from the error messages. Error: '
-					. $response->message)
+			$self->warning('Unable to retrieve prior grade from LMS. Error: ' . $response->message)
 				if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode};
-			debug('Unable to retrieve prior grade from LMS. Note that if your server time is not correct, '
-					. 'this may fail for reasons which are less than obvious from the error messages. Error: '
-					. $response->message);
+			debug('Unable to retrieve prior grade from LMS. Error: ' . $response->message);
 			debug($response->body);
 			return 0;
 		}
diff --git a/lib/WeBWorK/Authen/LTIAdvantage.pm b/lib/WeBWorK/Authen/LTIAdvantage.pm
index 863dcd5481..22f6c0b651 100644
--- a/lib/WeBWorK/Authen/LTIAdvantage.pm
+++ b/lib/WeBWorK/Authen/LTIAdvantage.pm
@@ -1,18 +1,3 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::LTIAdvantage;
 use parent qw(WeBWorK::Authen);
 
@@ -29,26 +14,10 @@ use experimental 'signatures';
 
 use WeBWorK::Debug;
 use WeBWorK::Localize;
-use WeBWorK::Utils::DateTime qw(formatDateTime);
+use WeBWorK::Utils::DateTime   qw(formatDateTime);
 use WeBWorK::Utils::Instructor qw(assignSetToUser);
 use WeBWorK::Authen::LTIAdvantage::SubmitGrade;
 
-=head1 CONSTRUCTOR
-
-=over
-
-=item new($c)
-
-Instantiates a new WeBWorK::Authen object for the given WeBWorK::Controller ($c).
-
-=back
-
-=cut
-
-sub new ($invocant, $c) {
-	return bless { c => $c }, ref($invocant) || $invocant;
-}
-
 sub request_has_data_for_this_verification_module ($self) {
 	debug('LTIAdvantage has been called for data verification');
 	my $c = $self->{c};
@@ -159,17 +128,26 @@ sub get_credentials ($self) {
 		return $value;
 	};
 
-	if (my $user_id = $extract_claim->($ce->{LTI}{v1p3}{preferred_source_of_username})) {
-		$user_id_source  = $ce->{LTI}{v1p3}{preferred_source_of_username};
-		$type_of_source  = 'preferred_source_of_username';
-		$self->{user_id} = $user_id;
-	}
+	# First check if there is a user with the current LMS user id saved in the lis_source_did column.
+	if ($claims->{sub} && (my $user = ($c->db->getUsersWhere({ lis_source_did => $claims->{sub} }))[0])) {
+		$user_id_source  = 'database';
+		$type_of_source  = 'existing database user';
+		$self->{user_id} = $user->user_id;
+	} else {
+		if (my $user_id = $extract_claim->($ce->{LTI}{v1p3}{preferred_source_of_username})) {
+			$user_id_source  = $ce->{LTI}{v1p3}{preferred_source_of_username};
+			$type_of_source  = "$user_id_source which was preferred_source_of_username";
+			$self->{user_id} = $user_id;
+		}
 
-	# Fallback if necessary
-	if (!defined $self->{user_id} && (my $user_id = $extract_claim->($ce->{LTI}{v1p3}{fallback_source_of_username}))) {
-		$user_id_source  = $ce->{LTI}{v1p3}{fallback_source_of_username};
-		$type_of_source  = 'fallback_source_of_username';
-		$self->{user_id} = $user_id;
+		# Fallback if necessary
+		if (!defined $self->{user_id}
+			&& (my $user_id = $extract_claim->($ce->{LTI}{v1p3}{fallback_source_of_username})))
+		{
+			$user_id_source  = $ce->{LTI}{v1p3}{fallback_source_of_username};
+			$type_of_source  = "$user_id_source which was fallback_source_of_username";
+			$self->{user_id} = $user_id;
+		}
 	}
 
 	if ($self->{user_id}) {
@@ -184,7 +162,7 @@ sub get_credentials ($self) {
 				[ roles      => 'https://purl.imsglobal.org/spec/lti/claim/roles' ],
 				[ last_name  => 'family_name' ],
 				[ first_name => 'given_name' ],
-				[ section    => 'https://purl.imsglobal.org/spec/lti/claim/custom#section' ],
+				[ section    => 'https://purl.imsglobal.org/spec/lti/claim/lis#course_section_sourcedid' ],
 				[ recitation => 'https://purl.imsglobal.org/spec/lti/claim/custom#recitation' ],
 			);
 
@@ -196,7 +174,7 @@ sub get_credentials ($self) {
 		# For setting up it is helpful to print out what is believed to be the user id and address is at this point.
 		if ($ce->{debug_lti_parameters}) {
 			warn "=========== SUMMARY ============\n";
-			warn "User id is |$self->{user_id}| (obtained from $user_id_source which was $type_of_source)\n";
+			warn "User id is |$self->{user_id}| (obtained from $type_of_source)\n";
 			warn "User email address is |$self->{email}|\n";
 			warn "strip_domain_from_email is |", $ce->{LTI}{v1p3}{strip_domain_from_email} // 0, "|\n";
 			warn "Student id is |$self->{student_id}|\n";
@@ -213,7 +191,7 @@ sub get_credentials ($self) {
 		}
 
 		# Save these for later if they are available in the JWT.  It is important that the lti_lms_user_id be updated
-		# with the 'sub' value from the claim.  The value from the state can not entirely be trusted.  In addition, this
+		# with the 'sub' value from the claim.  The value from the state cannot entirely be trusted.  In addition, this
 		# may not be the same as the original login_hint (it is different for Canvas, but the same for Moodle).
 		$c->stash->{lti_lms_user_id} = $claims->{sub};
 		$c->stash->{lti_lms_lineitem} =
@@ -323,8 +301,7 @@ sub authenticate ($self) {
 				"Account creation blocked by block_lti_create_user setting. Did not create user $self->{user_id}.";
 			if ($ce->{debug_lti_parameters}) {
 				warn $c->maketext('Account creation is currently disabled in this course.  '
-						. 'Please speak to your instructor or system administrator.')
-					. "\n";
+						. 'Please speak to your instructor or system administrator.') . "\n";
 			}
 			return 0;
 		} else {
@@ -369,10 +346,15 @@ sub create_user ($self) {
 	# Determine the roles defined for this user defined in the LTI request and assign a permission level on that basis.
 	my @LTIroles = @{ $self->{roles} };
 
-	# Restrict to institution and context roles and remove the purl link portion (ignore system roles).
+	# Restrict to context roles and remove the purl link portion.  System roles are always ignored, but institution
+	# roles are also included if $LTI{v1p3}{AllowInstitutionRoles} = 1.
 	@LTIroles =
 		map {s|^[^#]*#||r}
-		grep {m!^http://purl.imsglobal.org/vocab/lis/v2/(membership|institution\/person)#!} @LTIroles;
+		grep {
+			m!^http://purl.imsglobal.org/vocab/lis/v2/membership#!
+			|| ($ce->{LTI}{v1p3}{AllowInstitutionRoles}
+				&& m!^http://purl.imsglobal.org/vocab/lis/v2/institution/person#!)
+		} @LTIroles;
 
 	if ($ce->{debug_lti_parameters}) {
 		warn "The adjusted LTI roles defined for this user are: \n-- " . join("\n-- ", @LTIroles),
@@ -415,6 +397,7 @@ sub create_user ($self) {
 	$newUser->recitation($self->{recitation} // '');
 	$newUser->comment(formatDateTime(time, 0, $ce->{siteDefaults}{timezone}, $ce->{language}));
 	$newUser->student_id($self->{student_id} // '');
+	$newUser->lis_source_did($c->stash->{lti_lms_user_id}) if $c->stash->{lti_lms_user_id};
 
 	# Allow sites to customize the user.
 	$ce->{LTI}{v1p3}{modify_user}($self, $newUser) if ref($ce->{LTI}{v1p3}{modify_user}) eq 'CODE';
@@ -504,6 +487,7 @@ sub maybe_update_user ($self) {
 		$tempUser->section($self->{section}       // '');
 		$tempUser->recitation($self->{recitation} // '');
 		$tempUser->student_id($self->{student_id} // '');
+		$tempUser->lis_source_did($c->stash->{lti_lms_user_id}) if $c->stash->{lti_lms_user_id};
 
 		# Allow sites to customize the temp user
 		$ce->{LTI}{v1p3}{modify_user}($self, $tempUser) if ref($ce->{LTI}{v1p3}{modify_user}) eq 'CODE';
diff --git a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm
index c7c9418767..6fc66b97f4 100644
--- a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm
+++ b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm
@@ -1,18 +1,3 @@
-###############################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::LTIAdvantage::SubmitGrade;
 
 =head1 NAME
@@ -34,12 +19,13 @@ use Mojo::IOLoop;
 use Crypt::JWT qw(encode_jwt);
 use Crypt::PK::RSA;
 use Math::Random::Secure qw(irand);
-use Digest::SHA qw(sha256_hex);
+use Digest::SHA          qw(sha256_hex);
 use Time::HiRes;
 
 use WeBWorK::Debug;
-use WeBWorK::Utils qw(wwRound);
-use WeBWorK::Utils::Sets qw(grade_set grade_gateway grade_all_sets);
+use WeBWorK::Utils                      qw(wwRound);
+use WeBWorK::Utils::Sets                qw(grade_all_sets);
+use WeBWorK::Authen::LTI::GradePassback qw(getSetPassbackScore);
 
 # This package contains utilities for submitting grades to the LMS via LTI 1.3.
 sub new ($invocant, $c, $post_processing_mode = 0) {
@@ -193,7 +179,7 @@ async sub get_access_token ($self) {
 
 # Computes and submits the course grade for userID to the LMS.
 # The course grade is the sum of all (weighted) problems assigned to the user.
-async sub submit_course_grade ($self, $userID) {
+async sub submit_course_grade ($self, $userID, $submittedSet = undef) {
 	my $c  = $self->{c};
 	my $ce = $c->{ce};
 	my $db = $c->{db};
@@ -201,17 +187,38 @@ async sub submit_course_grade ($self, $userID) {
 	my $user = $db->getUser($userID);
 	return 0 unless $user;
 
+	$self->warning("Preparing to submit overall course grade to LMS for user $userID.");
+
 	my $lineitem = $db->getSettingValue('LTIAdvantageCourseLineitem');
+	unless ($lineitem) {
+		$self->warning('LMS lineitem is not available for the course.');
+		return 0;
+	}
+
+	unless ($user->lis_source_did) {
+		$self->warning('LMS user id is not available for this user.');
+		return 0;
+	}
 
-	$self->warning("Submitting all grades for user $userID");
-	$self->warning('LMS user id is not available for this user.')   unless $user->lis_source_did;
-	$self->warning('LMS lineitem is not available for the course.') unless $lineitem;
+	if ($submittedSet && !getSetPassbackScore($db, $ce, $userID, $submittedSet, 1)) {
+		$self->warning("Set's critical date has not yet passed, and user has not yet met the threshold to send set's "
+				. 'score early. Not submitting grade.');
+		return -1;
+	}
 
-	return await $self->submit_grade($user->lis_source_did, $lineitem, grade_all_sets($db, $userID));
+	my ($courseTotalRight, $courseTotal, $includedSets) = grade_all_sets($db, $ce, $userID, \&getSetPassbackScore);
+	if (@$includedSets) {
+		$self->warning("Submitting overall score for user $userID for sets: "
+				. join(', ', map { $_->set_id } (@$includedSets)));
+		return await $self->submit_grade($user->lis_source_did, $lineitem, $courseTotalRight, $courseTotal);
+	} else {
+		$self->warning("No sets for user $userID meet criteria to be included in course grade calculation.");
+		return -1;
+	}
 }
 
 # Computes and submits the set grade for $userID and $setID to the LMS.  For gateways the best score is used.
-async sub submit_set_grade ($self, $userID, $setID) {
+async sub submit_set_grade ($self, $userID, $setID, $submittedSet = undef) {
 	my $c  = $self->{c};
 	my $ce = $c->{ce};
 	my $db = $c->{db};
@@ -219,16 +226,28 @@ async sub submit_set_grade ($self, $userID, $setID) {
 	my $user = $db->getUser($userID);
 	return 0 unless $user;
 
-	my $userSet = $db->getMergedSet($userID, $setID);
+	$self->warning("Preparing to submit grade to LMS for user $userID and set $setID.");
 
-	$self->warning("Submitting grade for user $userID and set $setID.");
-	$self->warning('LMS user id is not available for this user.') unless $user->lis_source_did;
-	$self->warning('LMS lineitem is not available for this set.') unless $userSet->lis_source_did;
+	unless ($user->lis_source_did) {
+		$self->warning('LMS user id is not available for this user.');
+		return 0;
+	}
+
+	my $userSet = $submittedSet // $db->getMergedSet($userID, $setID);
+	unless ($userSet->lis_source_did) {
+		$self->warning('LMS lineitem is not available for this set.');
+		return 0;
+	}
 
-	return await $self->submit_grade($user->lis_source_did, $userSet->lis_source_did,
-		$userSet->assignment_type =~ /gateway/
-		? grade_gateway($db, $userSet, $userSet->set_id, $userID)
-		: (grade_set($db, $userSet, $userID, 0))[ 0, 1 ]);
+	my $score = getSetPassbackScore($db, $ce, $userID, $userSet, !$self->{post_processing_mode});
+	unless ($score) {
+		$self->warning("Set's critical date has not yet passed, and user has not yet met the threshold to send set's "
+				. 'score early. Not submitting grade.');
+		return -1;
+	}
+
+	return await $self->submit_grade($user->lis_source_did, $userSet->lis_source_did, $score->{totalRight},
+		$score->{total});
 }
 
 # Submits scoreGiven and scoreMaximum to the lms with $sourcedid as the identifier.
@@ -236,7 +255,7 @@ async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum
 	my $c  = $self->{c};
 	my $ce = $c->{ce};
 
-	return 0 unless $LMSuserID && $lineitem && (my $access_token = await $self->get_access_token);
+	return 0 unless (my $access_token = await $self->get_access_token);
 
 	$self->warning('Found data required for submitting grades to LMS.');
 
@@ -270,14 +289,23 @@ async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum
 			return 0;
 		}
 
-		my $priorData  = decode_json($response->body);
-		my $priorScore = @$priorData
-			&& $priorData->[0]{resultMaximum} ? $priorData->[0]{resultScore} / $priorData->[0]{resultMaximum} : 0;
+		my $priorData = decode_json($response->body);
+		my $priorScore =
+			(@$priorData && $priorData->[0]{resultMaximum} && defined $priorData->[0]{resultScore})
+			? $priorData->[0]{resultScore} / $priorData->[0]{resultMaximum}
+			: 0;
+
+		my $score = $scoreMaximum ? $scoreGiven / $scoreMaximum : 0;
 
-		my $score = $scoreGiven / $scoreMaximum;
-		if (abs($score - $priorScore) < 0.001) {
-			$self->warning(
-				"LMS grade will NOT be updated as the grade is unchanged. Old score: $priorScore, New score: $score.");
+		# Do not update the score if there is no significant change. Note that the cases where the webwork score
+		# is exactly 1 and the LMS score is not exactly 1, and the case where the webwork score is 0 and the LMS
+		# score is not set are considered significant changes.
+		if (abs($score - $priorScore) < 0.001
+			&& ($score != 1 || $priorScore == 1)
+			&& ($score != 0 || (@$priorData && defined $priorData->[0]{resultScore})))
+		{
+			$self->warning('LMS grade will NOT be updated as the grade has not significantly changed. '
+					. "Old score: $priorScore, New score: $score.");
 			return 1;
 		}
 
@@ -323,7 +351,7 @@ async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum
 # already exist.  If $private is true then the JSON decoded private key is returned, otherwise the JSON decoded public
 # key is returned as a keyset. If an error occurs in this process then the returned key will be undefined, and the error
 # that was thrown will also be returned. Note that this is not a class method and the only required parameter is $ce
-# which should be a a minimal course environment.  The course environment is only needed to determine the site DATA
+# which should be a minimal course environment.  The course environment is only needed to determine the site DATA
 # directory.
 sub get_site_key ($ce, $private = 0) {
 	my $key;
diff --git a/lib/WeBWorK/Authen/Moodle.pm b/lib/WeBWorK/Authen/Moodle.pm
index 3b04545d1e..58eccc42cd 100644
--- a/lib/WeBWorK/Authen/Moodle.pm
+++ b/lib/WeBWorK/Authen/Moodle.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::Moodle;
 use base qw/WeBWorK::Authen/;
 
diff --git a/lib/WeBWorK/Authen/Proctor.pm b/lib/WeBWorK/Authen/Proctor.pm
index 90e2b318a5..feb4e3a979 100644
--- a/lib/WeBWorK/Authen/Proctor.pm
+++ b/lib/WeBWorK/Authen/Proctor.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::Proctor;
 use base 'WeBWorK::Authen';
 
@@ -25,7 +10,7 @@ WeBWorK::Authen::Proctor - Authenticate gateway test proctors.
 use strict;
 use warnings;
 
-use WeBWorK::Utils qw(x);
+use WeBWorK::Utils     qw(x);
 use WeBWorK::DB::Utils qw(grok_vsetID);
 
 use constant GENERIC_ERROR_MESSAGE => x('Invalid user ID or password.');
@@ -39,7 +24,7 @@ sub verify {
 	my $c    = $self->{c};
 
 	# At this point the usual authentication has already occurred and the user has been verified.  If the
-	# use_grade_auth_proctor option is set to 'No', then proctor authorization is not not needed.  So return
+	# use_grade_auth_proctor option is set to 'No', then proctor authorization is not needed.  So return
 	# 1 here to skip proctor authorization and proceed on to the GatewayQuiz module which will grade the test.
 	if ($c->req->body_params->param('submitAnswers')) {
 		my ($setName, $versionNum) = grok_vsetID($c->stash('setID'));
@@ -96,10 +81,17 @@ sub verify_normal_user {
 	# is 'No', then the verify method will have returned 1, and this never happens.  For an ongoing login session, only
 	# a key with versioned set information is accepted, and that version must match the requested set version.  The set
 	# id will not have a version when opening a new version. For that new proctor credentials are required.
-	if ($self->{login_type} eq 'proctor_login'
-		&& $c->stash('setID') =~ /,v\d+$/
+	if (
+		$self->{login_type} eq 'proctor_login'
 		&& $c->authen->session('proctor_authorization_granted')
-		&& $c->authen->session('proctor_authorization_granted') eq $c->stash('setID'))
+		&& (
+			(
+				$c->stash('setID') =~ /,v\d+$/
+				&& $c->authen->session('proctor_authorization_granted') eq $c->stash('setID')
+			)
+			|| $c->authen->session('acting_proctor')
+		)
+		)
 	{
 		return 1;
 	} else {
diff --git a/lib/WeBWorK/Authen/Saml2.pm b/lib/WeBWorK/Authen/Saml2.pm
new file mode 100644
index 0000000000..254a07e874
--- /dev/null
+++ b/lib/WeBWorK/Authen/Saml2.pm
@@ -0,0 +1,333 @@
+package WeBWorK::Authen::Saml2;
+use Mojo::Base 'WeBWorK::Authen', -signatures;
+
+use Mojo::File qw(path tempfile);
+use Mojo::JSON qw(encode_json);
+use Mojo::UserAgent;
+use Net::SAML2::IdP;
+use Net::SAML2::SP;
+use URN::OASIS::SAML2 qw(BINDING_HTTP_POST BINDING_HTTP_REDIRECT);
+use Net::SAML2::Binding::POST;
+use Net::SAML2::Protocol::Assertion;
+
+use WeBWorK::Debug qw(debug);
+use WeBWorK::Authen::LTIAdvanced::Nonce;
+
+=head1 NAME
+
+WeBWorK::Authen::Saml2 - Authenticate using a SAML2 identity provider.
+
+=cut
+
+sub request_has_data_for_this_verification_module ($self) {
+	my $c = $self->{c};
+
+	# Skip if the bypass_query param is set.
+	if ($c->ce->{saml2}{bypass_query} && $c->param($c->ce->{saml2}{bypass_query})) {
+		debug('Saml2 authen module bypass detected. Going to next authentication module.');
+		return 0;
+	}
+
+	return 1;
+}
+
+sub verify ($self) {
+	my $result = $self->SUPER::verify;
+	my $c      = $self->{c};
+
+	if ($c->current_route eq 'saml2_acs') {
+		# Transfer the saml2_nameid and saml2_session to the webwork session.
+		# These are used to logout of the identity provider if that is configured.
+		$self->session->{saml2_nameid}  = $c->stash->{saml2_nameid}  if $c->stash->{saml2_nameid};
+		$self->session->{saml2_session} = $c->stash->{saml2_session} if $c->stash->{saml2_session};
+
+		# If two factor verification is needed, defer that until after redirecting to the course route.
+		if ($c->stash->{saml2_redirect} && $self->session->{two_factor_verification_needed}) {
+			$self->session->{two_factor_verification_needed_after_redirect} =
+				delete $self->session->{two_factor_verification_needed};
+			return 1;
+		}
+	}
+
+	return $result;
+}
+
+sub do_verify ($self) {
+	my $c  = $self->{c};
+	my $ce = $c->ce;
+
+	$self->{external_auth} = 1 if $ce->two_factor_authentication_enabled && $ce->{saml2}{twoFAOnlyWithBypass};
+
+	if ($c->current_route eq 'saml2_acs') {
+		debug('Verifying Saml2 assertion');
+
+		my $idpCertificateFile = $self->idp(1);
+		unless ($idpCertificateFile) {
+			$c->stash->{authen_error} = $c->maketext(
+				'An internal server error occured. Please contact the system administrator for assistance.');
+			return 0;
+		}
+
+		# Verify that the response is signed by the identity provider and decode it.
+		my $decodedXml = Net::SAML2::Binding::POST->new(cacert => $idpCertificateFile->to_string)
+			->handle_response($c->stash->{saml2}{samlResp});
+		my $assertion = Net::SAML2::Protocol::Assertion->new_from_xml(
+			xml      => $decodedXml,
+			key_file => $self->spKeyFile->to_string
+		);
+
+		# Get the database key containing the authReqId that was generated before redirecting to the identity provider.
+		my $authReqIdKey = $c->db->getKey($assertion->in_response_to);
+		unless ($authReqIdKey) {
+			$c->stash->{authen_error} = $c->maketext('Invalid user ID or password.');
+			debug('Invalid request id in response.  Possible CSFR.');
+			return 0;
+		}
+		eval { $c->db->deleteKey($authReqIdKey->user_id) };    # Delete the key to avoid replay.
+
+		# Verify that the response has the same authReqId which means it's responding to the authentication request
+		# generated by webwork2. This also checks that timestamps are valid.
+		my $valid = $assertion->valid($ce->{saml2}{sp}{entity_id}, $authReqIdKey->user_id);
+		unless ($valid) {
+			$c->stash->{authen_error} = $c->maketext('Invalid user ID or password.');
+			debug('Bad timestamp or issuer');
+			return 0;
+		}
+
+		debug('Got valid response and looking for username.');
+		my $userId = $self->getUserId($ce->{saml2}{sp}{attributes}, $assertion);
+		if ($userId) {
+			debug("Got username $userId");
+
+			$c->authen->{saml2UserId} = $userId;
+			if ($self->SUPER::do_verify) {
+				# The user and key need to be set before systemLink is called.  They are only used if
+				# $session_management_via is 'key'.
+				$c->param('user', $userId);
+				$c->param('key',  $self->{session_key});
+				$c->stash->{saml2_redirect} = $c->systemLink($c->url_for($c->stash->{saml2}{relayState}{url}));
+
+				# Save these in the stash for now.  They will be transferred to the session after it has been created.
+				$c->stash->{saml2_nameid}  = $assertion->nameid;
+				$c->stash->{saml2_session} = $assertion->{session};
+
+				return 1;
+			}
+		}
+		$c->stash->{authen_error} = $c->maketext('User not found in course.');
+		debug('Unauthorized - User not found in ' . $c->stash->{courseID});
+		return 0;
+	}
+
+	# If there is an existing session, then control will be passed to the authen base class.
+	if ($ce->{session_management_via} eq 'session_cookie') {
+		my ($cookieUser) = $self->fetchCookie;
+		$self->{isLoggedIn} = 1 if defined $cookieUser;
+	} elsif ($c->param('user')) {
+		my $key = $c->db->getKey($c->param('user'));
+		$self->{isLoggedIn} = 1 if $key;
+	}
+
+	if ($self->{isLoggedIn}) {
+		debug('User signed in or was previously signed in.  Saml2 passing control back to the authen base class.');
+
+		# There was a successful saml response or the user was already logged in.
+		# So hand off to the authen base class to verify the user and manage the session.
+		my $result = $self->SUPER::do_verify;
+
+		$self->session->{two_factor_verification_needed} =
+			delete $self->session->{two_factor_verification_needed_after_redirect}
+			if $self->session->{two_factor_verification_needed_after_redirect};
+
+		return $result;
+	}
+
+	# This occurs if the user clicks the logout button when the identity provider session has timed out, but the
+	# webwork2 session is still active.  In this case return 0 so that the logged out page is shown anyway.
+	return 0 if $c->current_route eq 'logout';
+
+	# The user doesn't have an existing session, so redirect to the identity provider for login.
+	$self->sendLoginRequest;
+
+	return 0;
+}
+
+sub sp ($self) {
+	my $c = $self->{c};
+	return $c->stash->{sp} if $c->stash->{sp};
+
+	my $ce = $c->ce;
+
+	my $spCertificateFile = path($ce->{saml2}{sp}{certificate_file});
+	$spCertificateFile = $c->app->home->child($spCertificateFile) unless $spCertificateFile->is_abs;
+
+	$c->stash->{sp} = Net::SAML2::SP->new(
+		issuer                     => $ce->{saml2}{sp}{entity_id},
+		url                        => $ce->{server_root_url} . $c->url_for('root'),
+		error_url                  => $ce->{server_root_url} . $c->url_for('saml2_error'),
+		cert                       => $spCertificateFile->to_string,
+		key                        => $self->spKeyFile->to_string,
+		org_contact                => $ce->{saml2}{sp}{org}{contact},
+		org_name                   => $ce->{saml2}{sp}{org}{name},
+		org_url                    => $ce->{saml2}{sp}{org}{url},
+		org_display_name           => $ce->{saml2}{sp}{org}{display_name},
+		assertion_consumer_service => [ {
+			Binding   => BINDING_HTTP_POST,
+			Location  => $ce->{server_root_url} . $c->url_for('saml2_acs'),
+			isDefault => 'true',
+		} ],
+		$ce->{saml2}{sp}{enable_sp_initiated_logout}
+		? (
+			single_logout_service => [ {
+				Binding  => BINDING_HTTP_POST,
+				Location => $ce->{server_root_url} . $c->url_for('saml2_logout')
+			} ]
+			)
+		: ()
+	);
+
+	return $c->stash->{sp};
+}
+
+# The first time this method is executed for a given identity provider, the metadata file is retrieved from the metadata
+# URL.  It is then saved in the $ce->{saml2}{active_idp} subdirectory of $ce->{webworkDirs}{DATA}/Saml2IDPs together
+# with the identity provider's signing key which is extracted from the retrieved metadata.  On later requests the
+# metadata and certificate are used from the saved files.  This prevents the need to retrieve the metadata on every
+# login request.
+sub idp ($self, $ceritificateOnly = 0) {
+	if (!$self->{idp_certificate_file} || !$self->{idp}) {
+		my $ce = $self->{c}->ce;
+
+		my $saml2IDPDir = path("$ce->{webworkDirs}{DATA}/Saml2IDPs")->child($ce->{saml2}{active_idp});
+		$saml2IDPDir->make_path;
+
+		my $metadataXMLFile = $saml2IDPDir->child('metadata.xml');
+		my $certificateFile = $saml2IDPDir->child('cacert.crt');
+
+		if (-r $metadataXMLFile && -r $certificateFile) {
+			$self->{idp} =
+				Net::SAML2::IdP->new_from_xml(xml => $metadataXMLFile->slurp, cacert => $certificateFile->to_string);
+			$self->{idp_certificate_file} = $certificateFile;
+		} else {
+			my $response = Mojo::UserAgent->new->get($ce->{saml2}{idps}{ $ce->{saml2}{active_idp} })->result;
+			if ($response->is_success) {
+				my $metadataXML = $response->body;
+				$metadataXMLFile->spew($metadataXML);
+				$self->{idp} = Net::SAML2::IdP->new_from_xml(xml => $metadataXML);
+				$certificateFile->spew($self->{idp}->cert('signing')->[0]);
+				$self->{idp_certificate_file} = $certificateFile;
+			} else {
+				debug("Unable to retrieve metadata from identity provider $ce->{saml2}{active_idp} with "
+						. "metadata URL $ce->{samle}{idps}{$ce->{saml2}{active_idp}}");
+			}
+		}
+	}
+
+	return $self->{idp_certificate_file} if $ceritificateOnly;
+	return $self->{idp};
+}
+
+sub spKeyFile ($self) {
+	my $c = $self->{c};
+	return $self->{spKeyFile} if $self->{spKeyFile};
+	$self->{spKeyFile} = path($c->ce->{saml2}{sp}{private_key_file});
+	$self->{spKeyFile} = $c->app->home->child($self->{spKeyFile}) unless $self->{spKeyFile}->is_abs;
+	return $self->{spKeyFile};
+}
+
+sub sendLoginRequest ($self) {
+	my $c  = $self->{c};
+	my $ce = $c->ce;
+
+	my $idp = $self->idp;
+	unless ($idp) {
+		$c->stash->{authen_error} =
+			$c->maketext('An internal server error occured. Please contact the system administrator for assistance.');
+		return 0;
+	}
+
+	my $authReq = $self->sp->authn_request($idp->sso_url(BINDING_HTTP_REDIRECT));
+
+	# Get rid of stale request ids in the database. This borrows the maybe_purge_nonces method from the
+	# WeBWorK::Authen::LTIAdvanced::Nonce package.
+	WeBWorK::Authen::LTIAdvanced::Nonce->new($c, '', 0)->maybe_purge_nonces;
+
+	# The request id needs to be stored so that it can be verified in the identity provider response.
+	# This uses the "nonce" hack to store the request id in the key table.
+	my $key = $c->db->newKey({ user_id => $authReq->id, timestamp => time, key => 'nonce' });
+	eval { $c->db->deleteKey($authReq->id) };
+	eval { $c->db->addKey($key) };
+
+	# The second argument of the sign method contains info that the identity provider relays back.
+	# This information is used to send the user to the right place after login.
+	debug('Redirecting user to the identity provider');
+	$self->{redirect} = $self->sp->sso_redirect_binding($idp, 'SAMLRequest')
+		->sign($authReq->as_xml, encode_json({ course => $ce->{courseName}, url => $c->req->url->to_string }));
+	return;
+}
+
+sub logout_user ($self) {
+	my $ce = $self->{c}->ce;
+	if ($ce->{saml2}{sp}{enable_sp_initiated_logout}
+		&& defined $self->session->{saml2_nameid}
+		&& defined $self->session->{saml2_session})
+	{
+		my $idp = $self->idp;
+		return unless $idp;
+
+		my $logoutReq = $self->sp->logout_request(
+			$idp->slo_url(BINDING_HTTP_REDIRECT), $self->session->{saml2_nameid},
+			$idp->format || undef,                $self->session->{saml2_session}
+		);
+
+		debug('Redirecting user to the identity provider for logout');
+		$self->{redirect} = $self->sp->slo_redirect_binding($idp, 'SAMLRequest')
+			->sign($logoutReq->as_xml, encode_json({ course => $ce->{courseName} }));
+	}
+	return;
+}
+
+sub getUserId ($self, $attributeKeys, $assertion) {
+	my $ce = $self->{c}->ce;
+	my $db = $self->{c}->db;
+
+	if ($attributeKeys) {
+		for my $key (@$attributeKeys) {
+			debug("Trying attribute $key for username");
+			my $possibleUserId = $assertion->attributes->{$key}[0];
+			next unless $possibleUserId;
+			if ($db->getUser($possibleUserId)) {
+				debug("Using attribute value for username: $possibleUserId");
+				return $possibleUserId;
+			}
+		}
+	}
+	debug('No username match in attributes. Trying NameID fallback');
+	if ($db->getUser($assertion->nameid)) {
+		debug('Using NameID for username: ' . $assertion->nameid);
+		return $assertion->nameid;
+	}
+	debug('NameID fallback failed. No username found.');
+	return;
+}
+
+sub get_credentials ($self) {
+	if ($self->{saml2UserId}) {
+		# User has been authenticated with the identity provider.
+		$self->{user_id}           = $self->{saml2UserId};
+		$self->{login_type}        = 'normal';
+		$self->{credential_source} = 'SAML2';
+		$self->{initial_login}     = 1;
+		debug('credential source: "SAML2", user: "', $self->{user_id}, '"');
+		return 1;
+	}
+	return $self->SUPER::get_credentials if $self->{isLoggedIn};
+	return 0;
+}
+
+sub authenticate ($self) {
+	# The identity provider handles authentication, so just return 1.
+	return 1;
+}
+
+1;
diff --git a/lib/WeBWorK/Authen/Shibboleth.pm b/lib/WeBWorK/Authen/Shibboleth.pm
index d0d1d419cb..498220f598 100644
--- a/lib/WeBWorK/Authen/Shibboleth.pm
+++ b/lib/WeBWorK/Authen/Shibboleth.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authen::Shibboleth;
 use Mojo::Base 'WeBWorK::Authen', -signatures;
 
diff --git a/lib/WeBWorK/Authz.pm b/lib/WeBWorK/Authz.pm
index 999c00b8ba..07ef5d5abf 100644
--- a/lib/WeBWorK/Authz.pm
+++ b/lib/WeBWorK/Authz.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::Authz;
 
 =head1 NAME
@@ -61,7 +46,7 @@ use warnings;
 use Carp qw/croak/;
 
 use WeBWorK::Utils::DateTime qw(before);
-use WeBWorK::Utils::Sets qw(is_restricted);
+use WeBWorK::Utils::Sets     qw(is_restricted);
 use WeBWorK::Authen::Proctor;
 use Net::IP;
 use Scalar::Util qw(weaken);
@@ -84,7 +69,7 @@ sub new {
 	my ($invocant, $c) = @_;
 	my $class = ref($invocant) || $invocant;
 	my $self  = { c => $c, };
-	#weaken $self->{c};
+	weaken $self->{c};
 
 	$c->{permission_retrieval_error} = 0;
 	bless $self, $class;
@@ -404,7 +389,7 @@ sub checkSet {
 		}
 		# Don't allow versioned sets to be viewed from the problem-list page.
 		if ($node_name eq 'problem_list') {
-			return $c->maketext("Requested version ([_1]) of set '[_2]' can not be directly accessed.", $verNum,
+			return $c->maketext("Requested version ([_1]) of set '[_2]' cannot be directly accessed.", $verNum,
 				$setName);
 		}
 	} else {
@@ -500,11 +485,12 @@ sub checkSet {
 			$ce->{LTI}{ $ce->{LTIVersion} }{LMS_url}
 			? $c->link_to($ce->{LTI}{ $ce->{LTIVersion} }{LMS_name} => $ce->{LTI}{ $ce->{LTIVersion} }{LMS_url})
 			: $ce->{LTI}{ $ce->{LTIVersion} }{LMS_name};
-		return $c->maketext(
+		return $c->b($c->maketext(
 			'You must use your Learning Management System ([_1]) to access this set.  '
 				. 'Try logging in to the Learning Management System and visiting the set from there.',
 			$LMS
-		) unless $set->lis_source_did;
+		))
+			unless $set->lis_source_did;
 	}
 
 	return 0;
diff --git a/lib/WeBWorK/ConfigObject.pm b/lib/WeBWorK/ConfigObject.pm
index 6f23fcf1aa..d9a2c0007f 100644
--- a/lib/WeBWorK/ConfigObject.pm
+++ b/lib/WeBWorK/ConfigObject.pm
@@ -64,8 +64,9 @@ sub entry_widget ($self, $default, $is_secret = 0) {
 	);
 }
 
-sub help_title ($self) { return $self->{c}->maketext('Variable Documentation') }
-sub help_name  ($self) { return '$' . $self->{var} }
+sub help_title           ($self) { return $self->{c}->maketext('Variable Documentation') }
+sub help_name            ($self) { return '$' . $self->{var} }
+sub help_link_aria_label ($self) { return $self->{c}->maketext('Variable documentation for [_1]', $self->help_name) }
 
 # This produces the documentation string and modal containing detailed documentation.
 # It is the same for all config types.
diff --git a/lib/WeBWorK/ConfigObject/boolean.pm b/lib/WeBWorK/ConfigObject/boolean.pm
index be10bb1dfb..f3636211c1 100644
--- a/lib/WeBWorK/ConfigObject/boolean.pm
+++ b/lib/WeBWorK/ConfigObject/boolean.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::boolean;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigObject/checkboxlist.pm b/lib/WeBWorK/ConfigObject/checkboxlist.pm
index 746adec2be..ec569ee17e 100644
--- a/lib/WeBWorK/ConfigObject/checkboxlist.pm
+++ b/lib/WeBWorK/ConfigObject/checkboxlist.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::checkboxlist;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigObject/list.pm b/lib/WeBWorK/ConfigObject/list.pm
index f2ec5b8112..743f9ec74f 100644
--- a/lib/WeBWorK/ConfigObject/list.pm
+++ b/lib/WeBWorK/ConfigObject/list.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::list;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigObject/lms_context_id.pm b/lib/WeBWorK/ConfigObject/lms_context_id.pm
index 42636efb20..73631a2a0d 100644
--- a/lib/WeBWorK/ConfigObject/lms_context_id.pm
+++ b/lib/WeBWorK/ConfigObject/lms_context_id.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::lms_context_id;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
@@ -106,7 +91,8 @@ sub entry_widget ($self, $default, $is_secret = 0) {
 	return $self->SUPER::entry_widget($default);
 }
 
-sub help_title ($self) { return $self->{c}->maketext('Setting Documentation') }
-sub help_name  ($self) { return $self->{c}->maketext('[_1] setting', $self->{var}) }
+sub help_title           ($self) { return $self->{c}->maketext('Setting Documentation') }
+sub help_name            ($self) { return $self->{c}->maketext('[_1] setting',                   $self->{var}) }
+sub help_link_aria_label ($self) { return $self->{c}->maketext('Setting documentation for [_1]', $self->{var}) }
 
 1;
diff --git a/lib/WeBWorK/ConfigObject/number.pm b/lib/WeBWorK/ConfigObject/number.pm
index 969eb4a875..2312c98fb5 100644
--- a/lib/WeBWorK/ConfigObject/number.pm
+++ b/lib/WeBWorK/ConfigObject/number.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::number;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigObject/permission.pm b/lib/WeBWorK/ConfigObject/permission.pm
index 80d51e8c30..344cf918f0 100644
--- a/lib/WeBWorK/ConfigObject/permission.pm
+++ b/lib/WeBWorK/ConfigObject/permission.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::permission;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigObject/permission_checkboxlist.pm b/lib/WeBWorK/ConfigObject/permission_checkboxlist.pm
index 4b51cd360a..f1b8f2a6d5 100644
--- a/lib/WeBWorK/ConfigObject/permission_checkboxlist.pm
+++ b/lib/WeBWorK/ConfigObject/permission_checkboxlist.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::permission_checkboxlist;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 use WeBWorK::Utils 'role_and_above';
diff --git a/lib/WeBWorK/ConfigObject/popuplist.pm b/lib/WeBWorK/ConfigObject/popuplist.pm
index 30995adb59..f7a1de6aa6 100644
--- a/lib/WeBWorK/ConfigObject/popuplist.pm
+++ b/lib/WeBWorK/ConfigObject/popuplist.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::popuplist;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigObject/setting.pm b/lib/WeBWorK/ConfigObject/setting.pm
index d6c654e0d4..45eb79f7f8 100644
--- a/lib/WeBWorK/ConfigObject/setting.pm
+++ b/lib/WeBWorK/ConfigObject/setting.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::setting;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
@@ -35,7 +20,8 @@ sub save_string ($self, $oldval, $use_current = 0) {
 	return '';
 }
 
-sub help_title ($self) { return $self->{c}->maketext('Setting Documentation') }
-sub help_name  ($self) { return $self->{c}->maketext('[_1] setting', $self->{var}) }
+sub help_title           ($self) { return $self->{c}->maketext('Setting Documentation') }
+sub help_name            ($self) { return $self->{c}->maketext('[_1] setting',                   $self->{var}) }
+sub help_link_aria_label ($self) { return $self->{c}->maketext('Setting documentation for [_1]', $self->{var}) }
 
 1;
diff --git a/lib/WeBWorK/ConfigObject/text.pm b/lib/WeBWorK/ConfigObject/text.pm
index 1780eb8b84..16ebf8ce87 100644
--- a/lib/WeBWorK/ConfigObject/text.pm
+++ b/lib/WeBWorK/ConfigObject/text.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::text;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigObject/time.pm b/lib/WeBWorK/ConfigObject/time.pm
index 74429bc222..20185d8b33 100644
--- a/lib/WeBWorK/ConfigObject/time.pm
+++ b/lib/WeBWorK/ConfigObject/time.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::time;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigObject/timezone.pm b/lib/WeBWorK/ConfigObject/timezone.pm
index 590bd0a09e..feaf1f8acd 100644
--- a/lib/WeBWorK/ConfigObject/timezone.pm
+++ b/lib/WeBWorK/ConfigObject/timezone.pm
@@ -1,18 +1,3 @@
-################################################################################
-# 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.
-################################################################################
-
 package WeBWorK::ConfigObject::timezone;
 use Mojo::Base 'WeBWorK::ConfigObject', -signatures;
 
diff --git a/lib/WeBWorK/ConfigValues.pm b/lib/WeBWorK/ConfigValues.pm
index b5d1229c7b..411be4426c 100644
--- a/lib/WeBWorK/ConfigValues.pm
+++ b/lib/WeBWorK/ConfigValues.pm
@@ -1,18 +1,3 @@
-################################################################################
-# WeBWorK Online Homework Delivery System
-# Copyright © 2000-2024 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.
-################################################################################
-
 package WeBWorK::ConfigValues;
 use Mojo::Base 'Exporter', -signatures;
 
@@ -290,15 +275,24 @@ sub getConfigValues ($ce) {
 				var  => 'achievementsEnabled',
 				doc  => x('Enable Course Achievements'),
 				doc2 => x(
-					'Activiating this will enable Mathchievements for webwork. Mathchievements can be managed '
-						. 'by using the Achievements Manager link.'
+					"Achievements are a way to gamify WeBWorK. In parallel to a student's regular scores on "
+						. 'assignments, they earn "achievement points" for (a) answering an exercise correctly, and '
+						. '(b) earning badges. Badges can be for tasks like earning 100% on three assignments, '
+						. 'answering five questions correclty on the first attempt, etc. As students earn achivement '
+						. 'points, they can "level up" as well. An instructor can manage Achievents using the '
+						. 'Achievements Manager tool.'
 				),
 				type => 'boolean'
 			},
 			{
 				var  => 'achievementPointsPerProblem',
 				doc  => x('Achievement Points Per Problem'),
-				doc2 => x('This is the number of achievement points given to each user for completing a problem.'),
+				doc2 => x(
+					'This is the number of achievement points given to each user for completing a problem. The default '
+						. 'collection of achievements is designed for a course where a student who completes all the '
+						. 'exercises would earn 1000 points, not counting points from badges. It is recommended that '
+						. 'if you use the default collection and your course has N problems, set this value to 1000/N.'
+				),
 				type => 'number'
 			},
 			{
@@ -314,8 +308,16 @@ sub getConfigValues ($ce) {
 				var  => 'achievementItemsEnabled',
 				doc  => x('Enable Achievement Rewards'),
 				doc2 => x(
-					'Activating this will enable achievement rewards. This feature allows students to earn rewards by '
-						. 'completing achievements that allow them to affect their homework in a limited way.'
+					'Activating this will enable achievement reward items. This feature allows students to earn reward '
+						. 'items as they level up (if level achievements are being used). The default reward items:
    ' + . '
  1. award 50% score to one problem
  2. reset the number of attempts allowed for one ' + . 'problem
  3. extend a close date (and the reduced credit date) by 24 hours on one set' + . '
  4. double the weight of one problem within its set
  5. replaces one problem in a ' + . 'set with a copy of a different problem in that set
  6. award 100% score to one problem' + . '
  7. extend a close date (and the reduced credit date) by 48 hours on one set
  8. ' + . 'double the weight of all problems within a set
  9. reopen a set that has past its close ' + . 'date for 24 hours, with problems rerandomized
  10. award 100% score to all problems in ' + . 'one set.
' ), type => 'boolean' }, @@ -358,7 +360,7 @@ sub getConfigValues ($ce) { . 'to set the default length of the reduced scoring period and the value of work done in ' . 'the reduced scoring period below.

To use this, you also have to enable Reduced ' . 'Scoring for individual assignments and set their Reduced Scoring Dates by editing the ' - . 'set data.

This works with the avg_problem_grader (which is the the default grader) ' + . 'set data.

This works with the avg_problem_grader (which is the default grader) ' . 'and the std_problem_grader (the all or nothing grader). It will work with custom graders ' . 'if they are written appropriately.

' ), @@ -374,7 +376,7 @@ sub getConfigValues ($ce) { . 'will see the message "You are in the Reduced Scoring Period: All additional work done ' . 'counts 50% of the original."

To use this, you also have to enable Reduced Scoring ' . 'and set the Reduced Scoring Date for individual assignments by editing the set data ' - . 'using the Sets Manager.

This works with the avg_problem_grader (which is the the ' + . 'using the Sets Manager.

This works with the avg_problem_grader (which is the ' . 'default grader) and the std_problem_grader (the all or nothing grader). It will work ' . 'with custom graders if they are written appropriately.

' ), @@ -457,7 +459,7 @@ sub getConfigValues ($ce) { . 'answers.
  • SMAshowHints: Show hints for the new problem (assuming ' . 'hints exist).
  • Note: There is very little point enabling the Show Me Another ' . 'feature unless you check at least one of these options. Otherwise the students would ' - . 'simply see a new version that can not be attempted or learned from.' + . 'simply see a new version that cannot be attempted or learned from.' ), min => 0, values => [ 'SMAcheckAnswers', 'SMAshowSolutions', 'SMAshowCorrect', 'SMAshowHints' ], @@ -529,7 +531,14 @@ sub getConfigValues ($ce) { var => 'permissionLevels{record_answers_when_acting_as_student}', doc => x('Can submit answers for a student'), doc2 => x( - 'When acting as a student, this permission level and higher can submit answers for that student.'), + 'When acting as a student, this permission level and higher can submit answers for that student, ' + . 'which includes starting and grading test versions. This permission should only be turned ' + . 'on temporarily and set back to "nobody" after you are done submitting answers for a ' + . 'student. Leaving this permission on is dangerous, as you could unintentionally submit ' + . 'answers for a student, which can use up their total number of attempts. Further, if you ' + . 'are viewing an open test version, your answers on each page will be saved when you move ' + . q/between pages, which will overwrite the student's saved answers./ + ), type => 'permission' }, { @@ -541,6 +550,19 @@ sub getConfigValues ($ce) { ), type => 'permission' }, + { + var => 'permissionLevels{change_name}', + doc => x('Allowed to change their name'), + doc2 => x( + 'Users at this level and higher are allowed to change their first and last name. ' + . 'Note that if WeBWorK is used with an LMS, it may be configured to allow the LMS to ' + . 'manage user data such as user names. Then if a user changes their name in WeBWorK, ' + . 'the LMS might override that later. This course might be configured to allow you to ' + . 'control whether or not the LMS is allowed to manage user date in the LTI tab of the ' + . 'Course Configuration page.' + ), + type => 'permission' + }, { var => 'permissionLevels{change_email_address}', doc => x('Allowed to change their email address'), @@ -605,6 +627,26 @@ sub getConfigValues ($ce) { ), type => 'permission' }, + { + var => 'permissionLevels{view_leaderboard}', + doc => x('Allowed to view achievements leaderboard'), + doc2 => x( + 'The permission level to view the achievements leaderboard, if achievements are enabled. ' + . 'Consider that achievement points can be closely tied to student grades before ' + . 'showing the leaderboard to students.' + ), + type => 'permission' + }, + { + var => 'permissionLevels{view_leaderboard_usernames}', + doc => x('Allowed to view usernames on the achievements leaderboard'), + doc2 => x( + 'The permission level to view usernames on the achievements leaderboard. ' + . 'Consider that achievement points can be closely tied to student grades before ' + . 'showing user names to students.' + ), + type => 'permission' + }, ], [ x('Problem Display/Answer Checking'), @@ -808,9 +850,9 @@ sub getConfigValues ($ce) { . '
  • Debug: as in Standard, plus the problem environment (debugging data)
  • ' ), labels => { - '0' => 'Simple', - '1' => 'Standard', - '2' => 'Debug' + '0' => x('Simple'), + '1' => x('Standard'), + '2' => x('Debug') }, values => [qw(0 1 2)], type => 'popuplist' @@ -838,7 +880,7 @@ sub getConfigValues ($ce) { var => 'feedback_by_section', doc => x('Feedback by Section.'), doc2 => x( - 'By default, feedback is always sent to all users specified to recieve feedback. This ' + 'By default, feedback is always sent to all users specified to receive feedback. This ' . 'variable sets the system to only email feedback to users who have the same section as ' . 'the user initiating the feedback. I.e., feedback will only be sent to section leaders.' ), @@ -861,7 +903,7 @@ sub getConfigValues ($ce) { }, 'LTI{v1p1}{LMS_url}' => { var => 'LTI{v1p1}{LMS_url}', - doc => x('A URL for the LMS'), + doc => x('URL for the LMS'), doc2 => x( 'An address that can be used to log in to the LMS. This is used in messages to users ' . 'that direct them to go back to the LMS to access something in the WeBWorK course.' @@ -896,25 +938,120 @@ sub getConfigValues ($ce) { . 'of the LMS course.' ), values => [ '', qw(course homework) ], - labels => { '' => 'None', 'course' => 'Course', 'homework' => 'Homework' }, + labels => { '' => x('None'), 'course' => x('Course'), 'homework' => x('Homework') }, type => 'popuplist' }, + LTICheckPrior => { + var => 'LTICheckPrior', + doc => x('Check a score in the LMS actually needs updating before updating it'), + doc2 => x( + '

    When this is true, any time WeBWorK is about to send a score to the LMS, it will first request ' + . 'from the LMS what that score currently is. Then if there is no significant difference between ' + . 'the LMS score and the WeBWorK score, WeBWorK will not follow through with updating the LMS ' + . 'score. This is to avoid frequent insignificant updates to a student score in the LMS. With some ' + . 'LMSs, students may receive notifications each time a score is updated, and setting this ' + . 'variable will prevent too many notifications for them. This does create a two-step process, ' + . 'first querying the current score from the LMS and then actually updating the score (if there is ' + . 'a significant difference). Additional details:

    • If the LMS score is not 100%, but the ' + . 'WeBWorK score is, then even if the LMS score is only insignificantly less than 100%, it will be ' + . 'updated anyway.
    • If the LMS score is not set and the WeBWorK score is 0, this is ' + . 'considered a significant difference and the LMS score will updated to 0. However, the ' + . 'constraints of the $LTISendScoresAfterDate and the $LTISendGradesEarlyThreshold variables ' + . '(described below) might apply, and the score may still not be updated in this case.
    • ' + . '
    • "Significant" means an absolute difference of 0.001, or 0.1%. At this time this is not ' + . 'configurable.
    ' + ), + type => 'boolean' + }, LTIGradeOnSubmit => { var => 'LTIGradeOnSubmit', - doc => x('Update LMS Grade Each Submit'), + doc => x('Update LMS grade with each submission'), doc2 => x( - 'Sets if webwork sends grades back to the LMS every time a user submits an answer. ' - . 'This keeps students grades up to date, but can cause additional server load.' + 'If this is set to true, then each time a user submits an answer or grades a test, that will trigger ' + . 'WeBWorK possibly reporting a score to the LMS. However, several other configuration settings ' + . 'might still prevent WeBWorK from actually submitting a score to the LMS. If this is set to ' + . 'false, then grades will only be sent to the LMS with mass updates (either triggered by the ' + . 'instructor using the LTI Grade Update tool, or at mass update intervals).' ), type => 'boolean' }, + LTISendScoresAfterDate => { + var => 'LTISendScoresAfterDate', + doc => x('Date after which scores will be sent to the LMS'), + doc2 => x( + '

    This can be set to one of the dates associated with assignments, or "Never". For each assignment, ' + . 'if this setting is "After the ... " then if it is after the indicated date, WeBWorK will send ' + . 'scores. If this setting is "Never" then there is no date that will force WeBWorK to send scores ' + . 'and only the $LTISendGradesEarlyThreshold can cause scores to be sent. If scores are sent:

    ' + . "
    • For 'course' grade passback mode, the assignment will be included in the overall course " + . "score calculation.
    • For 'homework' grade passback mode, the assignment's score itself " + . 'will be sent.

    If $LTISendScoresAfterDate is set to "After the reduced scoring date" ' + . 'and an assignment has no reduced scoring date or reduced scoring is disabled, the fallback is ' + . 'to use the close date.

    For a given assignment, WeBWorK will still send a score to the LMS ' + . 'if the $LTISendGradesEarlyThreshold has been met, regardless of how $LTISendScoresAfterDate is ' + . 'set.

    ' + ), + values => [qw(open_date reduced_scoring_date due_date answer_date never)], + labels => { + open_date => x('After the open date'), + reduced_scoring_date => x('After the reduced scoring date'), + due_date => x('After the close date'), + answer_date => x('After the answer date'), + never => x('Never') + }, + type => 'popuplist' + }, + LTISendGradesEarlyThreshold => { + var => 'LTISendGradesEarlyThreshold', + doc => x('Condition under which scores will be sent early to an LMS'), + doc2 => x( + "

    This can either be set to a score or set to Attempted. When something triggers a potential grade " + . 'passback, if it is earlier than $LTISendScoresAfterDate, the condition described by this ' + . 'variable must be met or else no score will be sent.

    If this variable is a score, then the ' + . 'set will need to have a score that reaches or exceeds this score for its score to be sent to ' + . "the LMS (or included in the 'course' score calculation). If this variable is set to Attempted, " + . 'then the set needs to have been attempted for its score to be sent to the LMS (or included in ' + . "the 'course' score calculation).

    For a regular or jitar set, 'attempted' means that at " + . "least one exercise was attempted. For a test, 'attempted' means that either multiple versions " + . 'exist or there is one version with a graded submission.

    ' + ), + values => [ qw( + attempted 0 0.05 0.1 0.15 0.2 0.25 0.3 0.35 0.4 0.45 0.5 0.55 0.6 0.65 0.7 0.75 0.8 0.85 0.9 0.95 1 + ) ], + labels => { + attempted => x('Attempted'), + 0 => '0%', + 0.05 => '5%', + 0.1 => '10%', + 0.15 => '15%', + 0.2 => '20%', + 0.25 => '25%', + 0.3 => '30%', + 0.35 => '35%', + 0.4 => '40%', + 0.45 => '45%', + 0.5 => '50%', + 0.55 => '55%', + 0.6 => '60%', + 0.65 => '65%', + 0.7 => '70%', + 0.75 => '75%', + 0.8 => '80%', + 0.85 => '85%', + 0.9 => '90%', + 0.95 => '95%', + 1 => '100%', + }, + type => 'popuplist' + }, LTIMassUpdateInterval => { var => 'LTIMassUpdateInterval', doc => x('Time in seconds to periodically update LMS grades (-1 to disable)'), doc2 => x( - 'Sets the time in seconds to periodically update the LMS grades. WeBWorK will update all grades on ' + 'Sets the time in seconds to periodically update the LMS scores. WeBWorK will update all scores on ' . 'the LMS if it has been longer than this time since the completion of the last update. This is ' - . 'only an approximate time. 86400 seconds is one day. -1 disables periodic updates.' + . 'only an approximate time. Mass updates of this nature may put significant strain on the server, ' + . 'and should not be set to happen too frequently. -1 disables these periodic updates.' ), type => 'number' }, @@ -922,11 +1059,13 @@ sub getConfigValues ($ce) { var => 'LMSManageUserData', doc => x('Allow the LMS to update user account data'), doc2 => x( - 'WeBWorK will automatically create users when logging in via the LMS for the first time. If ' - . 'this flag is enabled then it will also keep the user account data (first name, last ' - . 'name, section, recitation) up to date with the LMS. If a user\'s information changes ' - . 'in the LMS then it will change in WeBWorK. However, any changes to the user data via ' - . 'WeBWorK will be overwritten the next time the user logs in.' + 'If this is set to true, then when a user enters WeBWorK using LTI from an LMS, their user account ' + . 'data in WeBWorK will be updated to match the data from the LMS. This applies to first name, ' + . 'last name, section, recitation, and email address. If a user\'s information changes in the LMS ' + . 'then it will change in WeBWorK the next time the user enters WeBWorK from the LMS. Any changes ' + . 'to the user data that are made in WeBWorK will be overwritten. So if this is set to true, you ' + . 'may want to review the settings in the Permissions tab for who is allowed to change their own ' + . 'name and email address.' ), type => 'boolean' }, @@ -1090,7 +1229,7 @@ sub getConfigValues ($ce) { @$configValues, [ x('LTI'), - map { $LTIConfigValues->{$_} } + map { $LTIConfigValues->{$_} } grep { defined $LTIConfigValues->{$_} } @{ $ce->{LTIConfigVariables} } ] ); diff --git a/lib/WeBWorK/ContentGenerator.pm b/lib/WeBWorK/ContentGenerator.pm index 9b9cb538e7..3e069f2a03 100644 --- a/lib/WeBWorK/ContentGenerator.pm +++ b/lib/WeBWorK/ContentGenerator.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator; use Mojo::Base 'WeBWorK::Controller', -signatures, -async_await; @@ -50,13 +35,13 @@ use Encode; use WeBWorK::File::Scoring qw(parse_scoring_file); use WeBWorK::Localize; -use WeBWorK::Utils qw(fetchEmailRecipients generateURLs getAssetURL); -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); +use WeBWorK::Utils qw(fetchEmailRecipients generateURLs getAssetURL); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); use WeBWorK::Utils::LanguageAndDirection qw(get_lang_and_dir); -use WeBWorK::Utils::Logs qw(writeCourseLog); -use WeBWorK::Utils::Routes qw(route_title route_navigation_is_restricted); -use WeBWorK::Utils::Sets qw(format_set_name_display); -use WeBWorK::Authen::LTI::MassUpdate qw(mass_update); +use WeBWorK::Utils::Logs qw(writeCourseLog); +use WeBWorK::Utils::Routes qw(route_title route_navigation_is_restricted); +use WeBWorK::Utils::Sets qw(format_set_name_display); +use WeBWorK::Authen::LTI::GradePassback qw(massUpdate); =head1 INVOCATION @@ -111,7 +96,7 @@ async sub go ($c) { # If grades are being passed back to the lti, then peroidically update all of the # grades because things can get out of sync if instructors add or modify sets. - mass_update($c) if $c->stash('courseID') && ref($c->db) && $ce->{LTIGradeMode}; + massUpdate($c) if $c->stash('courseID') && ref($c->db) && $ce->{LTIGradeMode}; # Check to determine if this is a problem set response. Individual content generators must check # $c->{invalidSet} and react appropriately. @@ -377,7 +362,7 @@ sub header { Not defined in this package. May be defined by a subclass to perform any early processing that is needed. -This method can not be used if responding with a file or redirect. +This method cannot be used if responding with a file or redirect. This method may be asynchronous. @@ -967,18 +952,13 @@ if defined. sub helpMacro ($c, $name, $args = {}) { my $ce = $c->ce; return '' unless -e "$ce->{webworkDirs}{root}/templates/HelpFiles/$name.html.ep"; - - my $label = $args->{label} // $c->tag( - 'i', - class => 'icon fa-solid fa-circle-question ' . ($args->{label_size} // ''), - 'aria-hidden' => 'true', - data => { alt => ' ? ' }, - '' + return $c->include( + "HelpFiles/$name", + name => $name, + label => delete $args->{label} // '', + labelSize => delete $args->{label_size} // '', + args => $args ); - delete $args->{label}; - delete $args->{label_size}; - - return $c->include("HelpFiles/$name", name => $name, label => $label, args => $args); } =item feedbackMacro(%params) @@ -993,8 +973,11 @@ sub feedbackMacro ($c, %params) { return '' unless $c->authz->hasPermissions($c->param('user'), 'submit_feedback'); if ($c->ce->{courseURLs}{feedbackURL}) { - return $c->link_to(($c->maketext($c->ce->{feedback_button_name}) || $c->maketext('Email instructor')) => - $c->ce->{courseURLs}{feedbackURL}); + return $c->link_to( + ($c->maketext($c->ce->{feedback_button_name}) || $c->maketext('Email instructor')) => + $c->ce->{courseURLs}{feedbackURL}, + class => 'btn btn-primary' + ); } elsif ($c->ce->{courseURLs}{feedbackFormURL}) { $params{notifyAddresses} = join(';', $c->fetchEmailRecipients('receive_feedback', $c->db->getUser($c->param('user')))); @@ -1154,7 +1137,7 @@ C, and C) are included with their default values. =item authen If set to a false value, the authentication parameters (C, -C, and C) are included in the the generated link unless +C, and C) are included in the generated link unless explicitly listed in C. =item use_abs_url @@ -1201,7 +1184,8 @@ sub systemLink ($c, $urlpath, %options) { my $url = $options{use_abs_url} ? $urlpath->to_abs : $urlpath; for my $name (keys %params) { - $params{$name} = [ $c->param($name) ] if (!defined $params{$name} && defined $c->param($name)); + $params{$name} = [ $c->param($name) ] + if (!defined $params{$name} && defined $c->param($name) && $c->param($name) ne ''); } return %params ? $url->query(%params) : $url; diff --git a/lib/WeBWorK/ContentGenerator/Achievements.pm b/lib/WeBWorK/ContentGenerator/Achievements.pm index 9d08fc3d78..817f6a731b 100644 --- a/lib/WeBWorK/ContentGenerator/Achievements.pm +++ b/lib/WeBWorK/ContentGenerator/Achievements.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - # This module prints out the list of achievements that a student has earned package WeBWorK::ContentGenerator::Achievements; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -37,25 +22,8 @@ sub initialize ($c) { $c->{globalData} = $db->getGlobalUserAchievement($c->{studentName}); # Check to see if user items are enabled and if the user has achievement data. - if ($ce->{achievementItemsEnabled} && defined $c->{globalData}) { - my $itemsWithCounts = WeBWorK::AchievementItems::UserItems($c->{studentName}, $db, $ce); - $c->{achievementItems} = $itemsWithCounts; - - my $usedItem = $c->param('useditem'); - - # If the useditem parameter is defined then the student wanted to use an item, so lets do that by calling the - # appropriate item's use method and printing results. - if (defined $usedItem) { - my $error = $itemsWithCounts->[$usedItem][0]->use_item($c->{studentName}, $c); - if ($error) { - $c->addbadmessage($error); - } else { - if ($itemsWithCounts->[$usedItem][1] != 1) { --$itemsWithCounts->[$usedItem][1]; } - else { splice(@$itemsWithCounts, $usedItem, 1); } - $c->addgoodmessage($c->maketext('Reward used successfully!')); - } - } - } + $c->{achievementItems} = WeBWorK::AchievementItems::UserItems($c, $c->{studentName}, undef, undef) + if $ce->{achievementItemsEnabled} && defined $c->{globalData}; return; } @@ -88,35 +56,6 @@ sub getAchievementLevelData ($c) { ); } -sub getAchievementItemsData ($c) { - my $db = $c->db; - - my $userID = $c->{studentName}; - - my (@items, %itemCounts, @sets, %setProblemIds); - - if ($c->ce->{achievementItemsEnabled} && $c->{achievementItems}) { - # Remove count data so @items is structured as originally designed. - for my $item (@{ $c->{achievementItems} }) { - push(@items, $item->[0]); - $itemCounts{ $item->[0]->id } = $item->[1]; - } - - for my $set ($db->getMergedSets(map { [ $userID, $_ ] } $db->listUserSets($userID))) { - push(@sets, $set); - $setProblemIds{ $set->set_id } = [ map { $_->[2] } - $db->listUserProblemsWhere({ user_id => $userID, set_id => $set->set_id }, 'problem_id') ]; - } - } - - return ( - items => \@items, - itemCounts => \%itemCounts, - sets => \@sets, - setProblemIds => \%setProblemIds - ); -} - sub getAchievementsData ($c) { my $db = $c->db; my $ce = $c->ce; diff --git a/lib/WeBWorK/ContentGenerator/AchievementsLeaderboard.pm b/lib/WeBWorK/ContentGenerator/AchievementsLeaderboard.pm new file mode 100644 index 0000000000..1c622d0673 --- /dev/null +++ b/lib/WeBWorK/ContentGenerator/AchievementsLeaderboard.pm @@ -0,0 +1,99 @@ +# Leader board for achievements. +package WeBWorK::ContentGenerator::AchievementsLeaderboard; +use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; + +=head1 NAME + +WeBWorK::ContentGenerator::AchievementsLeaderboard - Leaderboard for achievements, +which lists the total number of achievement points, level, and badges +earned for each user with the 'include_in_stats' status. + +Only users with the 'view_leaderboard' permission can see the Leaderboard, and only +users with the 'view_leaderboard_usernames' permission can see user names. + +=cut + +use WeBWorK::Utils qw(sortAchievements); + +sub initialize ($c) { + my $db = $c->db; + my $ce = $c->ce; + + # Get user Data + $c->{userName} = $c->param('user'); + $c->{studentName} = $c->param('effectiveUser') // $c->{userName}; + + return unless $c->authz->hasPermissions($c->{userName}, 'view_leaderboard'); + + # Get list of all users (except set-level proctors) and achievements. + my @achievements = sortAchievements($db->getAchievementsWhere); + my %achievementsById = map { $_->achievement_id => $_ } @achievements; + my %globalUserAchievements = + map { $_->user_id => $_ } $db->getGlobalUserAchievementsWhere({ user_id => { not_like => 'set_id:%' } }); + + $c->{showUserNames} = $c->authz->hasPermissions($c->{userName}, 'view_leaderboard_usernames'); + $c->{showLevels} = 0; # Hide level column unless at least one user has a level achievement. + + my %allUserAchievements; + for ($db->getUserAchievementsWhere({ + user_id => { not_like => 'set_id:%' }, + achievement_id => [ map { $_->achievement_id } grep { $_->category ne 'level' } @achievements ], + })) + { + $allUserAchievements{ $_->user_id }{ $_->achievement_id } = $_; + } + + my @rows; + for my $user ($db->getUsersWhere({ user_id => { not_like => 'set_id:%' } })) { + # Only include users who can be shown in stats. + next unless $ce->status_abbrev_has_behavior($user->status, 'include_in_stats'); + + my $globalData = $globalUserAchievements{ $user->user_id }; + my $userAchievements = $allUserAchievements{ $user->user_id }; + + # Skip unless user has achievement data. + next unless $globalData && $userAchievements; + + my $level = $globalData->level_achievement_id ? $achievementsById{ $globalData->level_achievement_id } : ''; + + my @badges; + for my $achievement (@achievements) { + # Skip level achievements and only show earned achievements. + last if $achievement->category eq 'level'; + + push(@badges, $achievement) + if $userAchievements->{ $achievement->achievement_id } + && $achievement->enabled + && $userAchievements->{ $achievement->achievement_id }->earned; + } + + push(@rows, [ $globalData->achievement_points || 0, $level, $user, \@badges ]); + } + + # Sort rows descending by achievement points (or number of badges if achievement points are equal) + # then loop over them to compute rank and determine rank of effective student user. + my $rank = 0; + my $prev_points = -1; + my $skip = 1; + @rows = sort { $b->[0] <=> $a->[0] || scalar(@{ $b->[3] }) <=> scalar(@{ $a->[3] }) } @rows; + for my $row (@rows) { + # All users with an equal number of achievement points have the same rank. + if ($row->[0] == $prev_points) { + $skip++; + } else { + $rank += $skip; + $skip = 1; + } + $prev_points = $row->[0]; + unshift(@$row, $rank); + + $c->{showLevels} = 1 if $row->[2]; + $c->{currentRank} = $rank if $c->{studentName} eq $row->[3]->user_id; + } + $c->{maxRank} = $rank; + $c->{leaderBoardRows} = \@rows; + + return; +} + +1; diff --git a/lib/WeBWorK/ContentGenerator/CourseAdmin.pm b/lib/WeBWorK/ContentGenerator/CourseAdmin.pm index 034f60b373..a11e7b560c 100644 --- a/lib/WeBWorK/ContentGenerator/CourseAdmin.pm +++ b/lib/WeBWorK/ContentGenerator/CourseAdmin.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::CourseAdmin; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -30,16 +15,16 @@ use Time::localtime; use WeBWorK::CourseEnvironment; use WeBWorK::Debug; -use WeBWorK::Utils qw(cryptPassword trim_spaces); -use WeBWorK::Utils::CourseIntegrityCheck; +use WeBWorK::Utils qw(cryptPassword trim_spaces); use WeBWorK::Utils::CourseManagement qw(addCourse renameCourse retitleCourse deleteCourse listCourses archiveCourse unarchiveCourse initNonNativeTables); use WeBWorK::Utils::Logs qw(writeLog); +use WeBWorK::Utils::CourseDBIntegrityCheck; +use WeBWorK::Utils::CourseDirectoryIntegrityCheck qw(checkCourseDirectories updateCourseDirectories); use WeBWorK::DB; sub pre_header_initialize ($c) { my $ce = $c->ce; - my $db = $c->db; my $authz = $c->authz; my $user = $c->param('user'); @@ -47,7 +32,7 @@ sub pre_header_initialize ($c) { # Check that the non-native tables are present in the database. # These are the tables which are not course specific. - my @table_update_messages = initNonNativeTables($ce, $ce->{dbLayoutName}); + my @table_update_messages = initNonNativeTables($ce); $c->addgoodmessage($c->c(@table_update_messages)->join($c->tag('br'))) if @table_update_messages; my @errors; @@ -83,7 +68,6 @@ sub pre_header_initialize ($c) { } } elsif (defined $c->param('confirm_retitle_course')) { $method_to_call = 'do_retitle_course'; - } elsif (defined $c->param('upgrade_course_tables')) { @errors = $c->rename_course_validate; if (@errors) { @@ -214,6 +198,16 @@ sub pre_header_initialize ($c) { } else { $method_to_call = 'manage_lti_course_map_form'; } + } elsif ($subDisplay eq 'manage_otp_secrets') { + if (defined $c->param('take_action')) { + if ($c->param('action') eq 'reset') { + $method_to_call = 'reset_otp_secrets_confirm'; + } else { + $method_to_call = 'copy_otp_secrets_confirm'; + } + } else { + $method_to_call = 'manage_otp_secrets_form'; + } } elsif ($subDisplay eq 'registration') { if (defined($c->param('register_site'))) { $method_to_call = 'do_registration'; @@ -230,20 +224,17 @@ sub pre_header_initialize ($c) { } sub add_course_form ($c) { + $c->param('number_of_additional_users', ($c->param('number_of_additional_users') // 0) + 1) + if $c->param('add_another_instructor'); + return $c->include('ContentGenerator/CourseAdmin/add_course_form'); } sub add_course_validate ($c) { my $ce = $c->ce; - my $add_courseID = trim_spaces($c->param('new_courseID')) || ''; - my $add_initial_userID = trim_spaces($c->param('add_initial_userID')) || ''; - my $add_initial_password = trim_spaces($c->param('add_initial_password')) || ''; - my $add_initial_confirmPassword = trim_spaces($c->param('add_initial_confirmPassword')) || ''; - my $add_initial_firstName = trim_spaces($c->param('add_initial_firstName')) || ''; - my $add_initial_lastName = trim_spaces($c->param('add_initial_lastName')) || ''; - my $add_initial_email = trim_spaces($c->param('add_initial_email')) || ''; - my $add_dbLayout = trim_spaces($c->param('add_dbLayout')) || ''; + my $add_courseID = trim_spaces($c->param('new_courseID')) || ''; + my $number_of_additional_users = $c->param('number_of_additional_users') || 0; my @errors; @@ -260,35 +251,16 @@ sub add_course_validate ($c) { push @errors, $c->maketext('Course ID cannot exceed [_1] characters.', $ce->{maxCourseIdLength}); } - if ($add_initial_userID ne '') { - if ($add_initial_password eq '') { - push @errors, $c->maketext('You must specify a password for the initial instructor.'); - } - if ($add_initial_confirmPassword eq '') { - push @errors, $c->maketext('You must confirm the password for the initial instructor.'); - } - if ($add_initial_password ne $add_initial_confirmPassword) { - push @errors, $c->maketext('The password and password confirmation for the instructor must match.'); - } - if ($add_initial_firstName eq '') { - push @errors, $c->maketext('You must specify a first name for the initial instructor.'); - } - if ($add_initial_lastName eq '') { - push @errors, $c->maketext('You must specify a last name for the initial instructor.'); - } - if ($add_initial_email eq '') { - push @errors, $c->maketext('You must specify an email address for the initial instructor.'); - } - } + for (1 .. $number_of_additional_users) { + my $userID = trim_spaces($c->param("add_initial_userID_$_")) || ''; - if ($add_dbLayout eq '') { - push @errors, 'You must select a database layout.'; - } else { - if (exists $ce->{dbLayouts}{$add_dbLayout}) { - # we used to check for layout-specific fields here, but there aren't any layouts that require them - # anymore. (in the future, we'll probably deal with this in layout-specific modules.) - } else { - push @errors, "The database layout $add_dbLayout doesn't exist."; + unless ($userID =~ /^[\w.,-]*$/) { + push @errors, + $c->maketext( + 'User ID number [_1] may only contain letters, numbers, hyphens, periods, commas, ' + . 'and underscores.', + $_ + ); } } @@ -300,71 +272,99 @@ sub do_add_course ($c) { my $db = $c->db; my $authz = $c->authz; - my $add_courseID = trim_spaces($c->param('new_courseID')) // ''; - my $add_courseTitle = ($c->param('add_courseTitle') // '') =~ s/^\s*|\s*$//gr; - my $add_courseInstitution = ($c->param('add_courseInstitution') // '') =~ s/^\s*|\s\*$//gr; - - my $add_initial_userID = trim_spaces($c->param('add_initial_userID')) // ''; - my $add_initial_password = trim_spaces($c->param('add_initial_password')) // ''; - my $add_initial_confirmPassword = trim_spaces($c->param('add_initial_confirmPassword')) // ''; - my $add_initial_firstName = trim_spaces($c->param('add_initial_firstName')) // ''; - my $add_initial_lastName = trim_spaces($c->param('add_initial_lastName')) // ''; - my $add_initial_email = trim_spaces($c->param('add_initial_email')) // ''; + my $add_courseID = trim_spaces($c->param('new_courseID')) // ''; + my $add_courseTitle = ($c->param('add_courseTitle') // '') =~ s/^\s*|\s*$//gr; + my $add_courseInstitution = ($c->param('add_courseInstitution') // '') =~ s/^\s*|\s\*$//gr; + my $number_of_additional_users = $c->param('number_of_additional_users') || 0; my $copy_from_course = trim_spaces($c->param('copy_from_course')) // ''; - my $add_dbLayout = trim_spaces($c->param('add_dbLayout')) || ''; - my $ce2 = WeBWorK::CourseEnvironment->new({ courseName => $add_courseID }); - my %courseOptions = (dbLayoutName => $add_dbLayout); + my %courseOptions; my @users; # copy users from current (admin) course if desired - if ($c->param('add_admin_users')) { - for my $userID ($db->listUsers) { + for my $userID ($c->param('add-admin-users')) { + unless ($db->existsUser($userID)) { + $c->addbadmessage($c->maketext( + 'User "[_1]" will not be copied from the [_2] course as it does not exist.', $userID, + $ce->{admin_course_id} + )); + next; + } + for (1 .. $number_of_additional_users) { + my $add_initial_userID = trim_spaces($c->param("add_initial_userID_$_")) // ''; + if ($userID eq $add_initial_userID) { $c->addbadmessage($c->maketext( - 'User "[_1]" will not be copied from [_2] course as it is the initial instructor.', $userID, - $ce->{admin_course_id} + 'User "[_1]" will not be copied from the [_2] course as it is the same as additional user ' + . 'number [_3].', + $userID, $ce->{admin_course_id}, $_ )); next; } - my $PermissionLevel = $db->newPermissionLevel(); - $PermissionLevel->user_id($userID); - $PermissionLevel->permission($ce->{userRoles}{admin}); - my $User = $db->getUser($userID); - my $Password = $db->getPassword($userID); - $User->status('O'); # Add admin user as an observer. - - push @users, [ $User, $Password, $PermissionLevel ] - if $authz->hasPermissions($userID, 'create_and_delete_courses'); } - } - # add initial instructor if desired - if ($add_initial_userID =~ /\S/) { - my $User = $db->newUser( - user_id => $add_initial_userID, - first_name => $add_initial_firstName, - last_name => $add_initial_lastName, - student_id => $add_initial_userID, - email_address => $add_initial_email, - status => 'O', - ); - my $Password = $db->newPassword( - user_id => $add_initial_userID, - password => cryptPassword($add_initial_password), - ); - my $PermissionLevel = $db->newPermissionLevel( - user_id => $add_initial_userID, - permission => '10', - ); + my $PermissionLevel = $db->getPermissionLevel($userID); + my $User = $db->getUser($userID); + my $Password = $db->getPassword($userID); + + # Enroll student users, and make all other users observers. + $User->status($PermissionLevel->permission == $ce->{userRoles}{student} ? 'C' : 'O'); + push @users, [ $User, $Password, $PermissionLevel ]; } - push @{ $courseOptions{PRINT_FILE_NAMES_FOR} }, map { $_->[0]->user_id } @users; + # add additional instructors if desired + for (1 .. $number_of_additional_users) { + my $userID = trim_spaces($c->param("add_initial_userID_$_")) // ''; + my $password = trim_spaces($c->param("add_initial_password_$_")) // ''; + my $firstName = trim_spaces($c->param("add_initial_firstName_$_")) // ''; + my $lastName = trim_spaces($c->param("add_initial_lastName_$_")) // ''; + my $email = trim_spaces($c->param("add_initial_email_$_")) // ''; + my $permissionLevel = $c->param("add_initial_permission_$_"); + my $add_user = $c->param("add_initial_user_$_") // 0; + + if ($userID =~ /\S/) { + my $User = $db->newUser( + user_id => $userID, + first_name => $firstName, + last_name => $lastName, + email_address => $email, + status => $permissionLevel == $ce->{userRoles}{student} ? 'C' : 'O', + ); + my $Password = $db->newPassword( + user_id => $userID, + password => $password ? cryptPassword($password) : '', + ); + my $PermissionLevel = $db->newPermissionLevel( + user_id => $userID, + permission => $permissionLevel, + ); + push @users, [ $User, $Password, $PermissionLevel ]; + + # Add initial user to admin course if asked. + if ($add_user) { + if ($db->existsUser($userID)) { + $c->addbadmessage($c->maketext( + 'User "[_1]" will not be added to the [_2] course as it already exists.', $userID, + $ce->{admin_course_id} + )); + } else { + $User->status('D'); # By default don't allow user to login. + $db->addUser($User); + $db->addPassword($Password); + $db->addPermissionLevel($PermissionLevel); + $User->status($permissionLevel == $ce->{userRoles}{student} ? 'C' : 'O'); + } + } + } + } + + push @{ $courseOptions{PRINT_FILE_NAMES_FOR} }, + map { $_->[0]->user_id } grep { $_->[2]->permission >= $ce->{userRoles}{professor} } @users; # Include any optional arguments, including a template course and the course title and course institution. my %optional_arguments; @@ -384,11 +384,10 @@ sub do_add_course ($c) { eval { addCourse( - courseID => $add_courseID, - ce => $ce2, - courseOptions => \%courseOptions, - users => \@users, - initial_userID => $add_initial_userID, + courseID => $add_courseID, + ce => $ce2, + courseOptions => \%courseOptions, + users => \@users, %optional_arguments, ); }; @@ -417,53 +416,8 @@ sub do_add_course ($c) { "\tAdded", (defined $add_courseInstitution ? $add_courseInstitution : '(no institution specified)'), (defined $add_courseTitle ? $add_courseTitle : '(no title specified)'), - $add_courseID, - $add_initial_firstName, - $add_initial_lastName, - $add_initial_email, - ) + $add_courseID) ); - # Add contact to admin course as student? - # FIXME -- should we do this? - if ($add_initial_userID =~ /\S/) { - my $composite_id = "${add_initial_userID}_${add_courseID}"; # student id includes school name and contact - my $User = $db->newUser( - user_id => $composite_id, # student id includes school name and contact - first_name => $add_initial_firstName, - last_name => $add_initial_lastName, - student_id => $add_initial_userID, - email_address => $add_initial_email, - status => 'C', - ); - my $Password = $db->newPassword( - user_id => $composite_id, - password => cryptPassword($add_initial_password), - ); - my $PermissionLevel = $db->newPermissionLevel( - user_id => $composite_id, - permission => '0', - ); - # add contact to admin course as student - # or if this contact and course already exist in a dropped status - # change the student's status to enrolled - if (my $oldUser = $db->getUser($composite_id)) { - push( - @$output, - $c->tag( - 'div', - class => 'alert alert-danger p-1 mb-2', - $c->maketext('Replacing old data for [_1]: status: [_2]', $composite_id, $oldUser->status) - ) - ); - $db->deleteUser($composite_id); - } - eval { $db->addUser($User) }; - warn $@ if $@; - eval { $db->addPassword($Password) }; - warn $@ if $@; - eval { $db->addPermissionLevel($PermissionLevel) }; - warn $@ if $@; - } push( @$output, $c->tag( @@ -503,9 +457,8 @@ sub rename_course_confirm ($c) { # Create strings confirming title and institution change. # Connect to the database to get old title and institution. - my $dbLayoutName = $ce->{dbLayoutName}; - my $db = WeBWorK::DB->new($ce->{dbLayouts}{$dbLayoutName}); - my $oldDB = WeBWorK::DB->new($ce2->{dbLayouts}{$dbLayoutName}); + my $db = WeBWorK::DB->new($ce); + my $oldDB = WeBWorK::DB->new($ce2); my $rename_oldCourseTitle = $oldDB->getSettingValue('courseTitle') // ''; my $rename_oldCourseInstitution = $oldDB->getSettingValue('courseInstitution') // ''; @@ -529,49 +482,45 @@ sub rename_course_confirm ($c) { rename_oldCourseID => $rename_oldCourseID ) unless $c->param('rename_newCourseID_checkbox'); - if ($ce2->{dbLayoutName}) { - my $CIchecker = WeBWorK::Utils::CourseIntegrityCheck->new(ce => $ce2); + my $CIchecker = WeBWorK::Utils::CourseDBIntegrityCheck->new($ce2); - # Check database - my ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($rename_oldCourseID); + # Check database + my ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($rename_oldCourseID); - # Upgrade the database if requested. - my @upgrade_report; - if ($c->param('upgrade_course_tables')) { - my @schema_table_names = keys %$dbStatus; - my @tables_to_create = - grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_A } @schema_table_names; - my @tables_to_alter = - grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseIntegrityCheck::DIFFER_IN_A_AND_B } - @schema_table_names; - push(@upgrade_report, $CIchecker->updateCourseTables($rename_oldCourseID, [@tables_to_create])); - for my $table_name (@tables_to_alter) { - push(@upgrade_report, $CIchecker->updateTableFields($rename_oldCourseID, $table_name)); - } - - ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($rename_oldCourseID); + # Upgrade the database if requested. + my @upgrade_report; + if ($c->param('upgrade_course_tables')) { + my @schema_table_names = keys %$dbStatus; + my @tables_to_create = + grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_A } @schema_table_names; + my @tables_to_alter = + grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseDBIntegrityCheck::DIFFER_IN_A_AND_B } + @schema_table_names; + push(@upgrade_report, $CIchecker->updateCourseTables($rename_oldCourseID, [@tables_to_create])); + for my $table_name (@tables_to_alter) { + push(@upgrade_report, $CIchecker->updateTableFields($rename_oldCourseID, $table_name)); } - # Check directories - my ($directories_ok, $directory_report) = $CIchecker->checkCourseDirectories($ce2); - - return $c->include( - 'ContentGenerator/CourseAdmin/rename_course_confirm', - upgrade_report => \@upgrade_report, - tables_ok => $tables_ok, - dbStatus => $dbStatus, - directory_report => $directory_report, - directories_ok => $directories_ok, - rename_oldCourseTitle => $rename_oldCourseTitle, - change_course_title_str => $change_course_title_str, - rename_oldCourseInstitution => $rename_oldCourseInstitution, - change_course_institution_str => $change_course_institution_str, - rename_oldCourseID => $rename_oldCourseID, - rename_newCourseID => $rename_newCourseID - ); - } else { - return $c->tag('p', class => 'text-danger fw-bold', "Unable to find database layout for $rename_oldCourseID"); + ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($rename_oldCourseID); } + + # Check directories + my ($directories_ok, $directory_report) = checkCourseDirectories($ce2); + + return $c->include( + 'ContentGenerator/CourseAdmin/rename_course_confirm', + upgrade_report => \@upgrade_report, + tables_ok => $tables_ok, + dbStatus => $dbStatus, + directory_report => $directory_report, + directories_ok => $directories_ok, + rename_oldCourseTitle => $rename_oldCourseTitle, + change_course_title_str => $change_course_title_str, + rename_oldCourseInstitution => $rename_oldCourseInstitution, + change_course_institution_str => $change_course_institution_str, + rename_oldCourseID => $rename_oldCourseID, + rename_newCourseID => $rename_newCourseID + ); } sub rename_course_validate ($c) { @@ -886,19 +835,6 @@ sub do_delete_course ($c) { $c->tag('div', class => 'font-monospace', $error))->join('') ); } else { - # Mark the contact person in the admin course as dropped. - # Find the contact person for the course by searching the admin classlist. - my @contacts = grep {/_$delete_courseID$/} $db->listUsers; - if (@contacts) { - die "Incorrect number of contacts for the course $delete_courseID: " . join(' ', @contacts) - if @contacts != 1; - - # Mark the contact person as dropped. - my $User = $db->getUser($contacts[0]); - $User->status(($ce->status_name_to_abbrevs('Drop'))[0]); - $db->putUser($User); - } - writeLog($ce, 'hosted_courses', join("\t", "\tDeleted", '', '', $delete_courseID)); return $c->c( @@ -1009,48 +945,44 @@ sub archive_course_confirm ($c) { my $ce2 = WeBWorK::CourseEnvironment->new({ courseName => $archive_courseID }); - if ($ce2->{dbLayoutName}) { - my $CIchecker = WeBWorK::Utils::CourseIntegrityCheck->new(ce => $ce2); + my $CIchecker = WeBWorK::Utils::CourseDBIntegrityCheck->new($ce2); - # Check database - my ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($archive_courseID); + # Check database + my ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($archive_courseID); - # Upgrade the database if requested. - my @upgrade_report; - if ($c->param('upgrade_course_tables')) { - my @schema_table_names = keys %$dbStatus; - my @tables_to_create = - grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_A } @schema_table_names; - my @tables_to_alter = - grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseIntegrityCheck::DIFFER_IN_A_AND_B } - @schema_table_names; - push(@upgrade_report, $CIchecker->updateCourseTables($archive_courseID, [@tables_to_create])); - for my $table_name (@tables_to_alter) { - push(@upgrade_report, $CIchecker->updateTableFields($archive_courseID, $table_name)); - } - - ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($archive_courseID); + # Upgrade the database if requested. + my @upgrade_report; + if ($c->param('upgrade_course_tables')) { + my @schema_table_names = keys %$dbStatus; + my @tables_to_create = + grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_A } @schema_table_names; + my @tables_to_alter = + grep { $dbStatus->{$_}->[0] == WeBWorK::Utils::CourseDBIntegrityCheck::DIFFER_IN_A_AND_B } + @schema_table_names; + push(@upgrade_report, $CIchecker->updateCourseTables($archive_courseID, [@tables_to_create])); + for my $table_name (@tables_to_alter) { + push(@upgrade_report, $CIchecker->updateTableFields($archive_courseID, $table_name)); } - # Update and check directories. - my $dir_update_messages = $c->param('upgrade_course_tables') ? $CIchecker->updateCourseDirectories : []; - my ($directories_ok, $directory_report) = $CIchecker->checkCourseDirectories($ce2); - - return $c->include( - 'ContentGenerator/CourseAdmin/archive_course_confirm', - ce2 => $ce2, - upgrade_report => \@upgrade_report, - tables_ok => $tables_ok, - dbStatus => $dbStatus, - dir_update_messages => $dir_update_messages, - directory_report => $directory_report, - directories_ok => $directories_ok, - archive_courseID => $archive_courseID, - archive_courseIDs => \@archive_courseIDs - ); - } else { - return $c->tag('p', class => 'text-danger fw-bold', "Unable to find database layout for $archive_courseID"); + ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($archive_courseID); } + + # Update and check directories. + my $dir_update_messages = $c->param('upgrade_course_tables') ? updateCourseDirectories($ce2) : []; + my ($directories_ok, $directory_report) = checkCourseDirectories($ce2); + + return $c->include( + 'ContentGenerator/CourseAdmin/archive_course_confirm', + ce2 => $ce2, + upgrade_report => \@upgrade_report, + tables_ok => $tables_ok, + dbStatus => $dbStatus, + dir_update_messages => $dir_update_messages, + directory_report => $directory_report, + directories_ok => $directories_ok, + archive_courseID => $archive_courseID, + archive_courseIDs => \@archive_courseIDs + ); } sub do_archive_course ($c) { @@ -1118,21 +1050,6 @@ sub do_archive_course ($c) { ) ); } else { - # Mark the contact person in the admin course as dropped. - # Find the contact person for the course by searching the admin classlist. - my @contacts = grep {/_$archive_courseID$/} $db->listUsers; - if (@contacts) { - die "Incorrect number of contacts for the course $archive_courseID" . join(' ', @contacts) - if @contacts != 1; - my $composite_id = $contacts[0]; - - my $User = $db->getUser($composite_id); - my $status_name = 'Drop'; - my $status_value = ($ce->status_name_to_abbrevs($status_name))[0]; - $User->status($status_value); - $db->putUser($User); - } - push( @$output, $c->tag( @@ -1291,7 +1208,7 @@ sub do_unarchive_course ($c) { if ($c->param('clean_up_course')) { my $ce_new = WeBWorK::CourseEnvironment->new({ courseName => $new_courseID }); - my $db_new = WeBWorK::DB->new($ce_new->{dbLayout}); + my $db_new = WeBWorK::DB->new($ce_new); for my $student_id ($db_new->listPermissionLevelsWhere({ permission => $ce->{userRoles}{student} })) { $db_new->deleteUser($student_id->[0]); @@ -1383,7 +1300,7 @@ sub upgrade_course_confirm ($c) { my @upgrade_courseIDs = $c->param('upgrade_courseIDs'); - my ($extra_database_tables_exist, $extra_database_fields_exist) = (0, 0); + my ($extra_database_tables_exist, $extra_database_fields_exist, $incorrect_type_database_fields_exist) = (0, 0, 0); my $status_output = $c->c; @@ -1394,12 +1311,13 @@ sub upgrade_course_confirm ($c) { my $ce2 = WeBWorK::CourseEnvironment->new({ courseName => $upgrade_courseID }); # Create integrity checker - my $CIchecker = WeBWorK::Utils::CourseIntegrityCheck->new(ce => $ce2); + my $CIchecker = WeBWorK::Utils::CourseDBIntegrityCheck->new($ce2); # Report on database status my ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($upgrade_courseID); - my ($all_tables_ok, $extra_database_tables, $extra_database_fields, $rebuild_table_indexes, $db_report) = - $c->formatReportOnDatabaseTables($dbStatus, $upgrade_courseID); + my ($all_tables_ok, $extra_database_tables, $extra_database_fields, $rebuild_table_indexes, + $incorrect_type_database_fields, $db_report) + = $c->formatReportOnDatabaseTables($dbStatus, $upgrade_courseID); my $course_output = $c->c; @@ -1472,8 +1390,26 @@ sub upgrade_course_confirm ($c) { ); } + if ($incorrect_type_database_fields) { + $incorrect_type_database_fields_exist = 1; + push( + @$course_output, + $c->tag( + 'p', + class => 'text-danger fw-bold', + $c->maketext( + 'There are database fields that do not have the same type as the field defined in the schema ' + . 'for at least one table. Check the checkbox by the field to change its type when ' + . 'upgrading the course. Warning: This can fail which may corrupt the table. If you have ' + . 'not archived this course, then do that now before upgrading if you want to change the ' + . 'type of any of these fields.' + ) + ) + ); + } + # Report on directory status - my ($directories_ok, $directory_report) = $CIchecker->checkCourseDirectories; + my ($directories_ok, $directory_report) = checkCourseDirectories($ce2); push(@$course_output, $c->tag('div', class => 'mb-2', $c->maketext('Directory structure:'))); push( @$course_output, @@ -1508,10 +1444,11 @@ sub upgrade_course_confirm ($c) { return $c->include( 'ContentGenerator/CourseAdmin/upgrade_course_confirm', - upgrade_courseIDs => \@upgrade_courseIDs, - extra_database_tables_exist => $extra_database_tables_exist, - extra_database_fields_exist => $extra_database_fields_exist, - status_output => $status_output->join('') + upgrade_courseIDs => \@upgrade_courseIDs, + extra_database_tables_exist => $extra_database_tables_exist, + extra_database_fields_exist => $extra_database_fields_exist, + incorrect_type_database_fields_exist => $incorrect_type_database_fields_exist, + status_output => $status_output->join('') ); } @@ -1525,15 +1462,16 @@ sub do_upgrade_course ($c) { my $ce2 = WeBWorK::CourseEnvironment->new({ courseName => $upgrade_courseID }); # Create integrity checker - my $CIchecker = WeBWorK::Utils::CourseIntegrityCheck->new(ce => $ce2); + my $CIchecker = WeBWorK::Utils::CourseDBIntegrityCheck->new($ce2); # Add missing tables and missing fields to existing tables my ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($upgrade_courseID); my @schema_table_names = keys %$dbStatus; my @tables_to_create = - grep { $dbStatus->{$_}[0] == WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_A } @schema_table_names; + grep { $dbStatus->{$_}[0] == WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_A } @schema_table_names; my @tables_to_alter = - grep { $dbStatus->{$_}[0] == WeBWorK::Utils::CourseIntegrityCheck::DIFFER_IN_A_AND_B } @schema_table_names; + grep { $dbStatus->{$_}[0] == WeBWorK::Utils::CourseDBIntegrityCheck::DIFFER_IN_A_AND_B } + @schema_table_names; my @upgrade_report; push( @@ -1546,8 +1484,10 @@ sub do_upgrade_course ($c) { push( @upgrade_report, $CIchecker->updateTableFields( - $upgrade_courseID, $table_name, - [ ($c->param("$upgrade_courseID.$table_name.delete_fieldIDs")) ] + $upgrade_courseID, + $table_name, + [ ($c->param("$upgrade_courseID.$table_name.delete_fieldIDs")) ], + [ ($c->param("$upgrade_courseID.$table_name.fix_type_fieldIDs")) ], ) ); } @@ -1555,8 +1495,9 @@ sub do_upgrade_course ($c) { # Analyze database status and prepare status report ($tables_ok, $dbStatus) = $CIchecker->checkCourseTables($upgrade_courseID); - my ($all_tables_ok, $extra_database_tables, $extra_database_fields, $rebuild_table_indexes, $db_report) = - $c->formatReportOnDatabaseTables($dbStatus); + my ($all_tables_ok, $extra_database_tables, $extra_database_fields, $rebuild_table_indexes, + $incorrect_type_database_fields, $db_report) + = $c->formatReportOnDatabaseTables($dbStatus); # Prepend course name $db_report = $c->c($c->tag('div', class => 'mb-2', $c->maketext('Database:')), $db_report); @@ -1584,9 +1525,23 @@ sub do_upgrade_course ($c) { ); } + if ($incorrect_type_database_fields) { + push( + @$db_report, + $c->tag( + 'p', + class => 'text-danger fw-bold', + $c->maketext( + 'There are database fields that do not have the same type as the ' + . 'field defined in the schema for at least one table.' + ) + ) + ); + } + # Add missing directories and prepare report on directory status - my $dir_update_messages = $CIchecker->updateCourseDirectories; # Needs more error messages - my ($directories_ok, $directory_report) = $CIchecker->checkCourseDirectories; + my $dir_update_messages = updateCourseDirectories($ce2); # Needs more error messages + my ($directories_ok, $directory_report) = checkCourseDirectories($ce2); # Show status my $course_report = $c->c; @@ -2292,11 +2247,38 @@ sub do_unhide_inactive_course ($c) { # LTI Course Map Management sub manage_lti_course_map_form ($c) { - my $ce = $c->ce; + my $ce = $c->ce; + my @courseIDs = listCourses($ce); my %courseMap = map { $_->course_id => $_->lms_context_id } $c->db->getLTICourseMapsWhere; for (@courseIDs) { $courseMap{$_} = '' unless defined $courseMap{$_} } - return $c->include('ContentGenerator/CourseAdmin/manage_lti_course_map_form', courseMap => \%courseMap); + + my %ltiConfigs = map { + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $_ }) }; + $_ => $@ + ? undef + : { + LTIVersion => $ce->{LTIVersion}, + $ce->{LTIVersion} + ? ( + $ce->{LTIVersion} eq 'v1p1' + ? (ConsumerKey => $ce->{LTI}{v1p1}{ConsumerKey}) + : $ce->{LTIVersion} eq 'v1p3' ? ( + PlatformID => $ce->{LTI}{v1p3}{PlatformID}, + ClientID => $ce->{LTI}{v1p3}{ClientID}, + DeploymentID => $ce->{LTI}{v1p3}{DeploymentID} + ) + : () + ) + : () + } + } @courseIDs; + + return $c->include( + 'ContentGenerator/CourseAdmin/manage_lti_course_map_form', + courseMap => \%courseMap, + ltiConfigs => \%ltiConfigs + ); } sub save_lti_course_map_validate ($c) { @@ -2419,6 +2401,230 @@ sub do_save_lti_course_map ($c) { return $c->manage_lti_course_map_form; } +# Form to copy or reset OTP secrets. +sub manage_otp_secrets_form ($c) { + my $courses = {}; + my $dbs = {}; + + # Create course data first, since it is used in all cases and initializes course db references. + for my $courseID (listCourses($c->ce)) { + my $ce = WeBWorK::CourseEnvironment->new({ courseName => $courseID }); + $dbs->{$courseID} = WeBWorK::DB->new($ce); + $courses->{$courseID} = [ $dbs->{$courseID}->listUsers ]; + } + + # Process the confirmed reset or copy actions here. + if ($c->param('otp_confirm_reset')) { + my $total = 0; + my $courseID = $c->param('sourceResetCourseID'); + for my $user ($c->param('otp_reset_row')) { + if ($courseID eq $c->ce->{courseName} && $user eq $c->param('user')) { + $c->addbadmessage($c->maketext('You may not reset your own OTP secret!')); + next; + } + my $password = $dbs->{$courseID}->getPassword($user); + if ($password && $password->otp_secret) { + $password->otp_secret(''); + $dbs->{$courseID}->putPassword($password); + $total++; + } + } + if ($total) { + $c->addgoodmessage($c->maketext('[_1] OTP secrets reset.', $total)); + } else { + $c->addbadmessage($c->maketext('No OTP secrets reset.')); + } + } elsif ($c->param('otp_confirm_copy')) { + my $total = 0; + for my $row ($c->param('otp_copy_row')) { + my ($s_course, $s_user, $d_course, $d_user) = split(':', $row); + if ($d_course eq $c->ce->{courseName} && $d_user eq $c->param('user')) { + $c->addbadmessage( + $c->maketext('You cannot overwrite your OTP secret with one from another course or user!')); + next; + } + my $s_password = $dbs->{$s_course}->getPassword($s_user); + if ($s_password && $s_password->otp_secret) { + # Password may not be defined if using external auth, so create new password record if not. + # Should we check $d_user is actually valid again (was checked on previous page)? + my $d_password = $dbs->{$d_course}->getPassword($d_user) + // $dbs->{$d_course}->newPassword(user_id => $d_user); + $d_password->otp_secret($s_password->otp_secret); + $dbs->{$d_course}->putPassword($d_password); + $total++; + } + } + if ($total) { + $c->addgoodmessage($c->maketext('[_1] OTP secrets copied.', $total)); + } else { + $c->addbadmessage($c->maketext('No OTP secrets copied.')); + } + } + + return $c->include('ContentGenerator/CourseAdmin/manage_otp_secrets_form', courses => $courses); +} + +# Deals with both single and multiple copy confirmation. +sub copy_otp_secrets_confirm ($c) { + my $action = $c->param('action'); + my $source_course; + my @source_users; + my @dest_courses; + my $dest_user; + + if ($action eq 'single') { + $source_course = $c->param('sourceSingleCourseID'); + @source_users = ($c->param('sourceSingleUserID')); + @dest_courses = ($c->param('destSingleCourseID')); + $dest_user = $c->param('destSingleUserID'); + } elsif ($action eq 'multiple') { + $source_course = $c->param('sourceMultipleCourseID'); + @source_users = ($c->param('sourceMultipleUserID')); + @dest_courses = ($c->param('destMultipleCourseID')); + } else { + $c->addbadmessage($c->maketext('Invalid action [_1].', $action)); + return $c->manage_otp_secrets_form; + } + + my @errors; + push(@errors, $c->maketext('Source course ID missing.')) unless (defined $source_course && $source_course ne ''); + push(@errors, $c->maketext('Source user ID missing.')) unless (@source_users && $source_users[0] ne ''); + push(@errors, $c->maketext('Destination course ID missing.')) unless (@dest_courses && $dest_courses[0] ne ''); + push(@errors, $c->maketext('Destination user ID missing.')) + unless ( + $action eq 'multiple' + || (defined $dest_user + && $dest_user ne '') + ); + if (@errors) { + for (@errors) { + $c->addbadmessage($_); + } + return $c->manage_otp_secrets_form; + } + if ($action eq 'single' && $source_course eq $dest_courses[0] && $source_users[0] eq $dest_user) { + $c->addbadmessage( + $c->maketext('Destination user must be different than source user when copying from same course')); + return $c->manage_otp_secrets_form; + } + if ($action eq 'multiple' && @dest_courses == 1 && $source_course eq $dest_courses[0]) { + $c->addbadmessage($c->maketext('Destination course must be different than source course.')); + return $c->manage_otp_secrets_form; + } + + my @rows; + my %dbs; + my $source_ce = WeBWorK::CourseEnvironment->new({ courseName => $source_course }); + $dbs{$source_course} = WeBWorK::DB->new($source_ce); + + for my $s_user (@source_users) { + my $s_user_password = $dbs{$source_course}->getPassword($s_user); + unless ($s_user_password && $s_user_password->otp_secret) { + push( + @rows, + { + source_course => $source_course, + source_user => $s_user, + source_message => $c->maketext('OTP secret is empty - Skipping'), + error => 'warning', + skip => 1, + } + ); + next; + } + + for my $d_course (@dest_courses) { + next if $action eq 'multiple' && $d_course eq $source_course; + + my $d_user = $action eq 'single' ? $dest_user : $s_user; + my $skip = 0; + my $error_message; + my $dest_error; + + unless ($dbs{$d_course}) { + my $dest_ce = WeBWorK::CourseEnvironment->new({ courseName => $d_course }); + $dbs{$d_course} = WeBWorK::DB->new($dest_ce); + } + + if ($d_course eq $c->ce->{courseName} && $d_user eq $c->param('user')) { + $dest_error = 'danger'; + $error_message = + $c->maketext('You cannot overwrite your OTP secret with one from another course or user!'); + $skip = 1; + } else { + my $d_user_password = $dbs{$d_course}->getPassword($d_user); + if (!defined $d_user_password) { + # Just because there is no password record, the user could still exist when using external auth. + unless ($dbs{$d_course}->existsUser($d_user)) { + $dest_error = 'warning'; + $error_message = $c->maketext('User does not exist - Skipping'); + $skip = 1; + } + } elsif ($d_user_password->otp_secret) { + $dest_error = 'danger'; + $error_message = $c->maketext('OTP Secret is not empty - Overwritting'); + } + } + + push( + @rows, + { + source_course => $source_course, + source_user => $s_user, + dest_course => $d_course, + dest_user => $d_user, + dest_message => $error_message, + error => $dest_error, + skip => $skip + } + ); + } + } + + return $c->include('ContentGenerator/CourseAdmin/copy_otp_secrets_confirm', action_rows => \@rows); +} + +sub reset_otp_secrets_confirm ($c) { + my $source_course = $c->param('sourceResetCourseID'); + my @dest_users = ($c->param('destResetUserID')); + + my @errors; + push(@errors, $c->maketext('Source course ID missing.')) unless (defined $source_course && $source_course ne ''); + push(@errors, $c->maketext('Destination user ID missing.')) unless (@dest_users && $dest_users[0] ne ''); + if (@errors) { + for (@errors) { + $c->addbadmessage($_); + } + return $c->manage_otp_secrets_form; + } + + my $ce = WeBWorK::CourseEnvironment->new({ courseName => $source_course }); + my $db = WeBWorK::DB->new($ce); + my @rows; + for my $user (@dest_users) { + my $error = ''; + + if ($source_course eq $c->ce->{courseName} && $user eq $c->param('user')) { + $error = $c->maketext('You may not reset your own OTP secret!'); + } else { + my $password = $db->getPassword($user); + $error = $c->maketext('OTP Secret is empty - Skipping') unless $password && $password->otp_secret; + } + + push( + @rows, + { + user => $user, + message => $error, + error => $error ? 'warning' : '', + skip => $error ? 1 : 0, + } + ); + } + + return $c->include('ContentGenerator/CourseAdmin/reset_otp_secrets_confirm', action_rows => \@rows); +} + sub do_registration ($c) { my $ce = $c->ce; @@ -2453,42 +2659,43 @@ sub do_registration ($c) { # Format a list of tables and fields in the database, and the status of each. sub formatReportOnDatabaseTables ($c, $dbStatus, $courseID = undef) { my %table_status_message = ( - WeBWorK::Utils::CourseIntegrityCheck::SAME_IN_A_AND_B => + WeBWorK::Utils::CourseDBIntegrityCheck::SAME_IN_A_AND_B => $c->tag('span', class => 'text-success me-2', $c->maketext('Table is ok')), - WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_A => $c->tag( + WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_A => $c->tag( 'span', class => 'text-danger me-2', $c->maketext('Table defined in schema but missing in database') ), - WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_B => $c->tag( + WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_B => $c->tag( 'span', class => 'text-danger me-2', $c->maketext('Table defined in database but missing in schema') ), - WeBWorK::Utils::CourseIntegrityCheck::DIFFER_IN_A_AND_B => $c->tag( + WeBWorK::Utils::CourseDBIntegrityCheck::DIFFER_IN_A_AND_B => $c->tag( 'span', class => 'text-danger me-2', $c->maketext('Schema and database table definitions do not agree') ) ); my %field_status_message = ( - WeBWorK::Utils::CourseIntegrityCheck::SAME_IN_A_AND_B => + WeBWorK::Utils::CourseDBIntegrityCheck::SAME_IN_A_AND_B => $c->tag('span', class => 'text-success me-2', $c->maketext('Field is ok')), - WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_A => + WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_A => $c->tag('span', class => 'text-danger me-2', $c->maketext('Field missing in database')), - WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_B => + WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_B => $c->tag('span', class => 'text-danger me-2', $c->maketext('Field missing in schema')), - WeBWorK::Utils::CourseIntegrityCheck::DIFFER_IN_A_AND_B => $c->tag( + WeBWorK::Utils::CourseDBIntegrityCheck::DIFFER_IN_A_AND_B => $c->tag( 'span', class => 'text-danger me-2', $c->maketext('Schema and database field definitions do not agree') ) ); - my $all_tables_ok = 1; - my $extra_database_tables = 0; - my $extra_database_fields = 0; - my $rebuild_table_indexes = 0; + my $all_tables_ok = 1; + my $extra_database_tables = 0; + my $extra_database_fields = 0; + my $rebuild_table_indexes = 0; + my $incorrect_type_database_fields = 0; my $db_report = $c->c; @@ -2498,9 +2705,9 @@ sub formatReportOnDatabaseTables ($c, $dbStatus, $courseID = undef) { my $table_status = $dbStatus->{$table}[0]; push(@$table_report, $table . ': ', $table_status_message{$table_status}); - if ($table_status == WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_A) { + if ($table_status == WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_A) { $all_tables_ok = 0; - } elsif ($table_status == WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_B) { + } elsif ($table_status == WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_B) { $extra_database_tables = 1; push( @$table_report, @@ -2515,7 +2722,7 @@ sub formatReportOnDatabaseTables ($c, $dbStatus, $courseID = undef) { ) ) ) if defined $courseID; - } elsif ($table_status == WeBWorK::Utils::CourseIntegrityCheck::DIFFER_IN_A_AND_B) { + } elsif ($table_status == WeBWorK::Utils::CourseDBIntegrityCheck::DIFFER_IN_A_AND_B) { my %fieldInfo = %{ $dbStatus->{$table}[1] }; my $fields_report = $c->c; @@ -2523,7 +2730,7 @@ sub formatReportOnDatabaseTables ($c, $dbStatus, $courseID = undef) { my $field_status = $fieldInfo{$key}[0]; my $field_report = $c->c("$key: $field_status_message{$field_status}"); - if ($field_status == WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_B) { + if ($field_status == WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_B) { if ($fieldInfo{$key}[1]) { $rebuild_table_indexes = 1; } else { @@ -2553,8 +2760,34 @@ sub formatReportOnDatabaseTables ($c, $dbStatus, $courseID = undef) { ); } } - } elsif ($field_status == WeBWorK::Utils::CourseIntegrityCheck::ONLY_IN_A) { + } elsif ($field_status == WeBWorK::Utils::CourseDBIntegrityCheck::ONLY_IN_A) { $all_tables_ok = 0; + } elsif ($field_status == WeBWorK::Utils::CourseDBIntegrityCheck::DIFFER_IN_A_AND_B) { + $incorrect_type_database_fields = 1; + if (defined $courseID) { + push( + @$field_report, + $c->tag( + 'span', + class => 'form-check d-inline-block', + $c->tag( + 'label', + class => 'form-check-label', + $c->c( + $c->check_box( + "$courseID.$table.fix_type_fieldIDs" => $key, + class => 'form-check-input' + ), + $c->maketext( + 'Change type of field from [_1] to [_2] when upgrading', + $fieldInfo{$key}[1], + $fieldInfo{$key}[2] + ) + )->join('') + ) + ) + ); + } } push(@$fields_report, $c->tag('li', $field_report->join(''))); } @@ -2568,8 +2801,9 @@ sub formatReportOnDatabaseTables ($c, $dbStatus, $courseID = undef) { push(@$db_report, $c->tag('p', class => 'text-success', $c->maketext('Database tables are ok'))) if $all_tables_ok; return ( - $all_tables_ok, $extra_database_tables, $extra_database_fields, - $rebuild_table_indexes, $db_report->join('') + $all_tables_ok, $extra_database_tables, $extra_database_fields, $rebuild_table_indexes, + $incorrect_type_database_fields, + $db_report->join('') ); } diff --git a/lib/WeBWorK/ContentGenerator/EquationDisplay.pm b/lib/WeBWorK/ContentGenerator/EquationDisplay.pm index 59917e4f9e..224837866b 100644 --- a/lib/WeBWorK/ContentGenerator/EquationDisplay.pm +++ b/lib/WeBWorK/ContentGenerator/EquationDisplay.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::EquationDisplay; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; diff --git a/lib/WeBWorK/ContentGenerator/Feedback.pm b/lib/WeBWorK/ContentGenerator/Feedback.pm index 2082d978b2..336e5fc127 100644 --- a/lib/WeBWorK/ContentGenerator/Feedback.pm +++ b/lib/WeBWorK/ContentGenerator/Feedback.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Feedback; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -25,10 +10,9 @@ WeBWorK::ContentGenerator::Feedback - Send mail to professors. use Data::Dumper; use Email::Stuffer; use Try::Tiny; -use Text::Wrap qw(wrap); use WeBWorK::Upload; -use WeBWorK::Utils qw(decodeAnswers createEmailSenderTransportSMTP fetchEmailRecipients); +use WeBWorK::Utils qw(createEmailSenderTransportSMTP fetchEmailRecipients); # request paramaters used # @@ -137,62 +121,29 @@ sub initialize ($c) { my $subject = $ce->{mail}{feedbackSubjectFormat} || 'WeBWorK question from %c: %u set %s/prob %p'; $subject =~ s/%([$chars])/defined $subject_map{$1} ? $subject_map{$1} : ''/eg; - # Get info about remote user. - my $remote_host = $c->tx->remote_address || 'UNKNOWN'; - my $remote_port = $c->tx->remote_port || 'UNKNOWN'; - - my $systemURL = $c->url_for('root')->to_abs; - - my $msg = qq/This message was automatically generated by the WeBWorK -system at $systemURL, in response to a request from $remote_host:$remote_port. - -Click this link to see the page from which the user sent feedback: -$emailableURL - -/; - - if ($feedback) { - $msg .= qq/***** The feedback message: *****\n\n\n$feedback\n\n\n/; - } - if ($problem and $verbosity >= 1) { - $msg .= - qq/***** Data about the problem processor: ***** \n\n/ - . 'Display Mode: ' - . $c->param('displayMode') . "\n" - . 'Show Old Answers: ' - . ($c->param('showOldAnswers') ? 'yes' : 'no') . "\n" - . 'Show Correct Answers: ' - . ($c->param('showCorrectAnswers') ? 'yes' : 'no') . "\n" - . 'Show Hints: ' - . ($c->param('showHints') ? 'yes' : 'no') . "\n" - . 'Show Solutions: ' - . ($c->param('showSolutions') ? 'yes' : 'no') . "\n\n"; - } - - if ($user && $verbosity >= 1) { - $msg .= "***** Data about the user: *****\n\n"; - $msg .= $c->format_user($user) . "\n"; - } - - if ($problem && $verbosity >= 1) { - $msg .= "***** Data about the problem: *****\n\n"; - $msg .= $c->format_userproblem($problem) . "\n"; - } - if ($set && $verbosity >= 1) { - $msg .= "***** Data about the homework set: *****\n\n" . $c->format_userset($set) . "\n"; - } - if ($ce && $verbosity >= 2) { - $msg .= "***** Data about the environment: *****\n\n" . Dumper($ce) . "\n\n"; - } + my %data = ( + user => $user, + emailableURL => $emailableURL, + feedback => $feedback, + problem => $problem, + set => $set, + verbosity => $verbosity, + remote_host => $c->tx->remote_address || 'UNKNOWN', + remote_port => $c->tx->remote_port || 'UNKNOWN' + ); - my $email = Email::Stuffer->to(join(',', @recipients))->subject($subject)->text_body($msg) - ->header('X-Remote-Host' => $remote_host); + my $email = + Email::Stuffer->to(join(',', @recipients))->subject($subject) + ->text_body($c->render_to_string('ContentGenerator/Feedback/feedback_email', format => 'txt', %data)) + ->html_body($c->render_to_string('ContentGenerator/Feedback/feedback_email', %data)) + ->header('X-Remote-Host' => $data{remote_host}); if ($ce->{feedback_sender_email}) { my $from_name = $user ? $user->full_name : $ce->{generic_sender_name}; $email->from("$from_name <$ce->{feedback_sender_email}>")->reply_to($sender); } else { $email->from($sender); } + # Extra headers $email->header('X-WeBWorK-Route', $route) if defined $route; $email->header('X-WeBWorK-Course', $courseID) if defined $courseID; @@ -208,7 +159,7 @@ $emailableURL my $fileIDhash = $c->param('attachment'); if ($fileIDhash) { my $attachment = - WeBWorK::Upload->retrieve(split(/\s+/, $fileIDhash), dir => $ce->{webworkDirs}{uploadCache}); + WeBWorK::Upload->retrieve(split(/\s+/, $fileIDhash), $ce->{webworkDirs}{uploadCache}); # Get the filename and read its contents. my $filename = $attachment->filename; @@ -218,7 +169,7 @@ $emailableURL local $/; $contents = <$fh>; }; - close $fh; + $fh->close; $attachment->dispose; # Check to see that this is an allowed filetype. @@ -237,6 +188,19 @@ $emailableURL return; } + # Email::Stuffer incorrectly adds the attachment together with the text and html body parts at the same + # level. As a result, when an email has a text body, an html body, and an attachment, both the text and + # html are shown in most email clients. The text and html body should be parts of a separate part that has + # content type 'multipart/alternative'. So the email has two parts, and the first part has two parts which + # are the text and html body. The second part is the attachment. So before attaching the file move the text + # and html body into their own part. + $email->{parts} = [ + Email::MIME->create( + attributes => { content_type => 'multipart/alternative' }, + parts => $email->{parts} + ) + ]; + # Attach the file. $email->attach($contents, filename => $filename); } @@ -267,81 +231,4 @@ sub page_title ($c) { return $c->ce->{feedback_button_name} || $c->maketext('E-mail Instructor'); } -sub format_user ($c, $user) { - my $ce = $c->ce; - - my $result = "User ID: " . $user->user_id . "\n"; - $result .= "Name: " . $user->full_name . "\n"; - $result .= "Email: " . $user->email_address . "\n"; - unless ($ce->{blockStudentIDinFeedback}) { - $result .= "Student ID: " . $user->student_id . "\n"; - } - - my $status_name = $ce->status_abbrev_to_name($user->status); - my $status_string = - defined $status_name - ? "$status_name ('" . $user->status . "')" - : $user->status . " (unknown status abbreviation)"; - $result .= "Status: $status_string\n"; - - $result .= "Section: " . $user->section . "\n"; - $result .= "Recitation: " . $user->recitation . "\n"; - $result .= "Comment: " . $user->comment . "\n"; - - return $result; -} - -sub format_userset ($c, $set) { - my $ce = $c->ce; - - my $result = "Set ID: " . $set->set_id . "\n"; - $result .= "Set header file: " . $set->set_header . "\n"; - $result .= "Hardcopy header file: " . $set->hardcopy_header . "\n"; - - $result .= "Open date: " . $c->formatDateTime($set->open_date) . "\n"; - $result .= "Due date: " . $c->formatDateTime($set->due_date) . "\n"; - $result .= "Answer date: " . $c->formatDateTime($set->answer_date) . "\n"; - $result .= "Visible: " . ($set->visible ? "yes" : "no") . "\n"; - $result .= "Assignment type: " . $set->assignment_type . "\n"; - if ($set->assignment_type =~ /gateway/) { - $result .= "Attempts per version: " . $set->assignment_type . "\n"; - $result .= "Time interval: " . $set->time_interval . "\n"; - $result .= "Versions per interval: " . $set->versions_per_interval . "\n"; - $result .= "Version time limit: " . $set->version_time_limit . "\n"; - $result .= "Version creation time: " . $c->formatDateTime($set->version_creation_time) . "\n"; - $result .= "Problem randorder: " . $set->problem_randorder . "\n"; - $result .= "Version last attempt time: " . $set->version_last_attempt_time . "\n"; - } - - return $result; -} - -sub format_userproblem ($c, $problem) { - my $ce = $c->ce; - - my $result = "Problem ID: " . $problem->problem_id . "\n"; - $result .= "Source file: " . $problem->source_file . "\n"; - $result .= "Value: " . $problem->value . "\n"; - $result .= - "Max attempts " . ($problem->max_attempts == -1 ? "unlimited" : $problem->max_attempts) . "\n"; - $result .= "Random seed: " . $problem->problem_seed . "\n"; - $result .= "Status: " . $problem->status . "\n"; - $result .= "Attempted: " . ($problem->attempted ? "yes" : "no") . "\n"; - - my %last_answer = decodeAnswers($problem->last_answer); - if (%last_answer) { - $result .= "Last answer:\n"; - foreach my $key (sort keys %last_answer) { - $result .= "\t$key: $last_answer{$key}\n" if $last_answer{$key}; - } - } else { - $result .= "Last answer: none\n"; - } - - $result .= "Number of correct attempts: " . $problem->num_correct . "\n"; - $result .= "Number of incorrect attempts: " . $problem->num_incorrect . "\n"; - - return $result; -} - 1; diff --git a/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm b/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm index 06e2ec4ea8..f97712269f 100644 --- a/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm +++ b/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::GatewayQuiz; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; @@ -26,19 +11,18 @@ deal with versioning sets use Mojo::Promise; use Mojo::JSON qw(encode_json decode_json); -use WeBWorK::Utils qw(encodeAnswers decodeAnswers wwRound); -use WeBWorK::Utils::DateTime qw(before between after); -use WeBWorK::Utils::Files qw(path_is_subdir); -use WeBWorK::Utils::Instructor qw(assignSetVersionToUser); -use WeBWorK::Utils::Logs qw(writeLog writeCourseLog); +use WeBWorK::Utils qw(encodeAnswers decodeAnswers wwRound); +use WeBWorK::Utils::DateTime qw(before between after); +use WeBWorK::Utils::Files qw(path_is_subdir); +use WeBWorK::Utils::Instructor qw(assignSetVersionToUser); +use WeBWorK::Utils::Logs qw(writeLog writeCourseLog); use WeBWorK::Utils::ProblemProcessing qw/create_ans_str_from_responses compute_reduced_score/; -use WeBWorK::Utils::Rendering qw(getTranslatorDebuggingOptions renderPG); -use WeBWorK::Utils::Sets qw(is_restricted); -use WeBWorK::DB::Utils qw(global2user fake_set fake_set_version fake_problem); +use WeBWorK::Utils::Rendering qw(getTranslatorDebuggingOptions renderPG); +use WeBWorK::Utils::Sets qw(is_restricted); +use WeBWorK::DB::Utils qw(global2user fake_set fake_set_version fake_problem); use WeBWorK::Debug; -use WeBWorK::Authen::LTIAdvanced::SubmitGrade; -use WeBWorK::Authen::LTIAdvantage::SubmitGrade; use PGrandom; +use WeBWorK::Authen::LTI::GradePassback qw(passbackGradeOnSubmit); use Caliper::Sensor; use Caliper::Entity; @@ -142,9 +126,12 @@ sub can_recordAnswers ($c, $user, $permissionLevel, $effectiveUser, $set, $probl if ($user->user_id ne $effectiveUser->user_id) { # If the user is not allowed to record answers as another user, return that permission. If the user is allowed - # to record only set version answers, then allow that between the open and close dates, and so drop out of this - # conditional to the usual one. - return 1 if $authz->hasPermissions($user->user_id, 'record_answers_when_acting_as_student'); + # to record an unsubmitted test, allow that. If the user is allowed to record only set version answers, then + # allow that between the open and close dates, and so drop out of this conditional to the usual one. + return 1 + if $authz->hasPermissions($user->user_id, 'record_answers_when_acting_as_student') + || $c->can_gradeUnsubmittedTest($user, $permissionLevel, $effectiveUser, $set, $problem, $tmplSet, + $submitAnswers); return 0 if !$authz->hasPermissions($user->user_id, 'record_set_version_answers_when_acting_as_student'); } @@ -225,6 +212,15 @@ sub can_checkAnswers ($c, $user, $permissionLevel, $effectiveUser, $set, $proble return 0; } +# If user can use the problem grader, and the test is past due and has not been submitted, allow them to submit. +sub can_gradeUnsubmittedTest ($c, $user, $permissionLevel, $effectiveUser, $set, $problem, $tmplSet, $submitAnswers = 0) +{ + return + !$submitAnswers + && $c->can_showProblemGrader($user, $permissionLevel, $effectiveUser, $set, $problem, $tmplSet) + && (after($set->due_date + $c->ce->{gatewayGracePeriod}) && !$set->version_last_attempt_time); +} + sub can_showScore ($c, $user, $permissionLevel, $effectiveUser, $set, $problem, $tmplSet) { return $c->authz->hasPermissions($user->user_id, 'view_hidden_work') @@ -331,7 +327,8 @@ async sub pre_header_initialize ($c) { $c->{assignment_type} = 'gateway'; if (!$authz->hasPermissions($userID, 'modify_problem_sets')) { - $c->{invalidSet} = 'You do not have the authorization level required to view/edit undefined sets.'; + $c->{invalidSet} = + $c->maketext('You do not have the authorization level required to view/edit undefined sets.'); # Define these so that we can drop through to report the error in body. $tmplSet = fake_set($db); @@ -340,8 +337,8 @@ async sub pre_header_initialize ($c) { } else { # In this case we're creating a fake set from the input, so the input must include a source file. if (!$c->param('sourceFilePath')) { - $c->{invalidSet} = - 'An Undefined_Set was requested, but no source file for the contained problem was provided.'; + $c->{invalidSet} = $c->maketext( + 'An Undefined_Set was requested, but no source file for the contained problem was provided.'); # Define these so that we can drop through to report the error in body. $tmplSet = fake_set($db); @@ -388,6 +385,21 @@ async sub pre_header_initialize ($c) { } } } else { + # If there is a cap on problems per page, make sure that is respected in the global set in + # case something higher snuck in. + if ($ce->{test}{maxProblemsPerPage}) { + my $globalSet = $db->getGlobalSet($setID); + if ( + $ce->{test}{maxProblemsPerPage} + && ($globalSet->problems_per_page == 0 + || $globalSet->problems_per_page > $ce->{test}{maxProblemsPerPage}) + ) + { + $globalSet->problems_per_page($ce->{test}{maxProblemsPerPage}); + $db->putGlobalSet($globalSet); + } + } + # Get the template set, i.e., the non-versioned set that's assigned to the user. # If this failed in authz->checkSet, then $c->{invalidSet} is set. $tmplSet = $db->getMergedSet($effectiveUserID, $setID); @@ -397,6 +409,13 @@ async sub pre_header_initialize ($c) { # to be able to continue. $c->{assignment_type} = $tmplSet->assignment_type || 'gateway'; + # If there is a cap on problems per page, make sure that is respected in case something higher snuck in. + if ($ce->{test}{maxProblemsPerPage} + && ($tmplSet->problems_per_page == 0 || $tmplSet->problems_per_page > $ce->{test}{maxProblemsPerPage})) + { + $tmplSet->problems_per_page($ce->{test}{maxProblemsPerPage}); + } + # next, get the latest (current) version of the set if we don't have a # requested version number my @allVersionIds = $db->listSetVersions($effectiveUserID, $setID); @@ -427,12 +446,19 @@ async sub pre_header_initialize ($c) { } else { # If there is not a requested version or a latest version, then create dummy set to proceed. # FIXME RETURN TO: should this be global2version? - $set = global2user($ce->{dbLayout}{set_version}{record}, $db->getGlobalSet($setID)); + $set = global2user($db->{set_version}{record}, $db->getGlobalSet($setID)); $set->user_id($effectiveUserID); $set->psvn('000'); $set->set_id($setID); # redundant? $set->version_id(0); } + + # If there is a cap on problems per page, make sure that is respected in case something higher snuck in. + if ($ce->{test}{maxProblemsPerPage} + && ($set->problems_per_page == 0 || $set->problems_per_page > $ce->{test}{maxProblemsPerPage})) + { + $set->problems_per_page($ce->{test}{maxProblemsPerPage}); + } } my $setVersionNumber = $set ? $set->version_id : 0; @@ -498,7 +524,7 @@ async sub pre_header_initialize ($c) { if ($setVersionNumber && !$c->{invalidSet} && $setID ne 'Undefined_Set') { my @setVersionIDs = $db->listSetVersions($effectiveUserID, $setID); - my @setVersions = $db->getSetVersions(map { [ $effectiveUserID, $setID,, $_ ] } @setVersionIDs); + my @setVersions = $db->getSetVersions(map { [ $effectiveUserID, $setID, $_ ] } @setVersionIDs); for (@setVersions) { $totalNumVersions++; $currentNumVersions++ @@ -529,84 +555,109 @@ async sub pre_header_initialize ($c) { && ( $effectiveUserID eq $userID || ( - $authz->hasPermissions($userID, 'record_answers_when_acting_as_student') - || ($authz->hasPermissions($userID, 'create_new_set_version_when_acting_as_student') - && $c->param('createnew_ok')) + ( + $authz->hasPermissions($userID, 'record_answers_when_acting_as_student') + || $authz->hasPermissions($userID, 'create_new_set_version_when_acting_as_student') + ) + && $c->param('submit_for_student_ok') ) ) ) { - # Assign the set, get the right name, version number, etc., and redefine the $set and $problem for the - # remainder of this method. + # Attempt to assign the set. my $setTmpl = $db->getUserSet($effectiveUserID, $setID); - assignSetVersionToUser($db, $effectiveUserID, $setTmpl); - $setVersionNumber++; - - # Get a clean version of the set and merged version to use in the rest of the routine. - my $cleanSet = $db->getSetVersion($effectiveUserID, $setID, $setVersionNumber); - $set = $db->getMergedSetVersion($effectiveUserID, $setID, $setVersionNumber); - $set->visible(1); - - $problem = $db->getMergedProblemVersion($effectiveUserID, $setID, $setVersionNumber, $setPNum[0]); - - # Convert the floating point value from Time::HiRes to an integer for use below. Truncate towards 0. - my $timeNowInt = int($c->submitTime); - - # Set up creation time, and open and due dates. - my $ansOffset = $set->answer_date - $set->due_date; - $set->version_creation_time($timeNowInt); - $set->open_date($timeNowInt); - # Figure out the due date, taking into account the time limit cap. - my $dueTime = - $timeLimit == 0 || ($set->time_limit_cap && $c->submitTime + $timeLimit > $set->due_date) - ? $set->due_date - : $timeNowInt + $timeLimit; - - $set->due_date($dueTime); - $set->answer_date($set->due_date + $ansOffset); - $set->version_last_attempt_time(0); - - # Put this new info into the database. Put back the data needed for the version, and leave blank any - # information that should be inherited from the user set or global set. Set the data which determines - # if a set is open, because a set version should not reopen after it's complete. - $cleanSet->version_creation_time($set->version_creation_time); - $cleanSet->open_date($set->open_date); - $cleanSet->due_date($set->due_date); - $cleanSet->answer_date($set->answer_date); - $cleanSet->version_last_attempt_time($set->version_last_attempt_time); - $cleanSet->version_time_limit($set->version_time_limit); - $cleanSet->attempts_per_version($set->attempts_per_version); - $cleanSet->assignment_type($set->assignment_type); - $db->putSetVersion($cleanSet); - - # This is a new set version, so it's open. - $versionIsOpen = 1; - - # Set the number of attempts for this set to zero. - $currentNumAttempts = 0; + eval { assignSetVersionToUser($db, $effectiveUserID, $setTmpl) }; + + if ($@) { + $c->log->error("Error creating test version of $setID for $effectiveUserID: $@"); + $c->{invalidSet} = + $c->maketext('Unable to generate a valid test version. This is usually caused by invalid ' + . 'usage of grouping sets or a database error. Please speak to your instructor to fix the ' + . 'error. A system administrator can obtain more details on this error from the logs.'); + # Attempt to delete the set version if it was created. Failure from this attempt is ignored. + eval { $db->deleteSetVersion($userID, $setID, $setVersionNumber + 1) } + if $db->existsSetVersion($userID, $setID, $setVersionNumber + 1); + } else { + # Get the right name, version number, etc., and redefine the + # $set and $problem for the remainder of this method. + + ++$setVersionNumber; + + # Get a clean version of the set and merged version to use in the rest of the routine. + my $cleanSet = $db->getSetVersion($effectiveUserID, $setID, $setVersionNumber); + $set = $db->getMergedSetVersion($effectiveUserID, $setID, $setVersionNumber); + $set->visible(1); + # If there is a cap on problems per page, make sure that is respected in case something higher snuck in. + if ( + $ce->{test}{maxProblemsPerPage} + && ($tmplSet->problems_per_page == 0 + || $tmplSet->problems_per_page > $ce->{test}{maxProblemsPerPage}) + ) + { + $tmplSet->problems_per_page($ce->{test}{maxProblemsPerPage}); + } + + $problem = $db->getMergedProblemVersion($effectiveUserID, $setID, $setVersionNumber, $setPNum[0]); + + # Convert the floating point value from Time::HiRes to an integer for use below. Truncate toward 0. + my $timeNowInt = int($c->submitTime); + + # Set up creation time, and open and due dates. + my $ansOffset = $set->answer_date - $set->due_date; + $set->version_creation_time($timeNowInt); + $set->open_date($timeNowInt); + # Figure out the due date, taking into account the time limit cap. + my $dueTime = + $timeLimit == 0 || ($set->time_limit_cap && $c->submitTime + $timeLimit > $set->due_date) + ? $set->due_date + : $timeNowInt + $timeLimit; + + $set->due_date($dueTime); + $set->answer_date($set->due_date + $ansOffset); + $set->version_last_attempt_time(0); + + # Put this new info into the database. Put back the data needed for the version, and leave blank + # any information that should be inherited from the user set or global set. Set the data which + # determines if a set is open, because a set version should not reopen after it's complete. + $cleanSet->version_creation_time($set->version_creation_time); + $cleanSet->open_date($set->open_date); + $cleanSet->due_date($set->due_date); + $cleanSet->answer_date($set->answer_date); + $cleanSet->version_last_attempt_time($set->version_last_attempt_time); + $cleanSet->version_time_limit($set->version_time_limit); + $cleanSet->attempts_per_version($set->attempts_per_version); + $cleanSet->assignment_type($set->assignment_type); + $db->putSetVersion($cleanSet); + + # This is a new set version, so it's open. + $versionIsOpen = 1; + + # Set the number of attempts for this set to zero. + $currentNumAttempts = 0; + } } elsif ($maxAttempts != -1 && $totalNumVersions > $maxAttempts) { - $c->{invalidSet} = 'No new versions of this assignment are available, ' - . 'because you have already taken the maximum number allowed.'; + $c->{invalidSet} = $c->maketext('No new versions of this test are available, ' + . 'because you have already taken the maximum number allowed.'); - } elsif ($effectiveUserID ne $userID - && $authz->hasPermissions($userID, 'create_new_set_version_when_acting_as_student')) + } elsif ( + $effectiveUserID ne $userID + && ($authz->hasPermissions($userID, 'record_answers_when_acting_as_student') + || $authz->hasPermissions($userID, 'create_new_set_version_when_acting_as_student')) + ) { - - $c->{invalidSet} = - "User $effectiveUserID is being acted " - . 'as. If you continue, you will create a new version of this set ' - . 'for that user, which will count against their allowed maximum ' - . 'number of versions for the current time interval. IN GENERAL, THIS ' - . 'IS NOT WHAT YOU WANT TO DO. Please be sure that you want to ' - . 'do this before clicking the "Create new set version" link ' - . 'below. Alternately, PRESS THE "BACK" BUTTON and continue.'; - $c->{invalidVersionCreation} = 1; + $c->stash->{actingConfirmation} = $c->maketext( + 'You are acting as user [_1]. If you continue, you will create a new version of ' + . 'this test for that user, which will count against their allowed maximum ' + . 'number of versions for the current time interval. In general, this is not ' + . 'what you want to do. Please be sure that you want to do this before clicking ' + . 'the "Create New Test Version" button below. Alternatively, click "Cancel".', + $effectiveUserID + ); + $c->stash->{actingConfirmationButton} = $c->maketext('Create New Test Version'); } elsif ($effectiveUserID ne $userID) { - $c->{invalidSet} = "User $effectiveUserID is being acted as. " - . 'When acting as another user, new versions of the set cannot be created.'; - $c->{invalidVersionCreation} = 2; + $c->{actingCreationError} = 1; } elsif (($maxAttemptsPerVersion == 0 || $currentNumAttempts < $maxAttemptsPerVersion) && $c->submitTime < $set->due_date() + $ce->{gatewayGracePeriod}) @@ -615,16 +666,16 @@ async sub pre_header_initialize ($c) { $versionIsOpen = 1; } else { $c->{invalidSet} = - 'No new versions of this assignment are available, because the set is not open or its time' - . ' limit has expired.'; + $c->maketext('No new versions of this test are available, because the test is ' + . 'not open or its time limit has expired.'); } } elsif ($versionsPerInterval && ($currentNumVersions >= $versionsPerInterval)) { $c->{invalidSet} = - 'You have already taken all available versions of this test in the current time interval. ' - . 'You may take the test again after the time interval has expired.'; + $c->maketext('You have already taken all available versions of this test in the current ' + . 'time interval. You may take the test again after the time interval has expired.'); } @@ -633,19 +684,44 @@ async sub pre_header_initialize ($c) { if ( ($currentNumAttempts < $maxAttemptsPerVersion) && ($effectiveUserID eq $userID - || $authz->hasPermissions($userID, 'record_set_version_answers_when_acting_as_student')) + || $authz->hasPermissions($userID, 'record_set_version_answers_when_acting_as_student') + || $authz->hasPermissions($userID, 'record_answers_when_acting_as_student')) ) { if (between($set->open_date(), $set->due_date() + $ce->{gatewayGracePeriod}, $c->submitTime)) { $versionIsOpen = 1; + + # If acting as another user, then the user has permissions to record answers for the + # student which is dangerous for open test versions. Give a warning unless the user + # has already confirmed they understand the risk. + if ($effectiveUserID ne $userID && !$c->param('submit_for_student_ok')) { + $c->stash->{actingConfirmation} = $c->maketext( + 'You are trying to view an open test version for [_1] and have the permission to submit ' + . 'answers for that user. This is dangerous, as your answers can overwrite the ' + . q/student's answers as you move between test pages, preview, or check answers. / + . 'If you are planing to submit answers for this student, click "View Test Version" ' + . 'below to continue. If you only want to view the test version, click "Cancel" ' + . 'below, then disable the permission to record answers when acting as a student ' + . 'before viewing open test versions.', + $effectiveUserID + ); + $c->stash->{actingConfirmationButton} = $c->maketext('View Test Version'); + } } } } } elsif (!$c->{invalidSet} && !$requestedVersion) { - $c->{invalidSet} = 'This set is closed. No new set versions may be taken.'; + $c->{invalidSet} = $c->maketext('This test is closed. No new test versions may be taken.'); } + if ($c->stash->{actingConfirmation}) { + # Store session while waiting for confirmation for proctored tests. + $c->authen->session(acting_proctor => 1) if $c->{assignment_type} eq 'proctored_gateway'; + return; + } + delete $c->authen->session->{acting_proctor}; + # If the proctor session key does not have a set version id, then add it. This occurs when a student # initially enters a proctored test, since the version id is not determined until just above. if ($c->authen->session('proctor_authorization_granted') @@ -655,8 +731,8 @@ async sub pre_header_initialize ($c) { else { delete $c->authen->session->{proctor_authorization_granted}; } } - # If the set or problem is invalid, then delete any proctor session keys and return. - if ($c->{invalidSet} || $c->{invalidProblem}) { + # If the set is invalid, then delete any proctor session keys and return. + if ($c->{invalidSet} || $c->{actingCreationError}) { if (defined $c->{assignment_type} && $c->{assignment_type} eq 'proctored_gateway') { delete $c->authen->session->{proctor_authorization_granted}; } @@ -700,7 +776,7 @@ async sub pre_header_initialize ($c) { # Bail without doing anything if the set isn't yet open for this user. if (!($c->{isOpen} || $authz->hasPermissions($userID, 'view_unopened_sets'))) { - $c->{invalidSet} = 'This set is not yet open.'; + $c->{invalidSet} = $c->maketext('This test is not yet open.'); return; } @@ -732,6 +808,7 @@ async sub pre_header_initialize ($c) { checkAnswers => $c->can_checkAnswers(@args), recordAnswersNextTime => $c->can_recordAnswers(@args, $c->{submitAnswers}), checkAnswersNextTime => $c->can_checkAnswers(@args, $c->{submitAnswers}), + gradeUnsubmittedTest => $c->can_gradeUnsubmittedTest(@args, $c->{submitAnswers}), showScore => $c->can_showScore(@args), showProblemScores => $c->can_showProblemScores(@args), showWork => $c->can_showWork(@args), @@ -746,6 +823,12 @@ async sub pre_header_initialize ($c) { $c->{can} = \%can; $c->{will} = \%will; + # Issue a warning if a test has not been submitted, but can still be graded by the instructor. + $c->addbadmessage( + $c->maketext( + 'This test version is past due, but has not been graded. You can still grade the test for this user.') + ) if $can{gradeUnsubmittedTest} && $userID ne $effectiveUserID; + # Set up problem numbering and multipage variables. my @problemNumbers; @@ -821,7 +904,7 @@ async sub pre_header_initialize ($c) { my $problemN = $mergedProblems[$pIndex]; if (!defined $problemN) { - $c->{invalidSet} = 'One or more of the problems in this set have not been assigned to you.'; + $c->{invalidSet} = $c->maketext('One or more of the problems in this test have not been assigned to you.'); return; } @@ -872,7 +955,7 @@ async sub pre_header_initialize ($c) { my $setVName = "$setID,v$versionID"; # Report everything with the request submit time. Convert the floating point - # value from Time::HiRes to an integer for use below. Truncate towards 0. + # value from Time::HiRes to an integer for use below. Truncate toward 0. my $timeNowInt = int($c->submitTime); # Answer processing @@ -880,13 +963,12 @@ async sub pre_header_initialize ($c) { debug('begin answer processing'); my @scoreRecordedMessage = ('') x scalar(@problems); - my $LTIGradeResult = -1; + my $ltiGradePassbackMessage; # Save results to database as appropriate if ($c->{submitAnswers} || (($c->{previewAnswers} || $c->param('newPage')) && $can{recordAnswers})) { # If answers are being submitted, then save the problems to the database. If this is a preview or page change # and answers can be recorded, then save the last answer for future reference. - # Also save the persistent data to the database even when the last answer is not saved. # Deal with answers being submitted for a proctored exam. If there are no attempts left, then delete the # proctor session key so that it isn't possible to start another proctored test without being reauthorized. @@ -912,30 +994,8 @@ async sub pre_header_initialize ($c) { ($past_answers_string, $encoded_last_answer_string, $scores, $answer_types_string) = create_ans_str_from_responses($c->{formFields}, $pg_result, $pureProblem->flags =~ /:needs_grading/); - - # Transfer persistent problem data from the PERSISTENCE_HASH: - # - Get keys to update first, to avoid extra work when no updated ar - # are needed. When none, we avoid the need to decode/encode JSON, - # to save the pureProblem when it would not otherwise be saved. - # - We are assuming that there is no need to DELETE old - # persistent data if the hash is empty, even if in - # potential there may be some data already in the database. - my @persistent_data_keys = keys %{ $pg_result->{PERSISTENCE_HASH_UPDATED} }; - if (@persistent_data_keys) { - my $json_data = decode_json($pureProblem->{problem_data} || '{}'); - for my $key (@persistent_data_keys) { - $json_data->{$key} = $pg_result->{PERSISTENCE_HASH}{$key}; - } - $pureProblem->problem_data(encode_json($json_data)); - - # If the pureProblem will not be saved below, we should save the - # persistent data here before any other changes are made to it. - if (($c->{submitAnswers} && !$will{recordAnswers})) { - $c->db->putProblemVersion($pureProblem); - } - } } else { - my $prefix = sprintf('Q%04d_', $problemNumbers[$i]); + my $prefix = sprintf('Q%04d_', $problemNumbers[ $probOrder[$i] ]); my @fields = sort grep {/^(?!previous).*$prefix/} (keys %{ $c->{formFields} }); my %answersToStore = map { $_ => $c->{formFields}->{$_} } @fields; my @answer_order = @fields; @@ -966,6 +1026,7 @@ async sub pre_header_initialize ($c) { $pureProblem->attempted(1); $pureProblem->num_correct($pg_result->{state}{num_of_correct_ans}); $pureProblem->num_incorrect($pg_result->{state}{num_of_incorrect_ans}); + $pureProblem->problem_data(encode_json($pg_result->{PERSISTENCE_HASH} || '{}')); # Add flags which are really a comma separated list of answer types. $pureProblem->flags($answer_types_string); @@ -994,7 +1055,7 @@ async sub pre_header_initialize ($c) { . $problem->num_correct . "\t" . $problem->num_incorrect); } elsif ($c->{submitAnswers}) { - # This is the case answers were submitted but can not be saved. Report an error message. + # This is the case answers were submitted but cannot be saved. Report an error message. if ($c->{isClosed}) { $scoreRecordedMessage[ $probOrder[$i] ] = $c->maketext('Your score was not recorded because this problem set version is not open.'); @@ -1023,17 +1084,11 @@ async sub pre_header_initialize ($c) { } } - # Try to update the student score on the LMS if that option is enabled. - if ($c->{submitAnswers} && $will{recordAnswers} && $ce->{LTIGradeMode} && $ce->{LTIGradeOnSubmit}) { - my $grader = $ce->{LTI}{ $ce->{LTIVersion} }{grader}->new($c); - if ($ce->{LTIGradeMode} eq 'course') { - $LTIGradeResult = await $grader->submit_course_grade($effectiveUserID); - } elsif ($ce->{LTIGradeMode} eq 'homework') { - $LTIGradeResult = await $grader->submit_set_grade($effectiveUserID, $setID); - } - } + # Send the score for this set to the LMS if enabled. + $ltiGradePassbackMessage = await passbackGradeOnSubmit($c, $effectiveUserID, $c->{set}) + if $c->{submitAnswers} && $will{recordAnswers} && $ce->{LTIGradeMode}; - # Finally, log student answers answers are being submitted, provided that answers can be recorded. Note that + # Finally, log student answers that are being submitted, provided that answers can be recorded. Note that # this will log an overtime submission (or any case where someone submits the test, or spoofs a request to # submit a test). my $answer_log = $ce->{courseFiles}{logs}{answer_log}; @@ -1142,50 +1197,11 @@ async sub pre_header_initialize ($c) { # Reset start time $c->param('startTime', ''); } - } else { - # This 'else' case includes initial load of the first page of the - # quiz and checkAnswers calls, as well as when $can{recordAnswers} - # is false. - - # Save persistent data to database even in this case, when answers - # would not or can not be recorded. - my @pureProblems = $db->getAllProblemVersions($effectiveUserID, $setID, $versionID); - for my $i (0 .. $#problems) { - # Process each problem. - my $pureProblem = $pureProblems[ $probOrder[$i] ]; - my $pg_result = $pg_results[ $probOrder[$i] ]; - - if (ref $pg_result) { - # Transfer persistent problem data from the PERSISTENCE_HASH: - # - Get keys to update first, to avoid extra work when no updates - # are needed. When none, we avoid the need to decode/encode JSON, - # or to save the pureProblem. - # - We are assuming that there is no need to DELETE old - # persistent data if the hash is empty, even if in - # potential there may be some data already in the database. - my @persistent_data_keys = keys %{ $pg_result->{PERSISTENCE_HASH_UPDATED} }; - next unless (@persistent_data_keys); # stop now if nothing to do - if ($isFakeSet) { - warn join("", - "This problem stores persistent data and this cannot be done in a fake set. ", - "Some functionality may not work properly when testing this problem in this setting."); - next; - } - - my $json_data = decode_json($pureProblem->{problem_data} || '{}'); - for my $key (@persistent_data_keys) { - $json_data->{$key} = $pg_result->{PERSISTENCE_HASH}{$key}; - } - $pureProblem->problem_data(encode_json($json_data)); - - $c->db->putProblemVersion($pureProblem); - } - } } debug('end answer processing'); - $c->{scoreRecordedMessage} = \@scoreRecordedMessage; - $c->{LTIGradeResult} = $LTIGradeResult; + $c->{scoreRecordedMessage} = \@scoreRecordedMessage; + $c->{ltiGradePassbackMessage} = $ltiGradePassbackMessage; # Additional set-level database manipulation: We want to save the time that a set was submitted, and for proctored # tests we want to reset the assignment type after a set is submitted for the last time so that it's possible to @@ -1328,7 +1344,8 @@ sub path ($c, $args) { $args, 'WeBWorK' => $navigation_allowed ? $c->url_for('root') : '', $courseName => $navigation_allowed ? $c->url_for('set_list') : '', - $setID eq 'Undefined_Set' || $c->{invalidSet} + $setID eq 'Undefined_Set' + || $c->{invalidSet} || $c->{actingCreationError} || $c->stash->{actingConfirmation} ? ($setID => '') : ( $c->{set}->set_id => $c->url_for('problem_list', setID => $c->{set}->set_id), @@ -1342,7 +1359,7 @@ sub nav ($c, $args) { my $userID = $c->param('user'); my $effectiveUserID = $c->param('effectiveUser'); - return '' if $c->{invalidSet}; + return '' if $c->{invalidSet} || $c->{actingCreationError} || $c->stash->{actingConfirmation}; # Set up and display a student navigation for those that have permission to act as a student. if ($c->authz->hasPermissions($userID, 'become_student') && $effectiveUserID ne $userID) { @@ -1483,7 +1500,10 @@ async sub getProblemHTML ($c, $effectiveUser, $set, $formFields, $mergedProblem) : !$c->{previewAnswers} && $c->{will}{showCorrectAnswers} ? 1 : 0 ), - debuggingOptions => getTranslatorDebuggingOptions($c->authz, $c->{userID}) + debuggingOptions => getTranslatorDebuggingOptions($c->authz, $c->{userID}), + $c->{can}{checkAnswers} && defined $formFields->{ 'problem_data_' . $mergedProblem->problem_id } + ? (problemData => $formFields->{ 'problem_data_' . $mergedProblem->problem_id }) + : () }, ); @@ -1502,6 +1522,12 @@ async sub getProblemHTML ($c, $effectiveUser, $set, $formFields, $mergedProblem) $pg->{body_text} = undef; } + # If the user can check answers and either this is not an answer submission or the problem_data form + # parameter was previously set, then set or update the problem_data form parameter. + $c->param('problem_data_' . $mergedProblem->problem_id => encode_json($pg->{PERSISTENCE_HASH} || '{}')) + if $c->{can}{checkAnswers} + && (!$c->{submitAnswers} || defined $c->param('problem_data_' . $mergedProblem->problem_id)); + return $pg; } diff --git a/lib/WeBWorK/ContentGenerator/Grades.pm b/lib/WeBWorK/ContentGenerator/Grades.pm index 6845193158..567dd25f08 100644 --- a/lib/WeBWorK/ContentGenerator/Grades.pm +++ b/lib/WeBWorK/ContentGenerator/Grades.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Grades; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -22,10 +7,11 @@ WeBWorK::ContentGenerator::Grades - Display statistics by user. =cut -use WeBWorK::Utils qw(wwRound); -use WeBWorK::Utils::DateTime qw(after); -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); -use WeBWorK::Utils::Sets qw(grade_set format_set_name_display); +use WeBWorK::Utils qw(wwRound); +use WeBWorK::Utils::DateTime qw(after); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); +use WeBWorK::Utils::Sets qw(grade_set format_set_name_display); +use WeBWorK::Utils::ProblemProcessing qw(compute_unreduced_score); use WeBWorK::Localize; sub initialize ($c) { @@ -319,7 +305,7 @@ sub displayStudentStats ($c, $studentID) { next; } - my ($totalRight, $total, $problem_scores, $problem_incorrect_attempts) = + my ($totalRight, $total, $problem_scores, $problem_incorrect_attempts, $problem_records) = grade_set($db, $set, $studentID, $setIsVersioned, 1); $totalRight = wwRound(2, $totalRight); @@ -334,8 +320,9 @@ sub displayStudentStats ($c, $studentID) { $show_problem_scores = 0; } - for (my $i = 0; $i < $max_problems; ++$i) { - my $score = defined $problem_scores->[$i] && $show_problem_scores ? $problem_scores->[$i] : ''; + for my $i (0 .. $max_problems - 1) { + my $score = defined $problem_scores->[$i] && $show_problem_scores ? $problem_scores->[$i] : ''; + my $is_correct = $score =~ /^\d+$/ && compute_unreduced_score($ce, $problem_records->[$i], $set) == 1; push( @html_prob_scores, $c->tag( @@ -344,7 +331,7 @@ sub displayStudentStats ($c, $studentID) { $c->c( $c->tag( 'span', - class => $score eq '100' ? 'correct' : $score eq ' . ' ? 'unattempted' : '', + class => $is_correct ? 'correct' : $score eq ' . ' ? 'unattempted' : '', $c->b($score) ), $c->tag('br'), diff --git a/lib/WeBWorK/ContentGenerator/Hardcopy.pm b/lib/WeBWorK/ContentGenerator/Hardcopy.pm index ecfc9bc34f..fdd35ed8ca 100644 --- a/lib/WeBWorK/ContentGenerator/Hardcopy.pm +++ b/lib/WeBWorK/ContentGenerator/Hardcopy.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Hardcopy; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; @@ -29,13 +14,13 @@ use String::ShellQuote; use Archive::Zip qw(:ERROR_CODES); use XML::LibXML; -use WeBWorK::DB::Utils qw/user2global/; -use WeBWorK::Utils qw(decodeAnswers x); -use WeBWorK::Utils::DateTime qw(after); -use WeBWorK::Utils::Files qw(readFile); -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); +use WeBWorK::DB::Utils qw/user2global/; +use WeBWorK::Utils qw(decodeAnswers x); +use WeBWorK::Utils::DateTime qw(after); +use WeBWorK::Utils::Files qw(readFile); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); use WeBWorK::Utils::Rendering qw(renderPG); -use WeBWorK::Utils::Sets qw(is_restricted); +use WeBWorK::Utils::Sets qw(is_restricted); use PGrandom; =head1 CONFIGURATION VARIABLES @@ -1007,8 +992,7 @@ async sub write_set_tex ($c, $FH, $TargetUser, $themeTree, $setID) { { print $FH '\\def\\webworkReducedScoringDate{' . ($c->formatDateTime($MergedSet->{reduced_scoring_date}, $ce->{studentDateDisplayFormat}) =~ - s/\x{202f}/ /gr) - . "}%\n"; + s/\x{202f}/ /gr) . "}%\n"; } # write set header (theme presetheader, then PG header, then theme postsetheader) @@ -1181,10 +1165,10 @@ async sub write_problem_tex ($c, $FH, $TargetUser, $MergedSet, $themeTree, $prob problemID => $MergedProblem->problem_id, ), $MergedProblem->problem_id == 0 - # link for a fake problem (like a header file) + # link for a fake problem (like a header file) ? (params => { sourceFilePath => $MergedProblem->source_file, problemSeed => $MergedProblem->problem_seed }) - # link for a real problem + # link for a real problem : (), ); diff --git a/lib/WeBWorK/ContentGenerator/Home.pm b/lib/WeBWorK/ContentGenerator/Home.pm index 3cfea1c8ce..291a9ccc7c 100644 --- a/lib/WeBWorK/ContentGenerator/Home.pm +++ b/lib/WeBWorK/ContentGenerator/Home.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Home; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -23,7 +8,6 @@ WeBWorK::ContentGenerator::Home - display a list of courses. =cut use WeBWorK::Utils::Files qw(readFile); -use WeBWorK::Localize; sub info ($c) { my $result; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AchievementEditor.pm b/lib/WeBWorK/ContentGenerator/Instructor/AchievementEditor.pm index 57b65b2500..5d15249e15 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AchievementEditor.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AchievementEditor.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::AchievementEditor; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -25,7 +10,7 @@ WeBWorK::ContentGenerator::Instructor::AchievementEditor - edit an achevement ev use HTML::Entities; use File::Copy; -use WeBWorK::Utils qw(fix_newlines not_blank x); +use WeBWorK::Utils qw(fix_newlines not_blank x); use WeBWorK::Utils::Files qw(surePathToFile readFile path_is_subdir); use constant ACTION_FORMS => [qw(save save_as)]; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm b/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm index 7a59fda322..ca74586076 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::AchievementList; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -47,7 +32,7 @@ links to edit the evaluator and the individual user data. use Mojo::File; use Text::CSV; -use WeBWorK::Utils qw(sortAchievements x); +use WeBWorK::Utils qw(sortAchievements x); use WeBWorK::Utils::Files qw(surePathToFile); # Forms @@ -196,50 +181,47 @@ sub edit_handler ($c) { # Handler for assigning achievements to users sub assign_handler ($c) { my $db = $c->db; - my @users = $db->listUsers; my $overwrite = $c->param('action.assign.overwrite') eq 'everything'; my $scope = $c->param('action.assign.scope'); my @achievementIDs = $scope eq 'all' ? @{ $c->{allAchievementIDs} } : @{ $c->{selectedAchievementIDs} }; - # Enable all achievements + my @users = $db->listUsers; my @achievements = $db->getAchievements(@achievementIDs); - for my $achievement (@achievements) { - $achievement->enabled(1); - $db->putAchievement($achievement); - } - - # Assign globalUserAchievement data, overwriting if necc + # Enable all achievements. + for my $achievement (@achievements) { $achievement->enabled(1); } + $db->Achievement->update_records(\@achievements) if @achievements; + # Assign globalUserAchievement data, overwriting if necessary. + my (@globalAchievementRecordsToAdd, @globalAchievementRecordsToPut); + my %existingGlobalUserAchievements = map { $_ => 1 } $db->listGlobalUserAchievements; for my $user (@users) { - if (not $db->existsGlobalUserAchievement($user)) { - my $globalUserAchievement = $db->newGlobalUserAchievement(); - $globalUserAchievement->user_id($user); - $db->addGlobalUserAchievement($globalUserAchievement); + my $globalUserAchievement = $db->newGlobalUserAchievement(user_id => $user); + if (!$existingGlobalUserAchievements{$user}) { + push(@globalAchievementRecordsToAdd, $globalUserAchievement); } elsif ($overwrite) { - my $globalUserAchievement = $db->newGlobalUserAchievement(); - $globalUserAchievement->user_id($user); - $db->putGlobalUserAchievement($globalUserAchievement); + push(@globalAchievementRecordsToPut, $globalUserAchievement); } } + $db->GlobalUserAchievement->insert_records(\@globalAchievementRecordsToAdd) if @globalAchievementRecordsToAdd; + $db->GlobalUserAchievement->update_records(\@globalAchievementRecordsToPut) if @globalAchievementRecordsToPut; - # Assign userAchievement data, overwriting if necc - + # Assign userAchievement data, overwriting if necessary. + my (@userAchievementRecordsToAdd, @userAchievementRecordsToPut); for my $achievementID (@achievementIDs) { + my %existingUserAchievements = + map { $_->[0] => 1 } $db->listUserAchievementsWhere({ achievement_id => $achievementID }); for my $user (@users) { - if (not $db->existsUserAchievement($user, $achievementID)) { - my $userAchievement = $db->newUserAchievement(); - $userAchievement->user_id($user); - $userAchievement->achievement_id($achievementID); - $db->addUserAchievement($userAchievement); + my $userAchievement = $db->newUserAchievement(user_id => $user, achievement_id => $achievementID); + if (!$existingUserAchievements{$user}) { + push(@userAchievementRecordsToAdd, $userAchievement); } elsif ($overwrite) { - my $userAchievement = $db->newUserAchievement(); - $userAchievement->user_id($user); - $userAchievement->achievement_id($achievementID); - $db->putUserAchievement($userAchievement); + push(@userAchievementRecordsToPut, $userAchievement); } } } + $db->UserAchievement->insert_records(\@userAchievementRecordsToAdd) if @userAchievementRecordsToAdd; + $db->UserAchievement->update_records(\@userAchievementRecordsToPut) if @userAchievementRecordsToPut; return (1, $c->maketext('Assigned achievements to users.')); } @@ -252,8 +234,20 @@ sub score_handler ($c) { my $scope = $c->param('action.score.scope'); my @achievementsToScore = $scope eq 'all' ? @{ $c->{allAchievementIDs} } : $c->param('selected_achievements'); + # First get everything that is needed from the database. + my @achievements = sortAchievements($db->getAchievements(@achievementsToScore)); + my @users = $db->getUsersWhere({ user_id => { not_like => 'set_id:%' } }, [qw(section last_name)]); + + my %globalUserAchievements = map { $_->user_id => $_ } $db->getGlobalUserAchievementsWhere; + + my %userAchievements; + for (@achievements) { + $userAchievements{ $_->user_id }{ $_->achievement_id } = $_ + for $db->getUserAchievementsWhere({ achievement_id => $_->achievement_id }); + } + # Define file name - my $scoreFileName = $courseName . "_achievement_scores.csv"; + my $scoreFileName = $courseName . '_achievement_scores.csv'; my $scoreFilePath = $ce->{courseDirs}{scoring} . '/' . $scoreFileName; # Back up existing file @@ -265,60 +259,41 @@ sub score_handler ($c) { # Check path and open the file $scoreFilePath = surePathToFile($ce->{courseDirs}{scoring}, $scoreFilePath); - my $SCORE = Mojo::File->new($scoreFilePath)->open('>:encoding(UTF-8)') - or return (0, $c->maketext("Failed to open [_1]", $scoreFilePath)); + my $scoreFile = Mojo::File->new($scoreFilePath)->open('>:encoding(UTF-8)') + or return (0, $c->maketext('Failed to open [_1]', $scoreFilePath)); # Print out header info - print $SCORE $c->maketext("username, last name, first name, section, achievement level, achievement score,"); - - my @achievements = $db->getAchievements(@achievementsToScore); - @achievements = sortAchievements(@achievements); + print $scoreFile $c->maketext('username, last name, first name, section, achievement level, achievement score,'); for my $achievement (@achievements) { - print $SCORE $achievement->achievement_id . ", "; - } - print $SCORE "\n"; - - my @users = $db->listUsers; - - # Get user records - my @userRecords = (); - for my $currentUser (@users) { - my $userObj = $db->getUser($currentUser); - die "Unable to find user object for $currentUser. " unless $userObj; - push(@userRecords, $userObj); + print $scoreFile $achievement->achievement_id . ', '; } - - @userRecords = - sort { (lc($a->section) cmp lc($b->section)) || (lc($a->last_name) cmp lc($b->last_name)) } @userRecords; + print $scoreFile "\n"; # Print out achievement information for each user - for my $userRecord (@userRecords) { + for my $userRecord (@users) { my $user_id = $userRecord->user_id; - next unless $db->existsGlobalUserAchievement($user_id); - next if ($userRecord->{status} eq 'D' || $userRecord->{status} eq 'A'); - print $SCORE "$user_id, $userRecord->{last_name}, $userRecord->{first_name}, $userRecord->{section}, "; - my $globalUserAchievement = $db->getGlobalUserAchievement($user_id); - my $level_id = $globalUserAchievement->level_achievement_id; - $level_id = ' ' unless $level_id; - my $points = $globalUserAchievement->achievement_points; - $points = 0 unless $points; - print $SCORE "$level_id, $points, "; + next if !$globalUserAchievements{$user_id} || $userRecord->{status} eq 'D' || $userRecord->{status} eq 'A'; + + print $scoreFile "$user_id, $userRecord->{last_name}, $userRecord->{first_name}, $userRecord->{section}, "; + + my $level_id = $globalUserAchievements{$user_id}->level_achievement_id || ' '; + my $points = $globalUserAchievements{$user_id}->achievement_points || 0; + print $scoreFile "$level_id, $points, "; for my $achievement (@achievements) { my $achievement_id = $achievement->achievement_id; - if ($db->existsUserAchievement($user_id, $achievement_id)) { - my $userAchievement = $db->getUserAchievement($user_id, $achievement_id); - print $SCORE $userAchievement->earned ? "1, " : "0, "; + if ($userAchievements{$user_id}{$achievement_id}) { + print $scoreFile $userAchievements{$user_id}{$achievement_id}->earned ? '1, ' : '0, '; } else { - print $SCORE ", "; + print $scoreFile ', '; } } - print $SCORE "\n"; + print $scoreFile "\n"; } - $SCORE->close; + $scoreFile->close; # Include a download link return ( @@ -345,11 +320,13 @@ sub delete_handler ($c) { my @achievementIDsToDelete = @{ $c->{selectedAchievementIDs} }; my %allAchievementIDs = map { $_ => 1 } @{ $c->{allAchievementIDs} }; + my %visibleAchievementIDs = map { $_ => 1 } @{ $c->{visibleAchievementIDs} }; my %selectedAchievementIDs = map { $_ => 1 } @{ $c->{selectedAchievementIDs} }; # Iterate over selected achievements and delete. for my $achievementID (@achievementIDsToDelete) { delete $allAchievementIDs{$achievementID}; + delete $visibleAchievementIDs{$achievementID}; delete $selectedAchievementIDs{$achievementID}; $db->deleteAchievement($achievementID); @@ -357,12 +334,13 @@ sub delete_handler ($c) { # Update local fields $c->{allAchievementIDs} = [ keys %allAchievementIDs ]; + $c->{visibleAchievementIDs} = [ keys %visibleAchievementIDs ]; $c->{selectedAchievementIDs} = [ keys %selectedAchievementIDs ]; return (1, $c->maketext('Deleted [quant,_1,achievement].', scalar @achievementIDsToDelete)); } -# Handler for creating an ahcievement +# Handler for creating an achievement sub create_handler ($c) { my $db = $c->db; my $ce = $c->ce; @@ -370,42 +348,48 @@ sub create_handler ($c) { # Create achievement my $newAchievementID = $c->param('action.create.id'); - return (0, $c->maketext("Failed to create new achievement: no achievement ID specified!")) + return (0, $c->maketext('Failed to create new achievement: no achievement ID specified!')) unless $newAchievementID =~ /\S/; - return (0, $c->maketext("Achievement [_1] exists. No achievement created.", $newAchievementID)) + return (0, $c->maketext('Achievement [_1] exists. No achievement created.', $newAchievementID)) if $db->existsAchievement($newAchievementID); - my $newAchievementRecord = $db->newAchievement; - my $oldAchievementID = $c->{selectedAchievementIDs}->[0]; my $type = $c->param('action.create.type'); # Either assign empty data or copy over existing data - if ($type eq "empty") { - $newAchievementRecord->achievement_id($newAchievementID); - $newAchievementRecord->enabled(0); - $newAchievementRecord->assignment_type('default'); - $newAchievementRecord->test('blankachievement.at'); - $db->addAchievement($newAchievementRecord); - } elsif ($type eq "copy") { - return (0, $c->maketext("Failed to duplicate achievement: no achievement selected for duplication!")) + if ($type eq 'empty') { + eval { + $db->addAchievement($db->newAchievement( + achievement_id => $newAchievementID, + enabled => 0, + assignment_type => 'default', + test => 'blankachievement.at' + )); + }; + return (0, $c->maketext('Failed to create new achievement: [_1]', $@)) if $@; + } elsif ($type eq 'copy') { + my $oldAchievementID = $c->{selectedAchievementIDs}[0]; + return (0, $c->maketext('Failed to duplicate achievement: no achievement selected for duplication!')) unless $oldAchievementID =~ /\S/; - $newAchievementRecord = $db->getAchievement($oldAchievementID); + my $newAchievementRecord = $db->getAchievement($oldAchievementID); + return (0, $c->maketext('Failed to duplicate achievement: selected achievement does not exist!')) + unless $newAchievementRecord; $newAchievementRecord->achievement_id($newAchievementID); - $db->addAchievement($newAchievementRecord); - + eval { $db->addAchievement($newAchievementRecord) }; + return (0, $c->maketext('Failed to create new achievement: [_1]', $@)) if $@; } - # Assign achievement to current user - my $userAchievement = $db->newUserAchievement(); - $userAchievement->user_id($user); - $userAchievement->achievement_id($newAchievementID); - $db->addUserAchievement($userAchievement); - # Add to local list of achievements push @{ $c->{allAchievementIDs} }, $newAchievementID; push @{ $c->{visibleAchievementIDs} }, $newAchievementID; - return (0, $c->maketext("Failed to create new achievement: [_1]", $@)) if $@; + # Assign achievement to current user + eval { $db->addUserAchievement($db->newUserAchievement(user_id => $user, achievement_id => $newAchievementID)) }; + return ( + 0, + $c->maketext( + "Successfully created achievement, but failed to assign achievement to current user: [_1]", $@ + ) + ) if $@; return (1, $c->maketext('Successfully created new achievement [_1]', $newAchievementID)); } @@ -422,6 +406,8 @@ sub import_handler ($c) { my %visibleAchievementIDs = map { $_ => 1 } @{ $c->{visibleAchievementIDs} }; my $filePath = $ce->{courseDirs}{achievements} . '/' . $fileName; + my @userAchievementRecordsToAdd; + # Open file name my $fh = Mojo::File->new($filePath)->open('<:encoding(UTF-8)') or return (0, $c->maketext("Failed to open [_1]", $filePath)); @@ -430,7 +416,6 @@ sub import_handler ($c) { my $count = 0; my $csv = Text::CSV->new(); while (my $data = $csv->getline($fh)) { - my $achievement_id = $$data[0]; # Add imported achievement to visible list even if it already exists. @@ -462,24 +447,35 @@ sub import_handler ($c) { $count++; $allAchievementIDs{$achievement_id} = 1; - # Assign to usesrs if neccessary + # Assign to users if necessary. if ($assign eq "all") { for my $user (@users) { - if (not $db->existsGlobalUserAchievement($user)) { - my $globalUserAchievement = $db->newGlobalUserAchievement(); - $globalUserAchievement->user_id($user); - $db->addGlobalUserAchievement($globalUserAchievement); - } my $userAchievement = $db->newUserAchievement(); $userAchievement->user_id($user); $userAchievement->achievement_id($achievement_id); - $db->addUserAchievement($userAchievement); + push(@userAchievementRecordsToAdd, $userAchievement); } } } $fh->close; + # If achievements are going to be assigned, then add global user achievements + # for users for which they do not already exist. + if (@userAchievementRecordsToAdd) { + my @globalAchievementRecordsToAdd; + my %existingGlobalUserAchievements = map { $_ => 1 } $db->listGlobalUserAchievements; + for my $user (@users) { + next if $existingGlobalUserAchievements{$user}; + my $globalUserAchievement = $db->newGlobalUserAchievement(user_id => $user); + push(@globalAchievementRecordsToAdd, $globalUserAchievement); + } + $db->GlobalUserAchievement->insert_records(\@globalAchievementRecordsToAdd) if @globalAchievementRecordsToAdd; + } + + # Actually perform the assignments of the added achievements if there are any to assign. + $db->UserAchievement->insert_records(\@userAchievementRecordsToAdd) if @userAchievementRecordsToAdd; + $c->{allAchievementIDs} = [ keys %allAchievementIDs ]; $c->{visibleAchievementIDs} = [ keys %visibleAchievementIDs ]; return (1, $c->maketext('Imported [quant,_1,achievement].', $count)); diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AchievementNotificationEditor.pm b/lib/WeBWorK/ContentGenerator/Instructor/AchievementNotificationEditor.pm index 558d4f0350..906845efa4 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AchievementNotificationEditor.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AchievementNotificationEditor.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::AchievementNotificationEditor; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -22,7 +7,7 @@ WeBWorK::ContentGenerator::Instructor::AchievementNotificationEditor - edit the =cut -use WeBWorK::Utils qw(fix_newlines not_blank x); +use WeBWorK::Utils qw(fix_newlines not_blank x); use WeBWorK::Utils::Files qw(surePathToFile readFile path_is_subdir); use constant ACTION_FORMS => [qw(save save_as existing disable)]; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AchievementUserEditor.pm b/lib/WeBWorK/ContentGenerator/Instructor/AchievementUserEditor.pm index dd1c757efd..733eb7c286 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AchievementUserEditor.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AchievementUserEditor.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::AchievementUserEditor; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -29,105 +14,112 @@ sub initialize ($c) { my $achievementID = $c->stash('achievementID'); my $user = $c->param('user'); - # Make sure this is defined for the template. - $c->stash->{userRecords} = []; + # Make sure these are defined for the template. + $c->stash->{userRecords} = []; + $c->stash->{userAchievementRecords} = []; # Check permissions return unless $authz->hasPermissions($user, 'edit_achievements'); - my @all_users = $db->listUsers; + $c->stash->{userRecords} = + [ $db->getUsersWhere({ user_id => { not_like => 'set_id:%' } }, [qw/section last_name first_name/]) ]; + $c->stash->{userAchievementRecords} = + { map { $_->user_id => $_ } $db->getUserAchievementsWhere({ achievement_id => $achievementID }) }; + my %selectedUsers = map { $_ => 1 } $c->param('selected'); my $doAssignToSelected = 0; - #Check and see if we need to assign or unassign things + # Check and see if we need to assign or unassign achievements. if (defined $c->param('assignToAll')) { $c->addgoodmessage($c->maketext('Achievement has been assigned to all users.')); - %selectedUsers = map { $_ => 1 } @all_users; + %selectedUsers = map { $_->user_id => 1 } @{ $c->stash->{userRecords} }; $doAssignToSelected = 1; } elsif (defined $c->param('unassignFromAll') && defined($c->param('unassignFromAllSafety')) && $c->param('unassignFromAllSafety') == 1) { %selectedUsers = (); - $c->addbadmessage($c->maketext('Achievement has been unassigned to all students.')); + $c->addgoodmessage($c->maketext('Achievement has been unassigned from all users.')); $doAssignToSelected = 1; } elsif (defined $c->param('assignToSelected')) { $c->addgoodmessage($c->maketext('Achievement has been assigned to selected users.')); $doAssignToSelected = 1; } elsif (defined $c->param('unassignFromAll')) { - # no action taken $c->addbadmessage($c->maketext('No action taken')); } - #do actual assignment and unassignment + # Do the actual assignment and unassignment. if ($doAssignToSelected) { + my $achievement = $db->getAchievement($achievementID); + + my %globalUserAchievements = map { $_->user_id => $_ } $db->getGlobalUserAchievementsWhere; - my %achievementUsers = map { $_ => 1 } $db->listAchievementUsers($achievementID); - foreach my $selectedUser (@all_users) { - if (exists $selectedUsers{$selectedUser} && $achievementUsers{$selectedUser}) { - # update existing user data (in case fields were changed) - my $userAchievement = $db->getUserAchievement($selectedUser, $achievementID); + my ( + @userAchievementsToInsert, @userAchievementsToUpdate, @userAchievementsToDelete, + @globalUserAchievementsToInsert, @globalUserAchievementsToUpdate, + ); + + for my $user (@{ $c->stash->{userRecords} }) { + my $userID = $user->user_id; + if ($selectedUsers{$userID} && $c->stash->{userAchievementRecords}{$userID}) { + # Update existing user data (in case fields were changed). + my $updatedEarned = $c->param("$userID.earned") ? 1 : 0; + my $earned = $c->stash->{userAchievementRecords}{$userID}->earned ? 1 : 0; - my $updatedEarned = $c->param("$selectedUser.earned") ? 1 : 0; - my $earned = $userAchievement->earned ? 1 : 0; if ($updatedEarned != $earned) { + $c->stash->{userAchievementRecords}{$userID}->earned($updatedEarned); - $userAchievement->earned($updatedEarned); - my $globalUserAchievement = $db->getGlobalUserAchievement($selectedUser); - my $achievement = $db->getAchievement($achievementID); + my $points = $achievement->points || 0; + my $initialpoints = $globalUserAchievements{$userID}->achievement_points || 0; - my $points = $achievement->points || 0; - my $initialpoints = $globalUserAchievement->achievement_points || 0; - #add the correct number of points if we - # are saying that the user now earned the - # achievement, or remove them otherwise + # Add the correct number of points if we are saying that the + # user now earned the achievement, or remove them otherwise. if ($updatedEarned) { - - $globalUserAchievement->achievement_points($initialpoints + $points); + $globalUserAchievements{$userID}->achievement_points($initialpoints + $points); } else { - $globalUserAchievement->achievement_points($initialpoints - $points); + $globalUserAchievements{$userID}->achievement_points($initialpoints - $points); } - $db->putGlobalUserAchievement($globalUserAchievement); + push(@globalUserAchievementsToUpdate, $globalUserAchievements{$userID}); } - $userAchievement->counter($c->param("$selectedUser.counter")); - $db->putUserAchievement($userAchievement); - - } elsif (exists $selectedUsers{$selectedUser}) { - # add users that dont exist - my $userAchievement = $db->newUserAchievement(); - $userAchievement->user_id($selectedUser); - $userAchievement->achievement_id($achievementID); - $db->addUserAchievement($userAchievement); - - #If they dont have global achievement data, then add that too - if (not $db->existsGlobalUserAchievement($selectedUser)) { - my $globalUserAchievement = $db->newGlobalUserAchievement(); - $globalUserAchievement->user_id($selectedUser); - $db->addGlobalUserAchievement($globalUserAchievement); + my $updatedCounter = $c->param("$userID.counter") // ''; + my $counter = $c->stash->{userAchievementRecords}{$userID}->counter // ''; + $c->stash->{userAchievementRecords}{$userID}->counter($updatedCounter) + if $updatedCounter ne $counter; + + push(@userAchievementsToUpdate, $c->stash->{userAchievementRecords}{$userID}) + if $updatedEarned != $earned || $updatedCounter ne $counter; + } elsif ($selectedUsers{$userID}) { + # Add user achievements that don't exist. + $c->stash->{userAchievementRecords}{$userID} = $db->newUserAchievement; + $c->stash->{userAchievementRecords}{$userID}->user_id($userID); + $c->stash->{userAchievementRecords}{$userID}->achievement_id($achievementID); + push(@userAchievementsToInsert, $c->stash->{userAchievementRecords}{$userID}); + + # If the user does not have global achievement data, then add that too. + if (!$globalUserAchievements{$userID}) { + $globalUserAchievements{$userID} = $db->newGlobalUserAchievement(user_id => $userID); + push(@globalUserAchievementsToInsert, $globalUserAchievements{$userID}); } - } else { - # delete users who are not selected - # but dont delete users who dont exist - next unless $achievementUsers{$selectedUser}; - $db->deleteUserAchievement($selectedUser, $achievementID); + # Delete achievements for users that are not selected, but don't delete achievements that don't exist. + next unless $c->stash->{userAchievementRecords}{$userID}; + push(@userAchievementsToDelete, $c->stash->{userAchievementRecords}{$userID}); + delete $c->stash->{userAchievementRecords}{$userID}; } } - } - my @userRecords; - for my $currentUser (@all_users) { - my $userObj = $c->db->getUser($currentUser); - die "Unable to find user object for $currentUser. " unless $userObj; - push(@userRecords, $userObj); - } - @userRecords = - sort { (lc($a->section) cmp lc($b->section)) || (lc($a->last_name) cmp lc($b->last_name)) } @userRecords; + $db->GlobalUserAchievement->insert_records(\@globalUserAchievementsToInsert) if @globalUserAchievementsToInsert; + $db->GlobalUserAchievement->update_records(\@globalUserAchievementsToUpdate) if @globalUserAchievementsToUpdate; + $db->UserAchievement->insert_records(\@userAchievementsToInsert) if @userAchievementsToInsert; + $db->UserAchievement->update_records(\@userAchievementsToUpdate) if @userAchievementsToUpdate; - $c->stash->{userRecords} = \@userRecords; + # This is one of the rare places this can be done since user achievements don't + # have any dependent rows in other tables that also need to be deleted. + $db->UserAchievement->delete_records(\@userAchievementsToDelete) if @userAchievementsToDelete; + } return; } diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AddUsers.pm b/lib/WeBWorK/ContentGenerator/Instructor/AddUsers.pm index 3fba6e86ba..471c3e68d1 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AddUsers.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AddUsers.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::AddUsers; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -22,7 +7,7 @@ WeBWorK::ContentGenerator::Instructor::AddUsers - Menu interface for adding user =cut -use WeBWorK::Utils qw/cryptPassword trim_spaces/; +use WeBWorK::Utils qw/cryptPassword trim_spaces/; use WeBWorK::Utils::Instructor qw(assignSetsToUsers); sub initialize ($c) { diff --git a/lib/WeBWorK/ContentGenerator/Instructor/Assigner.pm b/lib/WeBWorK/ContentGenerator/Instructor/Assigner.pm index 6b56bdc9c5..7e728946eb 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/Assigner.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/Assigner.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::Assigner; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/Config.pm b/lib/WeBWorK/ContentGenerator/Instructor/Config.pm index bcfc74cead..918c38ae26 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/Config.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/Config.pm @@ -1,18 +1,3 @@ -################################################################################ -# 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::Config; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm b/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm index ffe587ff43..97d99f1e32 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm @@ -1,18 +1,3 @@ -################################################################################ -# 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::FileManager; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -28,12 +13,12 @@ use File::Copy; use File::Spec; use String::ShellQuote; use Archive::Tar; -use Archive::Zip qw(:ERROR_CODES); +use Archive::Zip qw(:ERROR_CODES); use Archive::Zip::SimpleZip qw($SimpleZipError); -use WeBWorK::Utils qw(sortByName min); +use WeBWorK::Utils qw(sortByName min); use WeBWorK::Utils::CourseManagement qw(archiveCourse); -use WeBWorK::Utils::Files qw(readFile); +use WeBWorK::Utils::Files qw(readFile path_is_subdir); use WeBWorK::Upload; use constant HOME => 'templates'; @@ -135,11 +120,11 @@ sub Init ($c) { sub HiddenFlags ($c) { return $c->c( - $c->hidden_field(dates => ''), - $c->hidden_field(overwrite => ''), - $c->hidden_field(unpack => ''), - $c->hidden_field(autodelete => ''), - $c->hidden_field(autodelete => 'Automatic'), + $c->hidden_field(dates => $c->getFlag('dates')), + $c->hidden_field(format => $c->getFlag('format', 'Automatic')), + $c->hidden_field(overwrite => $c->getFlag('overwrite')), + $c->hidden_field(unpack => $c->getFlag('unpack')), + $c->hidden_field(autodelete => $c->getFlag('autodelete')), )->join(''); } @@ -311,7 +296,7 @@ sub Rename ($c) { my $realpath = Mojo::File->new($oldfile)->realpath; if (grep { $realpath eq Mojo::File->new("$c->{courseRoot}/$_")->realpath } @{ $c->ce->{uneditableCourseFiles} }) { - $c->addbadmessage($c->maketext('The file "[_1]" is protected and can not be renamed.', $original)); + $c->addbadmessage($c->maketext('The file "[_1]" is protected and cannot be renamed.', $original)); return $c->Refresh(); } @@ -530,8 +515,8 @@ sub unpack_archive ($c, $archive) { @members = $zip->members; for (@members) { - my $out_file = $dir->child($_->fileName)->realpath; - if ($out_file !~ /^$dir/) { + my $out_file = $dir->child($_->fileName); + if (!path_is_subdir($out_file, $dir)) { push(@outside_files, $_->fileName); next; } @@ -565,8 +550,8 @@ sub unpack_archive ($c, $archive) { @members = $tar->list_files; for (@members) { - my $out_file = $dir->child($_)->realpath; - if ($out_file !~ /^$dir/) { + my $out_file = $dir->child($_); + if (!path_is_subdir($out_file->to_string, $dir->to_string)) { push(@outside_files, $_); next; } @@ -601,7 +586,7 @@ sub unpack_archive ($c, $archive) { 'p', $c->maketext( 'The following [plural,_1,file is,files are] outside the current working directory ' - . 'and can not be safely unpacked.', + . 'and cannot be safely unpacked.', scalar(@outside_files), ) ) @@ -720,8 +705,7 @@ sub Upload ($c) { return $c->Refresh; } - my ($id, $hash) = split(/\s+/, $fileIDhash); - my $upload = WeBWorK::Upload->retrieve($id, $hash, dir => $c->{ce}{webworkDirs}{uploadCache}); + my $upload = WeBWorK::Upload->retrieve(split(/\s+/, $fileIDhash), $c->{ce}{webworkDirs}{uploadCache}); my $name = $upload->filename; my $invalidUploadFilenameMsg = $c->checkName($name); @@ -794,21 +778,24 @@ sub Upload ($c) { if ($type ne 'Binary') { my $fh = $upload->fileHandle; my @lines = <$fh>; + $fh->close; $data = join('', @lines); if ($type eq 'Automatic') { $type = isText($data) ? 'Text' : 'Binary' } } if ($type eq 'Text') { $upload->dispose; $data =~ s/\r\n?/\n/g; + + my $backup_data = $data; + my $success = utf8::decode($data); # try to decode as utf8 + unless ($success) { + warn "Trying to convert file $file from latin1? to UTF-8"; + utf8::upgrade($backup_data); # try to convert data from latin1 to utf8. + $data = $backup_data; + } + if (open(my $UPLOAD, '>:encoding(UTF-8)', $file)) { - my $backup_data = $data; - my $success = utf8::decode($data); # try to decode as utf8 - unless ($success) { - warn "Trying to convert file $file from latin1? to UTF-8"; - utf8::upgrade($backup_data); # try to convert data from latin1 to utf8. - $data = $backup_data; - } - print $UPLOAD $data; # print massaged data to file. + print $UPLOAD $data; close $UPLOAD; } else { $c->addbadmessage($c->maketext(q{Can't create file "[_1]": [_2]}, $name, $!)); @@ -899,11 +886,11 @@ sub directoryListing ($c, $pwd) { my $file = "$dir/$name"; my $type = 0; - $type |= 1 if -l $file; # Symbolic link - $type |= 2 if !-l $file && -d $file; # Directory - $type |= 4 if -f $file; # Regular file - $type |= 8 if -T $file; # Text file - $type |= 16 if $file =~ m/\.(gif|jpg|png)$/i; # Image file + $type |= 1 if $c->isSymLink($file); # Symbolic link + $type |= 2 if !$c->isSymLink($file) && -d $file; # Directory + $type |= 4 if -f $file; # Regular file + $type |= 8 if -T $file; # Text file + $type |= 16 if $file =~ m/\.(gif|jpg|png)$/i; # Image file my $label = $name; $label .= '@' if $type & 1; @@ -988,7 +975,7 @@ sub checkPWD ($c, $pwd, $renameError = 0) { my $original = $pwd; $pwd =~ s!(^|/)\.!$1_!g; # don't enter hidden directories $pwd =~ s!^/!!; # remove leading / - $pwd =~ s![^-_./A-Z0-9~, ]!_!gi; # no illegal characters + $pwd =~ s![^-_./A-Z0-9~,() ]!_!gi; # no illegal characters return if $renameError && $original ne $pwd; $pwd = '.' if $pwd eq ''; @@ -1075,7 +1062,7 @@ sub verifyPath ($c, $path, $name) { $c->addbadmessage($c->maketext('You have specified an illegal path')); } } else { - $c->addbadmessage($c->maketext('You can not specify an absolute path')); + $c->addbadmessage($c->maketext('You cannot specify an absolute path')); } } else { $c->addbadmessage($c->maketext('Your file name contains illegal characters')); diff --git a/lib/WeBWorK/ContentGenerator/Instructor/Index.pm b/lib/WeBWorK/ContentGenerator/Instructor/Index.pm index 21ef2c95f3..2ac5e5e94e 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/Index.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/Index.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::Index; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -23,9 +8,9 @@ pages =cut -use WeBWorK::Utils qw(x); +use WeBWorK::Utils qw(x); use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); -use WeBWorK::Utils::Sets qw(format_set_name_internal); +use WeBWorK::Utils::Sets qw(format_set_name_internal); use constant E_MAX_ONE_SET => x('Please select at most one set.'); use constant E_ONE_USER => x('Please select exactly one user.'); diff --git a/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm b/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm index 397a8f7cfd..57145907a0 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm @@ -1,18 +1,3 @@ -################################################################################ -# 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::JobManager; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -203,7 +188,7 @@ sub delete_handler ($c) { for my $jobID (keys %{ $c->stash->{selectedJobs} }) { # If a job was inactive (not yet started) when the page was previously loaded, then it may be selected to be # deleted. By the time the delete form is submitted the job may have started and may now be active. In that - # case it can not be deleted. + # case it cannot be deleted. if ($c->stash->{jobs}{$jobID}{state} eq 'active') { $c->addbadmessage( $c->maketext('Unable to delete job [_1] as it has transitioned to an active state.', $jobID)); diff --git a/lib/WeBWorK/ContentGenerator/Instructor/LTIUpdate.pm b/lib/WeBWorK/ContentGenerator/Instructor/LTIUpdate.pm index 9b2e820ca4..6212477575 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/LTIUpdate.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/LTIUpdate.pm @@ -1,26 +1,10 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - # This page is for triggering LTI grade updates package WeBWorK::ContentGenerator::Instructor::LTIUpdate; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; -use WeBWorK::Utils(qw(getAssetURL)); -use WeBWorK::Utils::Sets qw(format_set_name_display); -use WeBWorK::Authen::LTI::MassUpdate qw(mass_update); +use WeBWorK::Utils::Sets qw(format_set_name_display); +use WeBWorK::Authen::LTI::GradePassback qw(massUpdate); sub initialize ($c) { my $db = $c->db; @@ -68,7 +52,7 @@ sub initialize ($c) { # Note that if somehow this point is reached with a setID and grade mode is "course", # then the setID will be ignored by the job. - mass_update($c, 1, $userID, $setID); + massUpdate($c, 1, $userID, $setID); return; } diff --git a/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm b/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm index fd09679795..877ba28947 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::PGProblemEditor; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -52,7 +37,7 @@ The suffix for a temporary file is "user_name.tmp" by default. This is the most common type. This editor can be called by an instructor when viewing any problem. The information for retrieving the source file is found -using the problemID in order to look look up the source file path. +using the problemID in order to look up the source file path. =item source_path_for_problem_file @@ -90,6 +75,11 @@ The "stationary" source for this problem is stored in the assets/pg directory and defined in defaults.config as $webworkFiles{screenSnippets}{blankProblem} +=item sample_problem + +This is a special case which allows one to edit a sample PG problem. These +are problems located in the pg/tutorial/sample_problems directory. + =back =head2 Action @@ -118,11 +108,12 @@ not exist. The path to the actual file being edited is stored in inputFilePath. use Mojo::File; use XML::LibXML; -use WeBWorK::Utils qw(not_blank x max); -use WeBWorK::Utils::Files qw(surePathToFile readFile path_is_subdir); +use WeBWorK::Utils qw(not_blank x max); +use WeBWorK::Utils::Files qw(surePathToFile readFile path_is_subdir); use WeBWorK::Utils::Instructor qw(assignProblemToAllSetUsers addProblemToSet); -use WeBWorK::Utils::JITAR qw(seq_to_jitar_id jitar_id_to_seq); -use WeBWorK::Utils::Sets qw(format_set_name_display); +use WeBWorK::Utils::JITAR qw(seq_to_jitar_id jitar_id_to_seq); +use WeBWorK::Utils::Sets qw(format_set_name_display); +use SampleProblemParser qw(getSampleProblemCode generateMetadata); use constant DEFAULT_SEED => 123456; @@ -191,11 +182,11 @@ sub pre_header_initialize ($c) { } else { $c->{file_type} = 'problem'; } - } else { - $c->{file_type} = 'blank_problem'; } } + return unless $c->{file_type}; + # Clean up sourceFilePath and check that sourceFilePath is relative to the templates folder if ($c->{file_type} eq 'source_path_for_problem_file') { $c->{sourceFilePath} = $c->getRelativeSourceFilePath($c->param('sourceFilePath')); @@ -242,6 +233,11 @@ sub initialize ($c) { $c->stash->{actionFormTitles} = ACTION_FORM_TITLES(); $c->stash->{hardcopyLabels} = []; + unless ($c->{file_type}) { + $c->stash->{sampleProblemMetadata} = generateMetadata("$ce->{pg_dir}/tutorial/sample-problems"); + return; + } + # Tell the templates if we are working on a PG file $c->{is_pg} = !$c->{file_type} || ($c->{file_type} ne 'course_info' && $c->{file_type} ne 'hardcopy_theme'); @@ -265,7 +261,7 @@ sub initialize ($c) { )); } - if ($c->{file_type} eq 'blank_problem') { + if ($c->{file_type} eq 'blank_problem' || $c->{file_type} eq 'sample_problem') { $c->addbadmessage($c->maketext('This file is a template. You may use "Save As" to create a new file.')); } elsif ($c->{inputFilePath} =~ /$BLANKPROBLEM$/) { $c->addbadmessage($c->maketext( @@ -298,7 +294,8 @@ sub initialize ($c) { eval { $problemContents = readFile($c->{editFilePath}) }; $problemContents = $@ if $@; $c->{inputFilePath} = $c->{editFilePath}; - + } elsif (path_is_subdir($c->{editFilePath}, "$ce->{pg_dir}/tutorial/sample-problems")) { + $problemContents = getSampleProblemCode($c->{editFilePath}); } else { $c->stash->{file_error} = $c->maketext('The given file path is not a valid location.'); } @@ -387,12 +384,14 @@ sub page_title ($c) { # Convert initial path component to [TMPL], [COURSE], or [WW]. sub shortPath ($c, $file) { - my $tmpl = $c->ce->{courseDirs}{templates}; - my $root = $c->ce->{courseDirs}{root}; - my $ww = $c->ce->{webworkDirs}{root}; + my $tmpl = $c->ce->{courseDirs}{templates}; + my $root = $c->ce->{courseDirs}{root}; + my $ww = $c->ce->{webworkDirs}{root}; + my $sample = $c->ce->{pg_dir} . '/tutorial/sample-problems'; $file =~ s|^$tmpl|[TMPL]|; $file =~ s|^$root|[COURSE]|; $file =~ s|^$ww|[WW]|; + $file =~ s|^$sample|[SAMPLE]|; return $file; } @@ -416,12 +415,13 @@ sub determineTempEditFilePath ($c, $path) { my $templatesDirectory = $c->ce->{courseDirs}{templates}; my $tmpEditFileDirectory = $c->getTempEditFileDirectory(); my $hardcopyThemesDir = $c->ce->{webworkDirs}{hardcopyThemes}; + my $pgRoot = $c->ce->{pg_dir}; $c->addbadmessage($c->maketext('The path to the original file should be absolute.')) unless $path =~ m|^/|; if ($path =~ /^$tmpEditFileDirectory/) { - $c->addbadmessage($c->maketext('The path can not be the temporary edit directory.')); + $c->addbadmessage($c->maketext('The path cannot be the temporary edit directory.')); } else { if ($path =~ /^$templatesDirectory/) { $path = $c->getRelativeSourceFilePath($path); @@ -429,6 +429,9 @@ sub determineTempEditFilePath ($c, $path) { } elsif ($path eq $c->ce->{webworkFiles}{screenSnippets}{blankProblem}) { # Handle the case of the blank problem in snippets. $path = "$tmpEditFileDirectory/blank.$setID.$user.tmp"; + } elsif ($path =~ m|^$pgRoot/tutorial/sample-problems/(.*\.pg)$|) { + # Handle the case of a sample problem. + $path = "$tmpEditFileDirectory/$1.$user.tmp"; } elsif ($path eq $c->ce->{webworkFiles}{hardcopySnippets}{setHeader}) { # Handle the case of the screen header in snippets. $path = "$tmpEditFileDirectory/screenHeader.$setID.$user.tmp"; @@ -450,7 +453,6 @@ sub determineTempEditFilePath ($c, $path) { } # Determine the original path to a file corresponding to a temporary edit file. -# Returns a path that is relative to the template directory. sub determineOriginalEditFilePath ($c, $path) { my $ce = $c->ce; @@ -511,6 +513,8 @@ sub getFilePaths ($c) { $editFilePath = "$ce->{courseDirs}{templates}/$ce->{courseFiles}{course_info}"; } elsif ($c->{file_type} eq 'blank_problem') { $editFilePath = $ce->{webworkFiles}{screenSnippets}{blankProblem}; + } elsif ($c->{file_type} eq 'sample_problem') { + $editFilePath = "$ce->{pg_dir}/tutorial/sample-problems/" . $c->param('sampleProblemFile'); } elsif ($c->{file_type} eq 'hardcopy_theme') { $editFilePath = "$ce->{courseDirs}{hardcopyThemes}/" . $c->param('hardcopy_theme'); if (!-e $editFilePath) { @@ -601,7 +605,7 @@ sub getBackupTimes ($c) { my $backupBasePath = $c->{backupBasePath}; my @files = glob(qq("$backupBasePath*")); return unless @files; - return reverse(map { $_ =~ s/$backupBasePath//r } @files); + return reverse(map { $_ =~ s/\Q$backupBasePath\E//r } @files); } sub backupFile ($c, $outputFilePath) { @@ -646,7 +650,7 @@ sub saveFileChanges ($c, $outputFilePath, $backup = 0) { unless (path_is_subdir($outputFilePath, $ce->{courseDirs}{templates}, 1)) { $c->addbadmessage($c->maketext( - 'The file [_1] is not contained in the course templates directory and can not be modified.', + 'The file [_1] is not contained in the course templates directory and cannot be modified.', $outputFilePath )); return; @@ -737,7 +741,7 @@ sub saveFileChanges ($c, $outputFilePath, $backup = 0) { $c->{inputFilePath} = $c->{editFilePath}; } else { $c->addbadmessage($c->maketext( - 'The temporary file [_1] is not in the course templates directory and can not be deleted!', + 'The temporary file [_1] is not in the course templates directory and cannot be deleted!', $c->{tempFilePath} )); } @@ -787,8 +791,8 @@ sub view_handler ($c) { sourceFilePath => $relativeTempFilePath } )); - } elsif ($c->{file_type} eq 'blank_problem') { - # Redirect to Problem.pm.pm. + } elsif ($c->{file_type} eq 'blank_problem' || $c->{file_type} eq 'sample_problem') { + # Redirect to Problem.pm. $c->authen->flash(status_message => $c->{status_message}->join('')); $c->reply_with_redirect($c->systemLink( $c->url_for('problem_detail', setID => 'Undefined_Set', problemID => 1), @@ -843,10 +847,9 @@ sub view_handler ($c) { return; } -# The hardcopy and format_code actions are handled by javascript. These are provided just in case -# something goes wrong and the actions are called. -sub hardcopy_action { } -sub format_code_action { } +# The format_code action is handled by javascript. This is provided just in case +# something goes wrong and the handler is called. +sub format_code_handler { } sub hardcopy_handler ($c) { # Redirect to problem editor page. @@ -1291,7 +1294,7 @@ sub revert_handler ($c) { unless (path_is_subdir($c->{tempFilePath}, $ce->{courseDirs}{templates}, 1)) { $c->addbadmessage($c->maketext( - 'The temporary file [_1] is not contained in the course templates directory and can not be deleted.', + 'The temporary file [_1] is not contained in the course templates directory and cannot be deleted.', $c->{tempFilePath} )); return; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemGrader.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemGrader.pm index c23ad33e43..7bf2c9ed78 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemGrader.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemGrader.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::ProblemGrader; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; @@ -25,9 +10,9 @@ manually grading webwork problems. use HTML::Entities; -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); use WeBWorK::Utils::Rendering qw(renderPG); -use WeBWorK::Utils::Sets qw(get_test_problem_position format_set_name_display); +use WeBWorK::Utils::Sets qw(get_test_problem_position format_set_name_display); async sub initialize ($c) { my $authz = $c->authz; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm index 28d613134c..6109aa49fd 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::ProblemSetDetail; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -25,11 +10,11 @@ specific user/set information as well as problem information use Exporter qw(import); -use WeBWorK::Utils qw(cryptPassword x); -use WeBWorK::Utils::Files qw(surePathToFile readFile); +use WeBWorK::Utils qw(cryptPassword x); +use WeBWorK::Utils::Files qw(surePathToFile readFile); use WeBWorK::Utils::Instructor qw(assignProblemToAllSetUsers addProblemToSet); -use WeBWorK::Utils::JITAR qw(seq_to_jitar_id jitar_id_to_seq); -use WeBWorK::Utils::Sets qw(format_set_name_internal format_set_name_display); +use WeBWorK::Utils::JITAR qw(seq_to_jitar_id jitar_id_to_seq); +use WeBWorK::Utils::Sets qw(format_set_name_internal format_set_name_display); require WeBWorK::PG; our @EXPORT_OK = qw(FIELD_PROPERTIES); @@ -81,7 +66,9 @@ use constant JITAR_SET_FIELD_ORDER => [qw(restrict_prob_progression email_instru # All but name are optional # some_field => { # name => "Some Field", -# type => "edit", # edit, choose, hidden, view - defines how the data is displayed +# type => "edit", # edit, choose, hidden, view, [min, max, step] - defines how the data is displayed +# [min, max, step] will introduce validation, so should not be used on just any +# input where we expect numbers # size => "50", # size of the edit box (if any) # override => "none", # none, one, any, all - defines for whom this data can/must be overidden # module => "problem_list", # WeBWorK module @@ -313,7 +300,7 @@ use constant FIELD_PROPERTIES => { }, attempts_per_version => { name => x("Graded Submissions per Version"), - type => 'edit', + type => [ 0, undef, 1 ], size => '3', override => 'any', default => '0', @@ -342,7 +329,7 @@ use constant FIELD_PROPERTIES => { }, versions_per_interval => { name => x('Versions per Interval'), - type => 'edit', + type => [ 0, undef, 1 ], size => '3', override => 'any', default => '0', @@ -365,7 +352,7 @@ use constant FIELD_PROPERTIES => { }, problems_per_page => { name => x('Problems per Page'), - type => 'edit', + type => [ 0, undef, 1 ], size => '3', override => 'any', default => '1', @@ -467,7 +454,7 @@ use constant FIELD_PROPERTIES => { }, # In addition to the set fields above, there are a number of things - # that are set but aren"t in this table: + # that are set but aren't in this table: # any set proctor information (which is in the user tables), and # any set location restriction information (which is in the # location tables) @@ -493,7 +480,7 @@ use constant FIELD_PROPERTIES => { }, max_attempts => { name => x('Max Attempts'), - type => 'edit', + type => [ -1, undef, 1 ], size => 6, override => 'any', default => '-1', @@ -506,7 +493,7 @@ use constant FIELD_PROPERTIES => { }, showMeAnother => { name => x('Show Me Another'), - type => 'edit', + type => [ -2, undef, 1 ], size => '6', override => 'any', default => '-1', @@ -522,7 +509,7 @@ use constant FIELD_PROPERTIES => { }, showHintsAfter => { name => x('Show Hints After'), - type => 'edit', + type => [ -2, undef, 1 ], size => '6', override => 'any', default => '-2', @@ -539,7 +526,7 @@ use constant FIELD_PROPERTIES => { }, prPeriod => { name => x('Rerandomize After'), - type => 'edit', + type => [ -1, undef, 1 ], size => '6', override => 'any', default => '-1', @@ -555,7 +542,7 @@ use constant FIELD_PROPERTIES => { }, problem_seed => { name => x('Seed'), - type => 'edit', + type => [ 0, undef, 1 ], size => 6, override => 'one', help_text => x( @@ -619,7 +606,7 @@ use constant FIELD_PROPERTIES => { }, att_to_open_children => { name => x('Attempt Threshold for Children'), - type => 'edit', + type => [ -1, undef, 1 ], size => 6, override => 'any', default => '0', @@ -644,7 +631,7 @@ use constant FIELD_PROPERTIES => { '0' => x('No'), }, help_text => x( - 'If this flag is set then this problem will count towards the grade of its parent problem. In ' + 'If this flag is set then this problem will count toward the grade of its parent problem. In ' . 'general the adjusted status on a problem is the larger of the problem\'s status and the weighted ' . 'average of the status of its child problems which have this flag enabled.' ), @@ -834,14 +821,29 @@ sub fieldHTML ($c, $userID, $setID, $problemID, $globalRecord, $userRecord, $fie if $forOneUser && $globalRecord && !$userRecord; my %properties = %{ FIELD_PROPERTIES()->{$field} }; + if ($field eq 'problems_per_page') { + if ($c->ce->{test}{maxProblemsPerPage} == 1) { + $properties{override} = 'none'; + } elsif ($c->ce->{test}{maxProblemsPerPage} > 1) { + my $max = $c->ce->{test}{maxProblemsPerPage}; + $properties{type} = [ 1, $max, 1 ]; + $properties{help_text} = + 'A test is broken up into pages with this many problems on each page. Students can ' + . 'move from page to page without clicking to grade the test, and their temporary answers will be ' + . "saved. The site administator has capped this setting at $max. If only using 1 problem per page, " + . 'the student has many pages and may be frustrated trying to reach a particular problem. However, ' + . 'their answers will be saved more frequently as they move from page to page.'; + } + } return '' if $properties{type} eq 'hidden'; return '' if $properties{override} eq 'one' && !$forOneUser; return '' if $properties{override} eq 'none' && !$forOneUser; return '' if $properties{override} eq 'all' && $forUsers; - my $edit = $properties{type} eq 'edit' && $properties{override} ne 'none'; - my $choose = $properties{type} eq 'choose' && $properties{override} ne 'none'; + my $edit = $properties{type} eq 'edit' && $properties{override} ne 'none'; + my $number = ref($properties{type}) eq 'ARRAY' && $properties{override} ne 'none'; + my $choose = $properties{type} eq 'choose' && $properties{override} ne 'none'; my ($globalValue, $userValue, $blankField) = (undef, undef, ''); if ($field =~ /:/) { @@ -885,7 +887,7 @@ sub fieldHTML ($c, $userID, $setID, $problemID, $globalRecord, $userRecord, $fie # This contains either a text input or a select for changing a given database field. my $input = ''; - if ($edit) { + if ($edit || $number) { if ($field =~ /_date/) { $input = $c->tag( 'div', @@ -922,6 +924,10 @@ sub fieldHTML ($c, $userID, $setID, $problemID, $globalRecord, $userRecord, $fie ); } else { my $value = $forUsers ? ($labels{$userValue} || $userValue) : ($labels{$globalValue} || $globalValue); + $value = $c->ce->{test}{maxProblemsPerPage} + if ($field eq 'problems_per_page' + && $c->ce->{test}{maxProblemsPerPage} + && ($value == 0 || $value > $c->ce->{test}{maxProblemsPerPage})); $value = format_set_name_display($value =~ s/\s*,\s*/,/gr) if $field eq 'restricted_release'; my @field_args = ( @@ -951,6 +957,15 @@ sub fieldHTML ($c, $userID, $setID, $problemID, $globalRecord, $userRecord, $fie ) )->join('') ); + } elsif ($number) { + $input = $c->number_field( + @field_args, + min => ($properties{type}[0] || 0), + max => ($properties{type}[1] || undef), + step => ($properties{type}[2] || 1), + placeholder => $value, + $forUsers && $canOverride ? (placeholder => $c->maketext('Set Default')) : () + ); } else { $input = $c->text_field(@field_args, $forUsers && $canOverride ? (placeholder => $c->maketext('Set Default')) : ()); @@ -998,12 +1013,10 @@ sub fieldHTML ($c, $userID, $setID, $problemID, $globalRecord, $userRecord, $fie bs_placement => 'top', bs_toggle => 'popover' }, - $c->tag( - 'i', - class => 'icon fas fa-question-circle', - data => { alt => $c->maketext('Help Icon') }, - 'aria-hidden' => 'true' - ) + $c->c( + $c->tag('i', class => 'icon fas fa-question-circle', 'aria-hidden' => 'true'), + $c->tag('span', class => 'visually-hidden', $c->maketext('[_1] Help', $properties{name})) + )->join('') ) : ''; @@ -1977,7 +1990,7 @@ sub initialize ($c) { } } - # Check that every user that that is being editing for has a valid UserSet. + # Check that every user that is being edited has a valid UserSet. my @unassignedUsers; if (@editForUser) { my @assignedUsers; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm index 1781b73609..6a65b99ce0 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::ProblemSetList; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; use WeBWorK::Utils::Instructor qw(getDefList); @@ -79,11 +64,11 @@ Delete sets: use Mojo::File; use WeBWorK::Debug; -use WeBWorK::Utils qw(x); -use WeBWorK::Utils::DateTime qw(getDefaultSetDueDate); +use WeBWorK::Utils qw(x); +use WeBWorK::Utils::DateTime qw(getDefaultSetDueDate); use WeBWorK::Utils::Instructor qw(assignSetToUser); -use WeBWorK::Utils::Sets qw(format_set_name_internal format_set_name_display); -use WeBWorK::File::SetDef qw(importSetsFromDef exportSetsToDef); +use WeBWorK::Utils::Sets qw(format_set_name_internal format_set_name_display); +use WeBWorK::File::SetDef qw(importSetsFromDef exportSetsToDef); use constant HIDE_SETS_THRESHOLD => 500; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/Scoring.pm b/lib/WeBWorK/ContentGenerator/Instructor/Scoring.pm index 3489fa6a45..68c7571a23 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/Scoring.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/Scoring.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::Scoring; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -23,7 +8,7 @@ WeBWorK::ContentGenerator::Instructor::Scoring - Generate scoring data files =cut use WeBWorK::Debug; -use WeBWorK::Utils qw(wwRound x); +use WeBWorK::Utils qw(wwRound x); use WeBWorK::Utils::Files qw(readFile); use WeBWorK::Utils::JITAR qw(jitar_id_to_seq jitar_problem_adjusted_status); use WeBWorK::ContentGenerator::Instructor::FileManager; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ScoringDownload.pm b/lib/WeBWorK/ContentGenerator/Instructor/ScoringDownload.pm index 71d7fe1ce2..114a688467 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ScoringDownload.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ScoringDownload.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::ScoringDownload; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm b/lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm index 723e922758..7831a955a0 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::SendMail; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -113,8 +98,9 @@ sub initialize ($c) { 'user_id' ); - # Filter out users who don't get included in email - @Users = grep { $ce->status_abbrev_has_behavior($_->status, "include_in_email") } @Users; + # Filter out users who don't get included in email unless in the admin course. + @Users = grep { $ce->status_abbrev_has_behavior($_->status, "include_in_email") } @Users + unless $ce->{courseName} eq $ce->{admin_course_id}; # Cache the user records for later use. $c->{ra_user_records} = \@Users; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/SetMaker.pm b/lib/WeBWorK/ContentGenerator/Instructor/SetMaker.pm index 56459effd6..0184bcca37 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/SetMaker.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/SetMaker.pm @@ -1,18 +1,3 @@ -################################################################################ -# 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::SetMaker; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -25,12 +10,12 @@ WeBWorK::ContentGenerator::Instructor::SetMaker - Make homework sets. use Mojo::File; use WeBWorK::Debug; -use WeBWorK::Utils qw(sortByName x); -use WeBWorK::Utils::DateTime qw(getDefaultSetDueDate); +use WeBWorK::Utils qw(sortByName x); +use WeBWorK::Utils::DateTime qw(getDefaultSetDueDate); use WeBWorK::Utils::Instructor qw(assignSetToUser assignProblemToAllSetUsers addProblemToSet); use WeBWorK::Utils::LibraryStats; use WeBWorK::Utils::ListingDB qw(getDBListings); -use WeBWorK::Utils::Sets qw(format_set_name_internal); +use WeBWorK::Utils::Sets qw(format_set_name_internal); use WeBWorK::Utils::Tags; # Use x to mark strings for maketext @@ -535,7 +520,7 @@ sub pre_header_initialize ($c) { $use_previous_problems = 0; } } elsif ($c->param('view_course_set')) { - # View problems selected from the a set in this course + # View problems selected from a set in this course my $set_to_display = $c->{current_library_set} // ''; debug("set_to_display is $set_to_display"); if ($set_to_display eq '') { diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ShowAnswers.pm b/lib/WeBWorK/ContentGenerator/Instructor/ShowAnswers.pm index d84ec86d92..3600589a36 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ShowAnswers.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ShowAnswers.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::ShowAnswers; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; @@ -25,7 +10,7 @@ WeBWorK::ContentGenerator::Instructor::ShowAnswers.pm -- display past answers o use Text::CSV; use Mojo::File; -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq prob_id_sort); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq prob_id_sort); use WeBWorK::Utils::Rendering qw(renderPG); use constant PAST_ANSWERS_FILENAME => 'past_answers'; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/Stats.pm b/lib/WeBWorK/ContentGenerator/Instructor/Stats.pm index bc81081388..63ef67a7bb 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/Stats.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/Stats.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::Stats; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -26,8 +11,8 @@ homework set (including sv graphs). use SVG; use WeBWorK::Utils::FilterRecords qw(getFiltersForClass filterRecords); -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq jitar_problem_adjusted_status); -use WeBWorK::Utils::Sets qw(grade_set format_set_name_display); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq jitar_problem_adjusted_status); +use WeBWorK::Utils::Sets qw(grade_set format_set_name_display); sub initialize ($c) { my $db = $c->db; @@ -550,6 +535,7 @@ sub build_bar_chart ($c, $data, %options) { mainTitle => 'ERROR: This must be set', xTitle => '', yTitle => '', + descText => '', barWidth => 22, barSep => 4, barFill => 'rgb(0,153,198)', @@ -585,6 +571,7 @@ sub build_bar_chart ($c, $data, %options) { ); # Main graph setup. + $svg->desc()->cdata($opts{descText}); $svg->rect( id => "bar_graph_window_$id", x => 0, @@ -711,9 +698,11 @@ sub build_bar_chart ($c, $data, %options) { for (0 .. $n) { my $xPos = $opts{leftMargin} + $_ * $barWidth + $opts{barSep}; my $yHeight = int($opts{plotHeight} * $data->[$_] / $opts{yMax} + 0.5); + my $tag = @{ $opts{barLinks} } ? $svg->anchor(-href => "$opts{barLinks}->[$_]") : $svg; + if ($opts{isJitarSet} && $opts{jitarBars}->[$_] > 0) { my $jHeight = int($opts{plotHeight} * $opts{jitarBars}->[$_] / $opts{yMax} + 0.5); - $svg->rect( + $tag->rect( x => $xPos, y => $opts{topMargin} + $opts{plotHeight} - $jHeight, width => $opts{barWidth} + $opts{barSep}, @@ -724,7 +713,6 @@ sub build_bar_chart ($c, $data, %options) { class => 'bar_graph_bar', ); } - my $tag = @{ $opts{barLinks} } ? $svg->anchor(-href => $opts{barLinks}->[$_]) : $svg; $tag->rect( x => $xPos, y => $opts{topMargin} + $opts{plotHeight} - $yHeight, diff --git a/lib/WeBWorK/ContentGenerator/Instructor/StudentProgress.pm b/lib/WeBWorK/ContentGenerator/Instructor/StudentProgress.pm index b052ea5419..6acc9cc579 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/StudentProgress.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/StudentProgress.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::StudentProgress; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -22,10 +7,10 @@ WeBWorK::ContentGenerator::Instructor::StudentProgress - Display Student Progres =cut -use WeBWorK::Utils qw(wwRound); +use WeBWorK::Utils qw(wwRound); use WeBWorK::Utils::FilterRecords qw(getFiltersForClass filterRecords); -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); -use WeBWorK::Utils::Sets qw(grade_set list_set_versions format_set_name_display); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); +use WeBWorK::Utils::Sets qw(grade_set list_set_versions format_set_name_display); sub initialize ($c) { my $db = $c->db; @@ -219,7 +204,7 @@ sub displaySets ($c) { @user_set_list, { record => $studentRecord, - score => 0, + score => 0, total => -1, date => '', testtime => '', diff --git a/lib/WeBWorK/ContentGenerator/Instructor/UserDetail.pm b/lib/WeBWorK/ContentGenerator/Instructor/UserDetail.pm index e86d376d9c..dc00d2d02e 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/UserDetail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/UserDetail.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::UserDetail; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -22,8 +7,8 @@ WeBWorK::ContentGenerator::Instructor::UserDetail - Detailed User specific infor =cut -use WeBWorK::DB::Utils qw(grok_versionID_from_vsetID_sql); -use WeBWorK::Utils qw(x); +use WeBWorK::DB::Utils qw(grok_versionID_from_vsetID_sql); +use WeBWorK::Utils qw(x); use WeBWorK::Utils::Instructor qw(assignSetToUser); use WeBWorK::Debug; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm b/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm index 0b62fcf56c..43640c581d 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::UserList; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -63,7 +48,7 @@ Export users: use Mojo::File; use WeBWorK::File::Classlist qw(parse_classlist write_classlist); -use WeBWorK::Utils qw(cryptPassword x); +use WeBWorK::Utils qw(cryptPassword x); use constant HIDE_USERS_THRESHHOLD => 200; use constant EDIT_FORMS => [qw(save_edit cancel_edit)]; @@ -236,8 +221,15 @@ sub pre_header_initialize ($c) { # Always have a definite sort order in case the first three sorts don't determine things. $c->{sortedUserIDs} = [ - map { $_->user_id } - sort { &$primarySortSub || &$secondarySortSub || &$ternarySortSub || byLastName || byFirstName || byUserID } + map { $_->user_id } + sort { + $primarySortSub->() + || $secondarySortSub->() + || $ternarySortSub->() + || byLastName() + || byFirstName() + || byUserID() + } grep { $c->{visibleUserIDs}{ $_->user_id } } (values %allUsers) ]; @@ -619,22 +611,18 @@ sub menuLabels ($c, $hashRef) { } sub importUsersFromCSV ($c, $fileName, $replaceExisting, $fallbackPasswordSource, @replaceList) { - my $ce = $c->ce; - my $db = $c->db; - my $dir = $ce->{courseDirs}{templates}; - my $user = $c->param('user'); - my $perm = $c->{userPermission}; + my $ce = $c->ce; + my $db = $c->db; - die $c->maketext("Illegal '/' character in input.") if $fileName =~ m|/|; - die $c->maketext("File [_1]/[_2] either does not exist or is not readable.", $dir, $fileName) - unless -r "$dir/$fileName"; + die $c->maketext(q{Illegal '/' character in input.}) if $fileName =~ m|/|; + die $c->maketext('File [_1]/[_2] either does not exist or is not readable.', + $ce->{courseDirs}{templates}, $fileName) + unless -r "$ce->{courseDirs}{templates}/$fileName"; my %allUserIDs = map { $_ => 1 } @{ $c->{allUserIDs} }; my %replaceOK; - if ($replaceExisting eq 'none') { - %replaceOK = (); - } elsif ($replaceExisting eq 'listed') { + if ($replaceExisting eq 'listed') { %replaceOK = map { $_ => 1 } @replaceList; } elsif ($replaceExisting eq 'any') { %replaceOK = %allUserIDs; @@ -642,15 +630,16 @@ sub importUsersFromCSV ($c, $fileName, $replaceExisting, $fallbackPasswordSource my $default_permission_level = $ce->{default_permission_level}; - my (@replaced, @added, @skipped); + my (@usersToInsert, @passwordsToInsert, @permissionsToInsert, + @usersToUpdate, @passwordsToUpdate, @permissionsToUpdate, @skipped); # get list of hashrefs representing lines in classlist file - my @classlist = parse_classlist("$dir/$fileName"); + my @classlist = parse_classlist("$ce->{courseDirs}{templates}/$fileName"); # Default status is enrolled -- fetch abbreviation for enrolled my $default_status_abbrev = $ce->{statuses}{Enrolled}{abbrevs}[0]; - foreach my $record (@classlist) { + for my $record (@classlist) { my %record = %$record; my $user_id = $record{user_id}; @@ -658,11 +647,11 @@ sub importUsersFromCSV ($c, $fileName, $replaceExisting, $fallbackPasswordSource push @skipped, $user_id; next; } - if ($user_id eq $user) { # don't replace yourself!! + if ($user_id eq $c->param('user')) { # don't replace yourself!! push @skipped, $user_id; next; } - if ($record{permission} && $perm < $record{permission}) { + if ($record{permission} && $c->{userPermission} < $record{permission}) { push @skipped, $user_id; next; } @@ -674,7 +663,7 @@ sub importUsersFromCSV ($c, $fileName, $replaceExisting, $fallbackPasswordSource # set default status is status field is "empty" $record{status} = $default_status_abbrev - unless defined $record{status} and $record{status} ne ""; + unless defined $record{status} && $record{status} ne ''; # Determine what to use for the password (if anything). if (!$record{password}) { @@ -690,7 +679,7 @@ sub importUsersFromCSV ($c, $fileName, $replaceExisting, $fallbackPasswordSource # set default permission level if permission level is "empty" $record{permission} = $default_permission_level - unless defined $record{permission} and $record{permission} ne ""; + unless defined $record{permission} && $record{permission} ne ''; my $User = $db->newUser(%record); my $PermissionLevel = $db->newPermissionLevel(user_id => $user_id, permission => $record{permission}); @@ -698,52 +687,55 @@ sub importUsersFromCSV ($c, $fileName, $replaceExisting, $fallbackPasswordSource # DBFIXME use REPLACE if (exists $allUserIDs{$user_id}) { - $db->putUser($User); - $db->putPermissionLevel($PermissionLevel); - $db->putPassword($Password) if $Password; + push(@usersToUpdate, $User); + push(@permissionsToUpdate, $PermissionLevel); + push(@passwordsToUpdate, $Password) if $Password; $User->{permission} = $PermissionLevel->permission; $User->{passwordExists} = 1 if $Password; - push @replaced, $User; } else { $allUserIDs{$user_id} = 1; - $db->addUser($User); - $db->addPermissionLevel($PermissionLevel); - $db->addPassword($Password) if $Password; + push(@usersToInsert, $User); + push(@permissionsToInsert, $PermissionLevel); + push(@passwordsToInsert, $Password) if $Password; $User->{permission} = $PermissionLevel->permission; $User->{passwordExists} = 1 if $Password; - push @added, $User; } } - return \@replaced, \@added, \@skipped; + $db->User->insert_records(\@usersToInsert) if @usersToInsert; + $db->User->update_records(\@usersToUpdate) if @usersToUpdate; + $db->Password->insert_records(\@passwordsToInsert) if @passwordsToInsert; + $db->Password->update_records(\@passwordsToUpdate) if @passwordsToUpdate; + $db->PermissionLevel->insert_records(\@permissionsToInsert) if @permissionsToInsert; + $db->PermissionLevel->update_records(\@permissionsToUpdate) if @permissionsToUpdate; + + return \@usersToUpdate, \@usersToInsert, \@skipped; } sub exportUsersToCSV ($c, $fileName, @userIDsToExport) { - my $ce = $c->ce; - my $db = $c->db; - my $dir = $ce->{courseDirs}->{templates}; + my $ce = $c->ce; + my $db = $c->db; - die $c->maketext("illegal character in input: '/'") if $fileName =~ m|/|; + die $c->maketext(q{illegal character in input: '/'}) if $fileName =~ m|/|; my @records; - my @Users = $db->getUsers(@userIDsToExport); - my @Passwords = $db->getPasswords(@userIDsToExport); - my @PermissionLevels = $db->getPermissionLevels(@userIDsToExport); - foreach my $i (0 .. $#userIDsToExport) { - my $User = $Users[$i]; - my $Password = $Passwords[$i]; - my $PermissionLevel = $PermissionLevels[$i]; - next unless defined $User; - my %record = ( - defined $PermissionLevel ? $PermissionLevel->toHash : (), - defined $Password ? $Password->toHash : (), - $User->toHash, + my @users = $db->getUsers(@userIDsToExport); + my %passwords = map { $_->user_id => $_ } $db->getPasswords(@userIDsToExport); + my %permissionLevels = map { $_->user_id => $_ } $db->getPermissionLevels(@userIDsToExport); + + for my $user (@users) { + my $password = $passwords{ $user->user_id }; + my $permissionLevel = $permissionLevels{ $user->user_id }; + my %record = ( + defined $permissionLevel ? $permissionLevel->toHash : (), + defined $password ? $password->toHash : (), + $user->toHash, ); push @records, \%record; } - write_classlist("$dir/$fileName", @records); + write_classlist("$ce->{courseDirs}{templates}/$fileName", @records); return; } diff --git a/lib/WeBWorK/ContentGenerator/Instructor/UsersAssignedToSet.pm b/lib/WeBWorK/ContentGenerator/Instructor/UsersAssignedToSet.pm index 63924f741a..5a3500321c 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/UsersAssignedToSet.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/UsersAssignedToSet.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Instructor::UsersAssignedToSet; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -25,7 +10,7 @@ users to which sets are assigned. use WeBWorK::Debug; use WeBWorK::Utils::Instructor qw(assignSetToAllUsers assignSetToGivenUsers); -use WeBWorK::Utils::Sets qw(format_set_name_display); +use WeBWorK::Utils::Sets qw(format_set_name_display); sub initialize ($c) { my $authz = $c->authz; @@ -33,6 +18,10 @@ sub initialize ($c) { my $setID = $c->stash('setID'); my $user = $c->param('user'); + # Make sure these are defined for the template. + $c->stash->{user_records} = []; + $c->stash->{set_records} = {}; + # Check permissions return unless $authz->hasPermissions($user, "access_instructor_tools"); return unless $authz->hasPermissions($user, "assign_problem_sets"); @@ -62,7 +51,7 @@ sub initialize ($c) { } # Get all user records and cache them for later use. - $c->{user_records} = + $c->stash->{user_records} = [ $db->getUsersWhere({ user_id => { not_like => 'set_id:%' } }, [qw/section last_name first_name/]) ]; if ($doAssignToSelected) { @@ -71,7 +60,7 @@ sub initialize ($c) { my %setUsers = map { $_ => 1 } $db->listSetUsers($setID); my @usersToAdd; - for my $selectedUser (map { $_->user_id } @{ $c->{user_records} }) { + for my $selectedUser (map { $_->user_id } @{ $c->stash->{user_records} }) { if (exists $selectedUsers{$selectedUser}) { unless ($setUsers{$selectedUser}) { # skip users already in the set debug("saving $selectedUser to be added to set later"); @@ -89,6 +78,8 @@ sub initialize ($c) { } } + $c->stash->{set_records} = { map { $_->user_id => $_ } $db->getUserSetsWhere({ set_id => $setID }) }; + return; } diff --git a/lib/WeBWorK/ContentGenerator/InstructorRPCHandler.pm b/lib/WeBWorK/ContentGenerator/InstructorRPCHandler.pm index bdd35d91fe..fe265c47aa 100644 --- a/lib/WeBWorK/ContentGenerator/InstructorRPCHandler.pm +++ b/lib/WeBWorK/ContentGenerator/InstructorRPCHandler.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::InstructorRPCHandler; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; @@ -39,8 +24,6 @@ error occurs, then the response will contain an "error" key. # was "instructor" only. Usage of all commands is based on permissions, and there have always been non-instructor users # that have some of these permissions. So this module and the corresponding route should really be renamed. -use JSON; - use WebworkWebservice; sub initializeRoute ($c, $routeCaptures) { diff --git a/lib/WeBWorK/ContentGenerator/LTIAdvanced.pm b/lib/WeBWorK/ContentGenerator/LTIAdvanced.pm index 2010c3d9a4..ad218e70ca 100644 --- a/lib/WeBWorK/ContentGenerator/LTIAdvanced.pm +++ b/lib/WeBWorK/ContentGenerator/LTIAdvanced.pm @@ -1,18 +1,3 @@ -############################################################################### -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::LTIAdvanced; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -20,14 +5,14 @@ use Net::OAuth; use UUID::Tiny ':std'; use Mojo::JSON qw(encode_json); -use WeBWorK::Utils::Sets qw(format_set_name_display); +use WeBWorK::Utils::Sets qw(format_set_name_display); use WeBWorK::Utils::CourseManagement qw(listCourses); use WeBWorK::DB; $Net::OAuth::PROTOCOL_VERSION = Net::OAuth::PROTOCOL_VERSION_1_0A; sub initializeRoute ($c, $routeCaptures) { - # If this is an LTI 1.1 content item request from an LMS course, then find the courseID of the course that that has + # If this is an LTI 1.1 content item request from an LMS course, then find the courseID of the course that has # this LMS course name set in its course environment. If this is a submission of the content selection form, then # get it from the form parameter. if ($c->current_route eq 'ltiadvanced_content_selection') { @@ -37,7 +22,7 @@ sub initializeRoute ($c, $routeCaptures) { if (!$courseID && $c->param('context_id')) { # The database object used here is not associated to any course, # and so the only has access to non-native tables. - my @matchingCourses = WeBWorK::DB->new(WeBWorK::CourseEnvironment->new->{dbLayout}) + my @matchingCourses = WeBWorK::DB->new(WeBWorK::CourseEnvironment->new) ->getLTICourseMapsWhere({ lms_context_id => $c->param('context_id') }); if (@matchingCourses == 1) { diff --git a/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm b/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm index f250ed008e..1559c3a207 100644 --- a/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm +++ b/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm @@ -1,31 +1,16 @@ -############################################################################### -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::LTIAdvantage; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; use Mojo::UserAgent; -use Mojo::JSON qw(decode_json); -use Crypt::JWT qw(decode_jwt encode_jwt); +use Mojo::JSON qw(decode_json); +use Crypt::JWT qw(decode_jwt encode_jwt); use Math::Random::Secure qw(irand); -use Digest::SHA qw(sha256_hex); +use Digest::SHA qw(sha256_hex); use WeBWorK::Debug qw(debug); use WeBWorK::Authen::LTIAdvantage::SubmitGrade; use WeBWorK::Utils::CourseManagement qw(listCourses); -use WeBWorK::Utils::Sets qw(format_set_name_display); +use WeBWorK::Utils::Sets qw(format_set_name_display); sub initializeRoute ($c, $routeCaptures) { # If this is the login phase of an LTI 1.3 login, then extract the courseID from the target_link_uri. If this is a @@ -76,11 +61,10 @@ sub initializeRoute ($c, $routeCaptures) { # The database object used here is not associated to any course, # and so the only has access to non-native tables. - my @matchingCourses = - WeBWorK::DB->new(WeBWorK::CourseEnvironment->new->{dbLayout})->getLTICourseMapsWhere({ - lms_context_id => + my @matchingCourses = WeBWorK::DB->new(WeBWorK::CourseEnvironment->new)->getLTICourseMapsWhere({ + lms_context_id => $c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti/claim/context'}{id} - }); + }); if (@matchingCourses == 1) { $c->stash->{courseID} = $matchingCourses[0]->course_id; @@ -323,7 +307,11 @@ sub get_lms_public_keyset ($c, $ce, $db, $renew = 0) { } # Get public keyset from the LMS. - my $response = Mojo::UserAgent->new->get($ce->{LTI}{v1p3}{PublicKeysetURL})->result; + my $response = eval { Mojo::UserAgent->new->get($ce->{LTI}{v1p3}{PublicKeysetURL})->result }; + if ($@) { + $c->stash->{LTIAuthenError} = "Failed to obtain public key from LMS due to a network error: $@"; + return; + } unless ($response->is_success) { $c->stash->{LTIAuthenError} = 'Failed to obtain public key from LMS: ' . $response->message; return; @@ -344,7 +332,7 @@ sub extract_jwt_claims ($c) { return unless $c->param('state'); # The following database object is not associated to any course, and so the only has access to non-native tables. - my $db = WeBWorK::DB->new(WeBWorK::CourseEnvironment->new->{dbLayout}); + my $db = WeBWorK::DB->new(WeBWorK::CourseEnvironment->new); # Retrieve the launch data saved in the login phase, and then delete it from the database. Note that this verifies # the state in the request. If there is no launch data saved in the database for the state in the request, then the @@ -367,7 +355,7 @@ sub extract_jwt_claims ($c) { 'Failed to initialize course environment for ' . $c->stash->{LTILaunchData}->data->{courseID} . ": $@\n"; return; } - $db = WeBWorK::DB->new($ce->{dbLayout}); + $db = WeBWorK::DB->new($ce); $c->purge_expired_lti_data($ce, $db); diff --git a/lib/WeBWorK/ContentGenerator/Login.pm b/lib/WeBWorK/ContentGenerator/Login.pm index fb5ae41860..f2f8529177 100644 --- a/lib/WeBWorK/ContentGenerator/Login.pm +++ b/lib/WeBWorK/ContentGenerator/Login.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Login; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -24,7 +9,7 @@ WeBWorK::ContentGenerator::Login - display a login form. use WeBWorK::Utils::Files qw(readFile); use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); -use WeBWorK::Utils::Sets qw(format_set_name_display); +use WeBWorK::Utils::Sets qw(format_set_name_display); sub page_title ($c) { # If the url is for a problem page, then the title is the set and problem id. diff --git a/lib/WeBWorK/ContentGenerator/LoginProctor.pm b/lib/WeBWorK/ContentGenerator/LoginProctor.pm index e0f011f95b..3bf5d58eec 100644 --- a/lib/WeBWorK/ContentGenerator/LoginProctor.pm +++ b/lib/WeBWorK/ContentGenerator/LoginProctor.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::LoginProctor; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; @@ -24,7 +9,7 @@ GatewayQuiz proctored tests. =cut use WeBWorK::Utils::Rendering qw(renderPG); -use WeBWorK::DB::Utils qw(grok_vsetID); +use WeBWorK::DB::Utils qw(grok_vsetID); async sub initialize ($c) { my $ce = $c->ce; diff --git a/lib/WeBWorK/ContentGenerator/Logout.pm b/lib/WeBWorK/ContentGenerator/Logout.pm index 60629ffcb6..d38c955d55 100644 --- a/lib/WeBWorK/ContentGenerator/Logout.pm +++ b/lib/WeBWorK/ContentGenerator/Logout.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Logout; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -30,7 +15,9 @@ sub pre_header_initialize ($c) { my $db = $c->db; my $authen = $c->authen; - my $userID = $c->param('user_id'); + # Do any special processing needed by external authentication. This is done before + # the session is killed in case the authentication module needs access to it. + $authen->logout_user if $authen->can('logout_user'); $authen->killSession; $authen->WeBWorK::Authen::write_log_entry('LOGGED OUT'); @@ -39,6 +26,8 @@ sub pre_header_initialize ($c) { # a proctored test. So try and delete the key. my $proctorID = $c->param('proctor_user'); if ($proctorID) { + my $userID = $c->param('user_id'); + eval { $db->deleteKey("$userID,$proctorID"); }; if ($@) { $c->addbadmessage("Error when clearing proctor key: $@"); @@ -50,9 +39,6 @@ sub pre_header_initialize ($c) { } } - # Do any special processing needed by external authentication. - $authen->logout_user if $authen->can('logout_user'); - $c->reply_with_redirect($authen->{redirect}) if $authen->{redirect}; return; diff --git a/lib/WeBWorK/ContentGenerator/Options.pm b/lib/WeBWorK/ContentGenerator/Options.pm index 8bd6ff8bac..9c669e581d 100644 --- a/lib/WeBWorK/ContentGenerator/Options.pm +++ b/lib/WeBWorK/ContentGenerator/Options.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Options; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -22,9 +7,13 @@ WeBWorK::ContentGenerator::Options - Change user options. =cut -use WeBWorK::Utils qw(cryptPassword); +use WeBWorK::Utils qw(cryptPassword utf8Crypt); use WeBWorK::Localize; +sub page_title ($c) { + return $c->maketext("Account settings for [_1]", $c->param('effectiveUser')); +} + sub initialize ($c) { my $db = $c->db; my $authz = $c->authz; @@ -52,7 +41,7 @@ sub initialize ($c) { $userID ne $effectiveUserID ? eval { $db->getPassword($c->{effectiveUser}->user_id) } : $password; # Check that either password is not defined or if it is defined then we have the right one. - if (!defined $password || crypt($currP // '', $password->password) eq $password->password) { + if (!defined $password || utf8Crypt($currP // '', $password->password) eq $password->password) { my $e_user_name = $c->{effectiveUser}->first_name . ' ' . $c->{effectiveUser}->last_name; if ($newP eq $confirmP) { if (!defined $effectiveUserPassword) { @@ -105,6 +94,38 @@ sub initialize ($c) { $c->{has_password} = defined $password; } + my $newFN = $c->param('newFirstName'); + if ($changeOptions && $authz->hasPermissions($userID, 'change_name') && $newFN) { + my $oldFN = $c->{effectiveUser}->first_name; + $c->{effectiveUser}->first_name($newFN); + eval { $db->putUser($c->{effectiveUser}) }; + if ($@) { + $c->{effectiveUser}->first_name($oldFN); + $c->log->error("Unable to save new first name for $userID: $@"); + $c->addbadmessage($c->maketext('Your first name has not been changed due to an internal error.')); + } else { + $c->param('currFirstName', $c->param('newFirstName')); + $c->param('newFirstName', undef); + $c->addgoodmessage($c->maketext('Your first name has been changed.')); + } + } + + my $newLN = $c->param('newLastName'); + if ($changeOptions && $authz->hasPermissions($userID, 'change_name') && $newLN) { + my $oldLN = $c->{effectiveUser}->last_name; + $c->{effectiveUser}->last_name($newLN); + eval { $db->putUser($c->{effectiveUser}) }; + if ($@) { + $c->{effectiveUser}->last_name($oldLN); + $c->log->error("Unable to save new last name for $userID: $@"); + $c->addbadmessage($c->maketext('Your last name has not been changed due to an internal error.')); + } else { + $c->param('currLastName', $c->param('newLastName')); + $c->param('newLastName', undef); + $c->addgoodmessage($c->maketext('Your last name has been changed.')); + } + } + my $newA = $c->param('newAddress'); if ($changeOptions && $authz->hasPermissions($userID, 'change_email_address') && $newA) { my $oldA = $c->{effectiveUser}->email_address; diff --git a/lib/WeBWorK/ContentGenerator/PODViewer.pm b/lib/WeBWorK/ContentGenerator/PODViewer.pm index e07ac0a61a..919124111f 100644 --- a/lib/WeBWorK/ContentGenerator/PODViewer.pm +++ b/lib/WeBWorK/ContentGenerator/PODViewer.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::PODViewer; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -30,15 +15,37 @@ use WeBWorK::Utils::PODParser; sub PODindex ($c) { my $pgRoot = $c->ce->{pg_dir}; - my $podFiles = Pod::Simple::Search->new->inc(0)->limit_re(qr/^doc|^lib|^macros/)->survey($pgRoot); + my $docFiles = Pod::Simple::Search->new->inc(0)->survey("$pgRoot/doc"); + my $macroFiles = Pod::Simple::Search->new->inc(0)->survey("$pgRoot/macros"); + my $libFiles = Pod::Simple::Search->new->inc(0)->survey("$pgRoot/lib"); + + my $docs = []; + for (sort keys %$docFiles) { + push(@$docs, $docFiles->{$_} =~ s!^$pgRoot/!!r); + } + + my $macros = {}; + for (sort keys %$macroFiles) { + my $macro = $macroFiles->{$_} =~ s!^$pgRoot/macros/(.+)/.+$!$1!r; + if ($macro =~ /^$pgRoot/) { + push(@$docs, $macroFiles->{$_} =~ s!^$pgRoot/!!r); + } else { + push(@{ $macros->{$macro} }, $macroFiles->{$_} =~ s!^$pgRoot/macros/$macro/!!r); + } + } - my $sections = {}; - for (sort keys %$podFiles) { - my $section = $_ =~ s/::.*$//r; - push(@{ $sections->{$section} }, $podFiles->{$_} =~ s!^$pgRoot/$section/!!r); + my $libs = []; + for (sort keys %$libFiles) { + push(@$libs, $libFiles->{$_} =~ s!^$pgRoot/lib/!!r); } - return $c->render('ContentGenerator/PODViewer', sections => $sections, sidebar_title => $c->maketext('Categories')); + return $c->render( + 'ContentGenerator/PODViewer', + docs => $docs, + macros => $macros, + libs => $libs, + sidebar_title => $c->maketext('Categories') + ); } sub renderPOD ($c) { diff --git a/lib/WeBWorK/ContentGenerator/Problem.pm b/lib/WeBWorK/ContentGenerator/Problem.pm index 067848c758..e2ef7bd20a 100644 --- a/lib/WeBWorK/ContentGenerator/Problem.pm +++ b/lib/WeBWorK/ContentGenerator/Problem.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::Problem; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; @@ -24,18 +9,18 @@ WeBWorK::ContentGenerator::Problem - Allow a student to interact with a problem. use WeBWorK::HTML::SingleProblemGrader; use WeBWorK::Debug; -use WeBWorK::Utils qw(decodeAnswers wwRound); +use WeBWorK::Utils qw(decodeAnswers wwRound); use WeBWorK::Utils::DateTime qw(before between after); -use WeBWorK::Utils::Files qw(path_is_subdir); -use WeBWorK::Utils::JITAR qw(seq_to_jitar_id jitar_id_to_seq is_jitar_problem_hidden is_jitar_problem_closed +use WeBWorK::Utils::Files qw(path_is_subdir); +use WeBWorK::Utils::JITAR qw(seq_to_jitar_id jitar_id_to_seq is_jitar_problem_hidden is_jitar_problem_closed jitar_problem_finished jitar_problem_adjusted_status); use WeBWorK::Utils::LanguageAndDirection qw(get_problem_lang_and_dir); -use WeBWorK::Utils::ProblemProcessing qw(process_and_log_answer jitar_send_warning_email compute_reduced_score +use WeBWorK::Utils::ProblemProcessing qw(process_and_log_answer jitar_send_warning_email compute_reduced_score compute_unreduced_score); -use WeBWorK::Utils::Rendering qw(getTranslatorDebuggingOptions renderPG); -use WeBWorK::Utils::Sets qw(is_restricted format_set_name_display); +use WeBWorK::Utils::Rendering qw(getTranslatorDebuggingOptions renderPG); +use WeBWorK::Utils::Sets qw(is_restricted format_set_name_display); use WeBWorK::AchievementEvaluator qw(checkForAchievements); -use WeBWorK::DB::Utils qw(global2user fake_set fake_problem); +use WeBWorK::DB::Utils qw(global2user fake_set fake_problem); use WeBWorK::Localize; use WeBWorK::AchievementEvaluator; @@ -233,12 +218,12 @@ sub can_showMeAnother ($c, $user, $effectiveUser, $set, $problem, $submitAnswers && $c->authen->session->{showMeAnother}{problemID} eq $problem->problem_id && ($c->{checkAnswers} || $c->{previewAnswers}); - # If the student has not attempted the original problem enough times yet, then showMeAnother can not be used. + # If the student has not attempted the original problem enough times yet, then showMeAnother cannot be used. return 0 if $problem->num_correct + $problem->num_incorrect + ($submitAnswers ? 1 : 0) < $c->{showMeAnother}{TriesNeeded}; - # If the number of showMeAnother uses has been exceeded, then the user can not use it again. + # If the number of showMeAnother uses has been exceeded, then the user cannot use it again. return 0 if $c->{showMeAnother}{Count} >= $c->{showMeAnother}{MaxReps} && $c->{showMeAnother}{MaxReps} > -1; return 1; @@ -385,7 +370,7 @@ async sub pre_header_initialize ($c) { $c->{invalidProblem} = !(defined $problem && ($c->{set}->visible || $authz->hasPermissions($userID, 'view_hidden_sets'))); - $c->addbadmessage($c->maketext('This problem will not count towards your grade.')) + $c->addbadmessage($c->maketext('This problem will not count toward your grade.')) if $problem && !$problem->value && !$c->{invalidProblem}; } @@ -602,7 +587,9 @@ async sub pre_header_initialize ($c) { : !$c->{previewAnswers} && $will{showCorrectAnswers} ? 1 : 0 ), - debuggingOptions => getTranslatorDebuggingOptions($authz, $userID) + debuggingOptions => getTranslatorDebuggingOptions($authz, $userID), + $can{checkAnswers} + && defined $formFields->{problem_data} ? (problemData => $formFields->{problem_data}) : () } ); @@ -797,9 +784,10 @@ sub siblings ($c) { { push( @items, - $c->link_to( - $c->maketext('Problem [_1]', join('.', @seq)) => '#', - class => $class . ' disabled-problem', + $c->tag( + 'a', + class => $class . ' disabled', + $c->maketext('Problem [_1]', join('.', @seq)) ) ); } else { @@ -1230,7 +1218,7 @@ sub output_score_summary ($c) { $c->maketext( 'Your overall recorded score is [_1]. [_2]', wwRound(0, $problem->status * 100) . '%', - $problem->value ? '' : $c->maketext('(This problem will not count towards your grade.)') + $problem->value ? '' : $c->maketext('(This problem will not count toward your grade.)') ), $c->tag('br') ) @@ -1349,16 +1337,13 @@ sub output_score_summary ($c) { ); } } - # Show information if this problem counts towards the grade of its parent. + # Show information if this problem counts toward the grade of its parent. # If it doesn't (and its not a top level problem) then its grade doesnt matter. if ($problem->counts_parent_grade() && scalar(@seq) != 1) { pop @seq; - push( - @$output, + push(@$output, $c->tag('br'), - $c->maketext( - 'The score for this problem can count towards score of problem [_1].', join('.', @seq) - ) + $c->maketext('The score for this problem can count toward score of problem [_1].', join('.', @seq)) ); } elsif (scalar(@seq) != 1) { pop @seq; @@ -1415,6 +1400,11 @@ sub output_misc ($c) { push(@$output, $c->hidden_field(studentNavFilter => $c->param('studentNavFilter'))) if $c->param('studentNavFilter'); + # If the user can check answers and a problem_data form parameter for + # this problem has been set then add a hidden input with that data. + push(@$output, $c->hidden_field(problem_data => $c->param('problem_data'))) + if $c->{can}{checkAnswers} && defined $c->param('problem_data'); + return $output->join(''); } diff --git a/lib/WeBWorK/ContentGenerator/ProblemSet.pm b/lib/WeBWorK/ContentGenerator/ProblemSet.pm index d4a7f7debc..ba77939ec5 100644 --- a/lib/WeBWorK/ContentGenerator/ProblemSet.pm +++ b/lib/WeBWorK/ContentGenerator/ProblemSet.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::ProblemSet; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; @@ -24,13 +9,14 @@ problem set. =cut use WeBWorK::Debug; -use WeBWorK::Utils qw(wwRound); -use WeBWorK::Utils::DateTime qw(after); -use WeBWorK::Utils::Files qw(path_is_subdir); +use WeBWorK::Utils qw(wwRound); +use WeBWorK::Utils::DateTime qw(after); +use WeBWorK::Utils::Files qw(path_is_subdir); use WeBWorK::Utils::Rendering qw(renderPG); -use WeBWorK::Utils::Sets qw(is_restricted grade_set format_set_name_display); -use WeBWorK::DB::Utils qw(grok_versionID_from_vsetID_sql); +use WeBWorK::Utils::Sets qw(is_restricted grade_set format_set_name_display); +use WeBWorK::DB::Utils qw(grok_versionID_from_vsetID_sql); use WeBWorK::Localize; +use WeBWorK::AchievementItems; async sub initialize ($c) { my $db = $c->db; @@ -47,12 +33,33 @@ async sub initialize ($c) { my $userID = $c->param('user'); my $eUserID = $c->param('effectiveUser'); + # Don't show "Start New Test" button when acting as another user, unless user has permissions to do so. + $c->stash->{disable_start_new_test} = $userID ne $eUserID + && !($authz->hasPermissions($userID, 'record_answers_when_acting_as_student') + || $authz->hasPermissions($userID, 'create_new_set_version_when_acting_as_student')); + my $user = $db->getUser($userID); my $effectiveUser = $db->getUser($eUserID); $c->{set} = $authz->{merged_set}; $c->{displayMode} = $user->displayMode || $ce->{pg}{options}{displayMode}; + # Import problem records for assignments or test version records for tests now. Then initialize all + # achievement item data to have access to the updated records if an achievement item was used. + if ($c->{set}->assignment_type =~ /gateway/) { + $c->{setVersions} = [ + $db->getMergedSetVersionsWhere( + { user_id => $eUserID, set_id => { like => $c->{set}->set_id . ',v%' } }, + \grok_versionID_from_vsetID_sql($db->{set_version_merged}->sql->_quote('set_id')) + ) + ]; + $c->{achievementItems} = WeBWorK::AchievementItems::UserItems($c, $eUserID, $c->{set}, $c->{setVersions}); + } else { + $c->{setProblems} = + [ $db->getMergedProblemsWhere({ user_id => $eUserID, set_id => $c->{set}->set_id }, 'problem_id') ]; + $c->{achievementItems} = WeBWorK::AchievementItems::UserItems($c, $eUserID, $c->{set}, $c->{setProblems}); + } + # Display status messages. $c->addmessage($c->tag('p', $c->b($c->authen->flash('status_message')))) if $c->authen->flash('status_message'); @@ -132,7 +139,7 @@ sub siblings ($c) { return '' unless $authz->hasPermissions($user, 'navigation_allowed'); # Note that listUserSets does not list versioned sets, but listUserSetsWhere does. On the other hand, listUserSets - # can not sort in the database, while listUserSetsWhere can. + # cannot sort in the database, while listUserSetsWhere can. my @setIDs = map { $_->[1] } $db->listUserSetsWhere({ user_id => $eUserID, set_id => { not_like => '%,v%' } }, 'set_id'); @@ -163,16 +170,7 @@ sub info { # This is called by the ContentGenerator/ProblemSet/body template for a regular homework set. # It lists the problems in the set. sub problem_list ($c) { - my $authz = $c->authz; - my $db = $c->db; - - my $setID = $c->stash('setID'); - my $user = $c->param('user'); - - my @problems = - $db->getMergedProblemsWhere({ user_id => $c->param('effectiveUser'), set_id => $setID }, 'problem_id'); - - return $c->include('ContentGenerator/ProblemSet/problem_list', problems => \@problems); + return $c->include('ContentGenerator/ProblemSet/problem_list', problems => $c->{setProblems}); } # This is called by the ContentGenerator/ProblemSet/body template for a test. @@ -199,12 +197,7 @@ sub gateway_body ($c) { my $timeInterval = $set->time_interval || 0; my @versionData; - my @setVersions = $db->getMergedSetVersionsWhere( - { user_id => $effectiveUser, set_id => { like => $set->set_id . ',v%' } }, - \grok_versionID_from_vsetID_sql($db->{set_version_merged}->sql->_quote('set_id')) - ); - - for my $verSet (@setVersions) { + for my $verSet (@{ $c->{setVersions} }) { # Count number of versions in current timeInterval if (!$timeInterval || $verSet->version_creation_time > ($timeNow - $timeInterval)) { ++$currentVersions; @@ -318,7 +311,7 @@ sub gateway_body ($c) { timeInterval => $timeInterval, timeNow => $timeNow, lastTime => $lastTime, - setVersions => \@setVersions, + setVersions => $c->{setVersions}, versionData => \@versionData, currentVersions => $currentVersions ); diff --git a/lib/WeBWorK/ContentGenerator/ProblemSets.pm b/lib/WeBWorK/ContentGenerator/ProblemSets.pm index 801ce3fd92..3eeb4fb4c3 100644 --- a/lib/WeBWorK/ContentGenerator/ProblemSets.pm +++ b/lib/WeBWorK/ContentGenerator/ProblemSets.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::ProblemSets; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; @@ -23,10 +8,10 @@ WeBWorK::ContentGenerator::ProblemSets - Display a list of built problem sets. =cut use WeBWorK::Debug; -use WeBWorK::Utils qw(sortByName); +use WeBWorK::Utils qw(sortByName); use WeBWorK::Utils::DateTime qw(after); -use WeBWorK::Utils::Files qw(readFile path_is_subdir); -use WeBWorK::Utils::Sets qw(is_restricted format_set_name_display); +use WeBWorK::Utils::Files qw(readFile path_is_subdir); +use WeBWorK::Utils::Sets qw(is_restricted format_set_name_display); use WeBWorK::Localize; # The "default" data in the course_info.txt file. @@ -191,12 +176,15 @@ sub getSetStatus ($c, $set) { $link_is_active = 0 unless $canViewUnopened; push(@$other_messages, $c->restricted_progression_msg(0, $set->restricted_status * 100, @restricted)); } elsif (!$canViewUnopened + && $ce->{LTIVersion} + && ($ce->{LTIVersion} ne 'v1p3' || !$ce->{LTI}{v1p3}{ignoreMissingSourcedID}) && defined $ce->{LTIGradeMode} && $ce->{LTIGradeMode} eq 'homework' && !$set->lis_source_did) { # The set shouldn't be shown if LTI grade mode is set to homework and a - # sourced_id is not available to use to send back grades. + # sourced_id is not available to use to send back grades + # (unless we are using LTI 1.3 and $LTI{v1p3}{ignoreMissingSourcedID} is set) push( @$other_messages, $c->maketext( diff --git a/lib/WeBWorK/ContentGenerator/ProctoredGatewayQuiz.pm b/lib/WeBWorK/ContentGenerator/ProctoredGatewayQuiz.pm index 232c9a9bdf..9aa3240e86 100644 --- a/lib/WeBWorK/ContentGenerator/ProctoredGatewayQuiz.pm +++ b/lib/WeBWorK/ContentGenerator/ProctoredGatewayQuiz.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::ProctoredGatewayQuiz; use Mojo::Base 'WeBWorK::ContentGenerator::GatewayQuiz', -signatures; diff --git a/lib/WeBWorK/ContentGenerator/RenderViaRPC.pm b/lib/WeBWorK/ContentGenerator/RenderViaRPC.pm index e861c41831..09e719d91b 100644 --- a/lib/WeBWorK/ContentGenerator/RenderViaRPC.pm +++ b/lib/WeBWorK/ContentGenerator/RenderViaRPC.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::RenderViaRPC; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; diff --git a/lib/WeBWorK/ContentGenerator/Saml2.pm b/lib/WeBWorK/ContentGenerator/Saml2.pm new file mode 100644 index 0000000000..961297fc58 --- /dev/null +++ b/lib/WeBWorK/ContentGenerator/Saml2.pm @@ -0,0 +1,45 @@ +package WeBWorK::ContentGenerator::Saml2; +use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; + +use Mojo::JSON qw(decode_json); + +use WeBWorK::Debug qw(debug); + +sub initializeRoute ($c, $routeCaptures) { + if ($c->current_route eq 'saml2_acs') { + return unless $c->param('SAMLResponse') && $c->param('RelayState'); + $c->stash->{saml2}{relayState} = decode_json($c->param('RelayState')); + $c->stash->{saml2}{samlResp} = $c->param('SAMLResponse'); + $routeCaptures->{courseID} = $c->stash->{courseID} = $c->stash->{saml2}{relayState}{course}; + } + + $routeCaptures->{courseID} = $c->stash->{courseID} = $c->param('courseID') + if $c->current_route eq 'saml2_metadata' && $c->param('courseID'); + + return; +} + +sub assertionConsumerService ($c) { + debug('Authentication succeeded. Redirecting to ' . $c->stash->{saml2_redirect}); + return $c->redirect_to($c->stash->{saml2_redirect}); +} + +sub metadata ($c) { + return $c->render(data => 'Internal site configuration error', status => 500) unless $c->authen->can('sp'); + return $c->render(data => $c->authen->sp->metadata, format => 'xml'); +} + +sub errorResponse ($c) { + return $c->reply->exception('SAML2 Login Error')->rendered(400); +} + +# When this request comes in the user is actually already signed out of webwork, so this just attempts to redirect back +# to webwork's logout page for the course. This doesn't verify anything in the response from the identity provider, but +# hopefully the courseID is found in the relay state so that the user can be redirected to the logout page for the +# course. +sub logout ($c) { + return $c->render('SAML2 Logout Error', status => 500) unless $c->param('RelayState'); + return $c->redirect_to($c->url_for('logout', courseID => decode_json($c->param('RelayState'))->{course})); +} + +1; diff --git a/lib/WeBWorK/ContentGenerator/SampleProblemViewer.pm b/lib/WeBWorK/ContentGenerator/SampleProblemViewer.pm index 3afe17cbff..34e7f0d255 100644 --- a/lib/WeBWorK/ContentGenerator/SampleProblemViewer.pm +++ b/lib/WeBWorK/ContentGenerator/SampleProblemViewer.pm @@ -1,25 +1,11 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::SampleProblemViewer; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; use File::Basename qw(basename); use Pod::Simple::Search; -use SampleProblemParser qw(parseSampleProblem generateMetadata); +use WeBWorK::Utils::Files qw(path_is_subdir); +use SampleProblemParser qw(parseSampleProblem generateMetadata getSampleProblemCode); =head1 NAME @@ -44,9 +30,17 @@ Render the requestedSampleProblem or one of the indexes. sub renderSampleProblem ($c) { my $pg_root = $c->ce->{pg_dir}; - (undef, my $macro_files) = Pod::Simple::Search->new->inc(0)->survey("$pg_root/macros"); - my %macro_locations = map { basename($_) => $_ =~ s!$pg_root/macros/!!r } keys %$macro_files; - my $metadata = generateMetadata("$pg_root/tutorial/sample-problems", macro_locations => \%macro_locations); + if ($c->stash->{filePath} =~ /\.pg$/) { + my $sampleProblemFile = "$pg_root/tutorial/sample-problems/" . $c->stash->{filePath}; + return $c->render(data => $c->maketext('File not found.')) + unless path_is_subdir($sampleProblemFile, $c->ce->{pg_dir} . '/tutorial/sample-problems') + && -r $sampleProblemFile; + + # Render the .pg file as a downloadable file. + return $c->render_file(data => getSampleProblemCode($sampleProblemFile)); + } + + my $metadata = generateMetadata("$pg_root/tutorial/sample-problems"); if (grep { $c->stash->{filePath} eq $_ } qw(categories techniques subjects macros)) { my %labels = ( @@ -79,25 +73,14 @@ sub renderSampleProblem ($c) { label => $labels{ $c->stash->{filePath} }, list => $list ); - } elsif ($c->stash->{filePath} =~ /\.pg$/) { - unless ($metadata->{ basename($c->stash->{filePath}) }) { - return $c->render(data => $c->maketext('File not found.')); - } - - # Render the .pg file as a downloadable file. - return $c->render_file( - data => parseSampleProblem( - "$pg_root/tutorial/sample-problems/" . $c->stash->{filePath}, - metadata => $metadata, - pod_root => $c->url_for('pod_index'), - pg_doc_home => $c->url_for('sample_problem_index') - )->{code} - ); } else { unless ($metadata->{ basename($c->stash->{filePath}) . '.pg' }) { $c->render(data => $c->maketext('Sample problem not found.')); } + (undef, my $macro_files) = Pod::Simple::Search->new->inc(0)->survey("$pg_root/macros"); + my %macro_locations = map { basename($_) => $_ =~ s!$pg_root/macros/!!r } keys %$macro_files; + # Render a problem with its documentation. my $problemFile = "$pg_root/tutorial/sample-problems/" . $c->stash->{filePath} . '.pg'; return $c->render( diff --git a/lib/WeBWorK/ContentGenerator/ShowMeAnother.pm b/lib/WeBWorK/ContentGenerator/ShowMeAnother.pm index 1903b39c21..39b164d4ab 100644 --- a/lib/WeBWorK/ContentGenerator/ShowMeAnother.pm +++ b/lib/WeBWorK/ContentGenerator/ShowMeAnother.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::ShowMeAnother; use Mojo::Base 'WeBWorK::ContentGenerator::Problem', -signatures, -async_await; @@ -23,9 +8,9 @@ WeBWorK::ContentGenerator::ShowMeAnother - Show students alternate versions of c =cut use WeBWorK::Debug; -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); use WeBWorK::Utils::Rendering qw(getTranslatorDebuggingOptions renderPG); -use WeBWorK::Utils::Sets qw(format_set_name_display); +use WeBWorK::Utils::Sets qw(format_set_name_display); async sub pre_header_initialize ($c) { my $ce = $c->ce; diff --git a/lib/WeBWorK/ContentGenerator/Skeleton.pm b/lib/WeBWorK/ContentGenerator/Skeleton.pm index 4ff46c0838..b8bec335f1 100644 --- a/lib/WeBWorK/ContentGenerator/Skeleton.pm +++ b/lib/WeBWorK/ContentGenerator/Skeleton.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - # SKEL: Welcome to the ContentGenerator skeleton! # # This module is designed to help you in creating subclasses of @@ -21,7 +6,7 @@ # # When you've finished, I recommend you do some cleanup. These modules are much # easier to maintain if they doesn't contain "vestigal" garbage code. Remove the -# "SKEL" comments and anything else that that you're not using in your module. +# "SKEL" comments and anything else that you're not using in your module. # SKEL: Declare the name and superclass of your module here: package WeBWorK::ContentGenerator::Skeleton; diff --git a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm index 11846668da..ccd4196ecb 100644 --- a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm +++ b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::ContentGenerator::TwoFactorAuthentication; use Mojo::Base 'WeBWorK::ContentGenerator::Login', -signatures; diff --git a/lib/WeBWorK/Controller.pm b/lib/WeBWorK/Controller.pm index 465e326184..237cf67880 100644 --- a/lib/WeBWorK/Controller.pm +++ b/lib/WeBWorK/Controller.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::Controller; use Mojo::Base 'Mojolicious::Controller', -signatures; @@ -25,7 +10,7 @@ fields. =cut use Encode; -use JSON::MaybeXS; +use Mojo::JSON qw(encode_json); use WeBWorK::Localize; diff --git a/lib/WeBWorK/CourseEnvironment.pm b/lib/WeBWorK/CourseEnvironment.pm index ac5359eca9..184052d41e 100644 --- a/lib/WeBWorK/CourseEnvironment.pm +++ b/lib/WeBWorK/CourseEnvironment.pm @@ -1,18 +1,3 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::CourseEnvironment; =head1 NAME @@ -82,6 +67,8 @@ environment file. If found, the file is read and added to the environment. =cut +our @errors; + sub new { my ($invocant, $seedVars) = @_; my $class = ref($invocant) || $invocant; @@ -94,20 +81,29 @@ sub new { $seedVars->{pg_dir} //= $WeBWorK::SeedCE{pg_dir} // $ENV{PG_ROOT}; $seedVars->{courseName} ||= '___'; # prevents extraneous error messages + $seedVars->{courseName} =~ s/'.*$//; # The following line is a work around for a bug that occurs on some systems. See # https://rt.cpan.org/Public/Bug/Display.html?id=77916 and # https://github.com/openwebwork/webwork2/pull/2098#issuecomment-1619812699. my %dummy = %+; + my @warnings; + my $outer_sig_warn = $SIG{__WARN__}; + local $SIG{__WARN__} = sub { push(@warnings, $_[0]); }; + my $safe = WeBWorK::WWSafe->new; $safe->permit('rand'); + # seed course environment with initial values while (my ($var, $val) = each %$seedVars) { - $val = "" if not defined $val; + $val //= ''; $safe->reval("\$$var = '$val';"); } + local @errors = (); + $safe->share('@errors'); + # Compile the "include" function with all opcodes available. my $include = q[ sub include { my ($file) = @_; @@ -115,16 +111,22 @@ sub new { # This regex matches any string that begins with "../", # ends with "/..", contains "/../", or is "..". if ($fullPath =~ m!(?:^|/)\.\.(?:/|$)!) { - die "Included file $file has potentially insecure path: contains \"..\""; + push(@errors, qq{Included file $file has potentially insecure path: contains ".."}); + die; } else { local @INC = (); my $result = do $fullPath; - if ($!) { - die "Failed to read include file $fullPath (has it been created from the corresponding .dist file?): $!"; - } elsif ($@) { - die "Failed to compile include file $fullPath: $@"; - } elsif (not $result) { - die "Include file $fullPath did not return a true value."; + if ($@) { + push(@errors, "Failed to compile include file $fullPath: $@"); + die; + } elsif ($!) { + push(@errors, + "Failed to read include file $fullPath " + . "(has it been created from the corresponding .dist file?): $!"); + die; + } elsif (!$result) { + push(@errors, "Include file $fullPath did not return a true value."); + die; } } } ]; @@ -147,11 +149,16 @@ sub new { my $globalFileContents = readFile($globalEnvironmentFile); $safe->share_from('main', [qw(%ENV)]); $safe->reval($globalFileContents); - # warn "end the evaluation\n"; # if that evaluation failed, we can't really go on... # we need a global environment! - $@ and croak "Could not evaluate global environment file $globalEnvironmentFile: $@"; + if ($@ || @errors) { + # Make sure any warnings that occurred are passed back to the global warning handler. + local $SIG{__WARN__} = ref($outer_sig_warn) eq 'CODE' ? $outer_sig_warn : 'DEFAULT'; + warn $_ for @warnings; + croak "Could not evaluate global environment file $globalEnvironmentFile: $errors[0]" if @errors; + croak "Could not evaluate global environment file $globalEnvironmentFile: $@"; + } # determine location of courseEnvironmentFile and simple configuration file # pull it out of $safe's symbol table ad hoc @@ -173,6 +180,10 @@ sub new { $@ or $safe->reval($courseWebConfigContents); } + # Pass any warnings that occurred back to the global warning handler. + local $SIG{__WARN__} = ref($outer_sig_warn) eq 'CODE' ? $outer_sig_warn : 'DEFAULT'; + warn $_ for @warnings; + # get the safe compartment's namespace as a hash no strict 'refs'; my %symbolHash = %{ $safe->root . "::" }; @@ -206,19 +217,15 @@ sub new { } # # We'll get the pg version here and read it into the safe symbol table if (-r $PG_version_file) { - #print STDERR ( "\n\nread PG_version file $PG_version_file\n\n"); my $PG_version_file_contents = readFile($PG_version_file) // ''; $safe->reval($PG_version_file_contents); - #print STDERR ("\n contents: $PG_version_file_contents"); no strict 'refs'; my %symbolHash2 = %{ $safe->root . "::" }; - #print STDERR "symbolHash".join(' ', keys %symbolHash2); use strict 'refs'; $self->{PG_VERSION} = ${ *{ $symbolHash2{PG_VERSION} } }; } else { $self->{PG_VERSION} = "unknown"; - #croak "Cannot read PG version file $PG_version_file"; warn "Cannot read PG version file $PG_version_file"; } diff --git a/lib/WeBWorK/DB.pm b/lib/WeBWorK/DB.pm index b13e62c082..5ccf5e0eba 100644 --- a/lib/WeBWorK/DB.pm +++ b/lib/WeBWorK/DB.pm @@ -1,19 +1,5 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 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. -################################################################################ - package WeBWorK::DB; +use Mojo::Base -strict; =head1 NAME @@ -21,7 +7,7 @@ WeBWorK::DB - interface with the WeBWorK databases. =head1 SYNOPSIS - my $db = WeBWorK::DB->new($dbLayout); + my $db = WeBWorK::DB->new($ce); my @userIDs = $db->listUsers(); my $Sam = $db->{user}->{record}->new(); @@ -40,11 +26,10 @@ WeBWorK::DB - interface with the WeBWorK databases. =head1 DESCRIPTION -WeBWorK::DB provides a consistent interface to a number of database backends. -Access and modification functions are provided for each logical table used by -the webwork system. The particular backend ("schema" and "driver"), record -class, data source, and additional parameters are specified by the hash -referenced by C<$dbLayout>, usually taken from the course environment. +WeBWorK::DB provides a database interface. Access and modification functions +are provided for each logical table used by the webwork system. The particular +schema, record class, and additional parameters are specified by the hash return +by the C method. =head1 ARCHITECTURE @@ -69,77 +54,55 @@ They are called "schema" modules because they control the structure of the data for a table. The schema modules provide an API that matches the requirements of the DB -layer, on a per-table basis. Each schema module has a style that determines -which drivers it can interface with. For example, SQL is an "dbi" style -schema. +layer, on a per-table basis. -=head2 Bottom Layer: Drivers +=head2 Bottom Layer: Database -Driver modules implement a style for a schema. They provide physical access to -a data source containing the data for a table. The style of a driver determines -what methods it provides. All drivers provide C and -C methods. A dbi style driver provides a C method which -returns the DBI handle. +The C module implements a DBI connection handle. It provides physical +access to the database. =head2 Record Types -In C<%dblayout>, each table is assigned a record class, used for passing +In the database layout, each table is assigned a record class, used for passing complete records to and from the database. The default record classes are subclasses of the WeBWorK::DB::Record class, and are named as follows: User, Password, PermissionLevel, Key, Set, UserSet, Problem, UserProblem. In the -following documentation, a reference the the record class for a table means the -record class currently defined for that table in C<%dbLayout>. +following documentation, a reference to the record class for a table means the +record class currently defined for that table in the database layout. =cut -use strict; -use warnings; - use Carp; use Data::Dumper; -use Scalar::Util qw/blessed/; -use HTML::Entities qw( encode_entities ); -use Mojo::JSON qw(encode_json decode_json); +use Scalar::Util qw(blessed); +use HTML::Entities qw(encode_entities); +use Mojo::JSON qw(encode_json decode_json); +use WeBWorK::DB::Database; use WeBWorK::DB::Schema; -use WeBWorK::DB::Utils qw/make_vsetID grok_vsetID grok_setID_from_vsetID_sql - grok_versionID_from_vsetID_sql/; +use WeBWorK::DB::Layout qw(databaseLayout); +use WeBWorK::DB::Utils qw(make_vsetID grok_vsetID grok_setID_from_vsetID_sql grok_versionID_from_vsetID_sql); use WeBWorK::Debug; use WeBWorK::Utils qw(runtime_use); -=for comment - -These exceptions will replace the ones in WeBWorK::DB::Schema and will be -allowed to propagate out to calling code. The following callers will have to be -changed to catch these exceptions instead of doing string matching: - -lib/WebworkSOAP.pm: if ($@ =~ m/user set exists/) { -lib/WeBWorK/ContentGenerator/Instructor.pm: if ($@ =~ m/user set exists/) { -lib/WeBWorK/ContentGenerator/Instructor.pm: if ( $@ =~ m/user set exists/ ) { -lib/WeBWorK/ContentGenerator/Instructor.pm: if ($@ =~ m/user problem exists/) { -lib/WeBWorK/ContentGenerator/Instructor.pm: if ($@ =~ m/user problem exists/) { -lib/WeBWorK/ContentGenerator/Instructor.pm: next if $@ =~ m/user set exists/; -lib/WeBWorK/Utils/DBImportExport.pm: if ($@ =~ m/exists/) { -lib/WeBWorK/DB.pm: if ($@ and $@ !~ m/password exists/) { -lib/WeBWorK/DB.pm: if ($@ and $@ !~ m/permission level exists/) { - -How these exceptions should be used: - -* RecordExists is thrown by the DBI error handler (handle_error in -Schema::NewSQL::Std) when in INSERT fails because a record exists. Thus it can -be thrown via addUser, addPassword, etc. - -* RecordNotFound should be thrown when we try to UPDATE and zero rows were -affected. Problem: Frank Wolfs (UofR PAS) may have a MySQL server that returns 0 -when updating even when a record was modified. What's up with that? There's some -question as to where we should throw this: in this file's put* methods? In -Std.pm's put method? Or in update_fields and update_fields_i? - -* DependencyNotFound should be throws when we check for a record that is needed -to insert another record (e.g. password depends on user). These checks are done -in this file, so we'll throw this exception from there. - -=cut +# How these exceptions should be used: +# +# * RecordExists is thrown when an INSERT fails because the record being +# inserted already exists. This exception is thrown by the database error +# handler and should not be thrown here or anywhere else. +# +# * RecordNotFound should be thrown if an UPDATE is attempted and there was no +# existing record to update. These exceptions should only be thrown by this +# file. +# +# * DependencyNotFound should be thrown when a record in another table does not +# exist that should exist for a record in the current table to be inserted (e.g. +# password depends on user). These exceptions should only be thrown by this +# file. +# +# * TableMissing is thrown if a table in the database layout is missing. This +# exception is thrown by the database error handler and should not be thrown +# here or anywhere else. use Exception::Class ( 'WeBWorK::DB::Ex' => { @@ -147,12 +110,10 @@ use Exception::Class ( }, 'WeBWorK::DB::Ex::RecordExists' => { isa => 'WeBWorK::DB::Ex', - fields => [ 'type', 'key' ], description => "record exists" }, 'WeBWorK::DB::Ex::RecordNotFound' => { isa => 'WeBWorK::DB::Ex', - fields => [ 'type', 'key' ], description => "record not found" }, 'WeBWorK::DB::Ex::DependencyNotFound' => { @@ -164,77 +125,51 @@ use Exception::Class ( }, ); -################################################################################ -# constructor -################################################################################ - =head1 CONSTRUCTOR -=over - -=item new($dbLayout) - -The C method creates a DB object and brings up the underlying schema/driver -structure according to the hash referenced by C<$dbLayout>. - -=back - -=head2 C<$dbLayout> Format - -C<$dbLayout> is a hash reference consisting of items keyed by table names. The -value of each item is a reference to a hash containing the following items: + my $db = WeBWorK::DB->new($ce) -=over +The C method creates a DB object, connects to the database via the +C module, and brings up the underlying schema structure according to +the hash referenced in the L. A course +environment object is the only required argument (as it is used to construct the +database layout). -=item record - -The name of a perl module to use for representing the data in a record. - -=item schema - -The name of a perl module to use for access to the table. - -=item driver - -The name of a perl module to use for access to the data source. - -=item source - -The location of the data source that should be used by the driver module. -Depending on the driver, this may be a path, a url, or a DBI spec. - -=item params - -A reference to a hash containing extra information needed by the schema. Some -schemas require parameters, some do not. Consult the documentation for the -schema in question. - -=back - -For each table defined in C<$dbLayout>, C loads the record, schema, and -driver modules. It the schema module's C method lists the current table -(or contains the string "*") and the output of the schema and driver modules' -C