diff --git a/Dockerfile b/Dockerfile index 349bb61816..f735e07e69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,6 +71,7 @@ RUN apt-get update \ imagemagick \ iputils-ping \ jq \ + libarchive-extract-perl \ libarchive-zip-perl \ libarray-utils-perl \ libc6-dev \ @@ -184,7 +185,7 @@ RUN apt-get update \ # ================================================================== # Phase 4 - Install additional Perl modules from CPAN that are not packaged for Ubuntu or are outdated in Ubuntu. -RUN cpanm install Statistics::R::IO DBD::MariaDB Mojo::SQLite@3.002 Perl::Tidy@20220613 \ +RUN cpanm install Statistics::R::IO DBD::MariaDB Mojo::SQLite@3.002 Perl::Tidy@20220613 Archive::Zip::SimpleZip \ && rm -fr ./cpanm /root/.cpanm /tmp/* # ================================================================== diff --git a/DockerfileStage1 b/DockerfileStage1 index d468ec0785..85357f3597 100644 --- a/DockerfileStage1 +++ b/DockerfileStage1 @@ -33,6 +33,7 @@ RUN apt-get update \ imagemagick \ iputils-ping \ jq \ + libarchive-extract-perl \ libarchive-zip-perl \ libarray-utils-perl \ libc6-dev \ @@ -146,7 +147,7 @@ RUN apt-get update \ # ================================================================== # Phase 3 - Install additional Perl modules from CPAN that are not packaged for Ubuntu or are outdated in Ubuntu. -RUN cpanm install -n Statistics::R::IO DBD::MariaDB Mojo::SQLite@3.002 Perl::Tidy@20220613 \ +RUN cpanm install -n Statistics::R::IO DBD::MariaDB Mojo::SQLite@3.002 Perl::Tidy@20220613 Archive::Zip::SimpleZip \ && rm -fr ./cpanm /root/.cpanm /tmp/* # ================================================================== diff --git a/bin/check_modules.pl b/bin/check_modules.pl index e27900f784..3c7d3c4dc1 100755 --- a/bin/check_modules.pl +++ b/bin/check_modules.pl @@ -66,6 +66,7 @@ =head1 DESCRIPTION my @modulesList = qw( Archive::Zip + Archive::Zip::SimpleZip Array::Utils Benchmark Carp diff --git a/conf/site.conf.dist b/conf/site.conf.dist index 4a00f93e23..9591b1e151 100644 --- a/conf/site.conf.dist +++ b/conf/site.conf.dist @@ -88,7 +88,7 @@ $externalPrograms{rm} = "/bin/rm"; $externalPrograms{mkdir} = "/bin/mkdir"; $externalPrograms{tar} = "/bin/tar"; $externalPrograms{gzip} = "/bin/gzip"; -$externalPrograms{git} = "/usr/bin/git"; +$externalPrograms{git} = "/usr/bin/git"; #################################################### # equation rendering/hardcopy utiltiies diff --git a/htdocs/js/FileManager/filemanager.js b/htdocs/js/FileManager/filemanager.js index 8f579d6bf8..02c3a50aa8 100644 --- a/htdocs/js/FileManager/filemanager.js +++ b/htdocs/js/FileManager/filemanager.js @@ -35,7 +35,7 @@ if ( numSelected === 0 || numSelected > 1 || - !/\.(tar|tar\.gz|tgz)$/.test(files.children[files.selectedIndex].value) + !/\.(tar|tar\.gz|tgz|zip)$/.test(files.children[files.selectedIndex].value) ) archiveButton.value = archiveButton.dataset.archiveText; else archiveButton.value = archiveButton.dataset.unarchiveText; @@ -45,6 +45,19 @@ files?.addEventListener('change', checkFiles); if (files) checkFiles(); + const archiveFilenameInput = document.getElementById('archive-filename'); + const archiveTypeSelect = document.getElementById('archive-type'); + if (archiveFilenameInput && archiveTypeSelect) { + archiveTypeSelect.addEventListener('change', () => { + if (archiveTypeSelect.value) { + archiveFilenameInput.value = archiveFilenameInput.value.replace( + /\.(zip|tgz|tar.gz)$/, + `.${archiveTypeSelect.value}` + ); + } + }); + } + const file = document.getElementById('file'); const uploadButton = document.getElementById('Upload'); const checkFile = () => (uploadButton.disabled = file.value === ''); diff --git a/htdocs/themes/math4/math4.scss b/htdocs/themes/math4/math4.scss index f0fee9b40f..87494e47b8 100644 --- a/htdocs/themes/math4/math4.scss +++ b/htdocs/themes/math4/math4.scss @@ -456,7 +456,7 @@ h2.page-title { gap: 0.25rem; margin: 0 0 0.5rem; - p { + div { margin: 0; } } diff --git a/lib/WeBWorK/ContentGenerator.pm b/lib/WeBWorK/ContentGenerator.pm index 9c0d7dad01..992597c747 100644 --- a/lib/WeBWorK/ContentGenerator.pm +++ b/lib/WeBWorK/ContentGenerator.pm @@ -265,8 +265,9 @@ message() template escape handler. sub addgoodmessage ($c, $message) { $c->addmessage($c->tag( - 'p', + 'div', class => 'alert alert-success alert-dismissible fade show ps-1 py-1', + role => 'alert', $c->c( $message, $c->tag( @@ -290,8 +291,9 @@ message() template escape handler. sub addbadmessage ($c, $message) { $c->addmessage($c->tag( - 'p', + 'div', class => 'alert alert-danger alert-dismissible fade show ps-1 py-1', + role => 'alert', $c->c( $message, $c->tag( diff --git a/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm b/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm index 5869750e92..83b5ccaa1c 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm @@ -22,12 +22,16 @@ WeBWorK::ContentGenerator::Instructor::FileManager.pm -- simple directory manage =cut +use Mojo::File; use File::Path; use File::Copy; use File::Spec; use String::ShellQuote; +use Archive::Tar; +use Archive::Zip qw(:ERROR_CODES); +use Archive::Zip::SimpleZip qw($SimpleZipError); -use WeBWorK::Utils qw(readDirectory readFile sortByName listFilesRecursive); +use WeBWorK::Utils qw(readDirectory readFile sortByName listFilesRecursive min); use WeBWorK::Upload; use WeBWorK::Utils::CourseManagement qw(archiveCourse); @@ -344,7 +348,7 @@ sub Delete ($c) { } } -# Make a gzipped tar archive +# Make a gzipped tar or zip archive sub MakeArchive ($c) { my @files = $c->param('files'); if (scalar(@files) == 0) { @@ -352,26 +356,90 @@ sub MakeArchive ($c) { return $c->Refresh; } - my $dir = "$c->{courseRoot}/$c->{pwd}"; - my $archive = uniqueName($dir, (scalar(@files) == 1) ? $files[0] . '.tgz' : "$c->{courseName}.tgz"); - my $tar = 'cd ' . shell_quote($dir) . " && $c->{ce}{externalPrograms}{tar} -cvzf " . shell_quote($archive, @files); - @files = readpipe $tar . ' 2>&1'; - if ($? == 0) { - my $n = scalar(@files); - $c->addgoodmessage($c->maketext('Archive "[_1]" created successfully ([quant,_2,file])', $archive, $n)); + my $dir = $c->{pwd} eq '.' ? $c->{courseRoot} : "$c->{courseRoot}/$c->{pwd}"; + + if ($c->param('confirmed')) { + my $action = $c->param('action') || 'Cancel'; + return $c->Refresh if $action eq 'Cancel' || $action eq $c->maketext('Cancel'); + + unless ($c->param('archive_filename')) { + $c->addbadmessage($c->maketext('The archive filename cannot be empty.')); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + + my $archive_type = + $c->param('archive_type') || ($c->param('archive_filename') =~ /\.(zip|tgz|tar.gz)$/ ? $1 : 'zip'); + + my $archive = $c->param('archive_filename'); + + # Add the correct extension to the archive filename unless it already has it. If the extension for + # the other archive type is given, then change it to the extension for this archive type. + if ($archive_type eq 'zip') { + $archive =~ s/(\.(tgz|tar.gz))?$/.zip/ unless $archive =~ /\.zip$/; + } else { + $archive =~ s/(\.zip)?$/.tgz/ unless $archive =~ /\.(tgz|tar.gz)$/; + } + + # Check filename validity. + if ($archive =~ m!/!) { + $c->addbadmessage($c->maketext('The archive filename may not contain a path component')); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + if ($archive =~ m!^\.! || $archive =~ m![^-_.a-zA-Z0-9 ]!) { + $c->addbadmessage($c->maketext('The archive filename contains illegal characters')); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + + if (-e "$dir/$archive" && !$c->param('overwrite')) { + $c->addbadmessage($c->maketext( + 'The file [_1] exists. Check "Overwrite existing archive" to force this file to be replaced.', + $archive + )); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + + unless (@files > 0) { + $c->addbadmessage($c->maketext('At least one file must be selected')); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + + my ($error, $ok); + if ($archive_type eq 'zip') { + if (my $zip = Archive::Zip::SimpleZip->new("$dir/$archive")) { + for (@files) { + $zip->add("$dir/$_", Name => $_, storelinks => 1); + } + $ok = $zip->close; + } + $error = $SimpleZipError unless $ok; + } else { + my $tar = Archive::Tar->new; + $tar->add_files(map {"$dir/$_"} @files); + # Make file names in the archive relative to the current working directory. + for ($tar->get_files) { + $tar->rename($_->full_path, $_->full_path =~ s!^$dir/!!r); + } + $ok = $tar->write("$dir/$archive", COMPRESS_GZIP); + $error = $tar->error unless $ok; + } + if ($ok) { + $c->addgoodmessage( + $c->maketext('Archive "[_1]" created successfully ([quant,_2,file])', $archive, scalar(@files))); + } else { + $c->addbadmessage($c->maketext(q{Can't create archive "[_1]": [_2]}, $archive, $error)); + } + return $c->Refresh; } else { - $c->addbadmessage( - $c->maketext(q{Can't create archive "[_1]": command returned [_2]}, $archive, systemError($?))); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); } - return $c->Refresh; } # Unpack a gzipped tar archive sub UnpackArchive ($c) { my $archive = $c->getFile('unpack'); return '' unless $archive; - if ($archive !~ m/\.(tar|tar\.gz|tgz)$/) { - $c->addbadmessage($c->maketext('You can only unpack files ending in ".tgz", ".tar" or ".tar.gz"')); + if ($archive !~ m/\.(tar|tar\.gz|tgz|zip)$/) { + $c->addbadmessage($c->maketext('You can only unpack files ending in ".zip", ".tgz", ".tar" or ".tar.gz"')); } else { $c->unpack_archive($archive); } @@ -379,19 +447,132 @@ sub UnpackArchive ($c) { } sub unpack_archive ($c, $archive) { - my $z = $archive =~ m/\.tar$/ ? '' : 'z'; - my $dir = "$c->{courseRoot}/$c->{pwd}"; - my $tar = 'cd ' . shell_quote($dir) . " && $c->{ce}{externalPrograms}{tar} -vx${z}f " . shell_quote($archive); - my @files = readpipe "$tar 2>&1"; - - if ($? == 0) { - my $n = scalar(@files); - $c->addgoodmessage($c->maketext('[quant,_1,file] unpacked successfully', $n)); - return 1; + my $dir = Mojo::File->new($c->{courseRoot}, $c->{pwd}); + + my (@members, @existing_files, @outside_files); + my $num_extracted = 0; + + if ($archive =~ m/\.zip$/) { + my $zip = Archive::Zip->new($dir->child($archive)->to_string); + unless ($zip) { + $c->addbadmessage($c->maketext(q{Unable to read zip archive file "[_1]".}, $dir->child($archive))); + return 0; + } + + Archive::Zip::setErrorHandler(sub ($error) { + chomp $error; + $c->addbadmessage($error); + }); + + @members = $zip->members; + for (@members) { + my $out_file = $dir->child($_->fileName)->realpath; + if ($out_file !~ /^$dir/) { + push(@outside_files, $_->fileName); + next; + } + + if (!$c->param('overwrite') && -e $out_file) { + push(@existing_files, $_->fileName); + next; + } + ++$num_extracted if $zip->extractMember($_ => $out_file->to_string) == AZ_OK; + } + + Archive::Zip::setErrorHandler(); + } elsif ($archive =~ m/\.(tar(\.gz)?|tgz)$/) { + local $Archive::Tar::WARN = 0; + + my $tar = Archive::Tar->new($dir->child($archive)->to_string); + unless ($tar) { + $c->addbadmessage($c->maketext(q{Unable to read tar archive file "[_1]".}, $dir->child($archive))); + return 0; + } + + $tar->setcwd($dir->to_string); + + @members = $tar->list_files; + for (@members) { + my $out_file = $dir->child($_)->realpath; + if ($out_file !~ /^$dir/) { + push(@outside_files, $_); + next; + } + + if (!$c->param('overwrite') && -e $dir->child($_)) { + push(@existing_files, $_); + next; + } + + unless ($tar->extract_file($_)) { + $c->addbadmessage($tar->error); + next; + } + ++$num_extracted; + } } else { - $c->addbadmessage($c->maketext(q{Can't unpack "[_1]": command returned [_2]}, $archive, systemError($?))); + $c->addbadmessage($c->maketext('Unsupported archive type in file "[_1]"', $archive)); return 0; } + + if (@outside_files) { + $c->addbadmessage( + $c->tag( + 'p', + $c->maketext( + 'The following [plural,_1,file is,files are] outside the current working directory ' + . 'and can not be safely unpacked.', + scalar(@outside_files), + ) + ) + . $c->tag( + 'div', + $c->tag( + 'ul', + $c->c( + (map { $c->tag('li', $_) } @outside_files[ 0 .. min(29, $#outside_files) ]), + ( + @outside_files > 30 + ? $c->tag('li', + $c->maketext('[quant,_1,more file,more files] not shown', @outside_files - 30)) + : () + ) + )->join('') + ) + ) + ); + } + + if (@existing_files) { + $c->addbadmessage( + $c->tag( + 'p', + $c->maketext( + 'The following [plural,_1,file already exists,files already exist]. ' + . 'Check "Overwrite existing files silently" to unpack [plural,_1,this file,these files].', + scalar(@existing_files), + ) + ) + . $c->tag( + 'div', + $c->tag( + 'ul', + $c->c( + (map { $c->tag('li', $_) } @existing_files[ 0 .. min(29, $#existing_files) ]), + ( + @existing_files > 30 + ? $c->tag('li', + $c->maketext('[quant,_1,more file,more files] not shown', @existing_files - 30)) + : () + ) + )->join('') + ) + ) + ); + } + + $c->addgoodmessage($c->maketext('[quant,_1,file] unpacked successfully', $num_extracted)) if $num_extracted; + return $num_extracted == @members; } # Make a new file and edit it @@ -472,9 +653,8 @@ sub Upload ($c) { $c->Confirm( $c->tag( 'p', - $c->b( - $c->maketext('File [_1] already exists. Overwrite it, or rename it as:', $name) - ) + $c->b($c->maketext( + 'File [_1] already exists. Overwrite it, or rename it as:', $name)) ), uniqueName($dir, $name), $c->maketext('Rename'), @@ -520,7 +700,7 @@ sub Upload ($c) { if (-e $file) { $c->addgoodmessage($c->maketext('File "[_1]" uploaded successfully', $name)); - if ($name =~ m/\.(tar|tar\.gz|tgz)$/ && $c->getFlag('unpack')) { + if ($name =~ m/\.(tar|tar\.gz|tgz|zip)$/ && $c->getFlag('unpack')) { if ($c->unpack_archive($name) && $c->getFlag('autodelete')) { if (unlink($file)) { $c->addgoodmessage($c->maketext('Archive "[_1]" deleted', $name)) } else { $c->addbadmessage($c->maketext(q{Can't delete archive "[_1]": [_2]}, $name, $!)) } @@ -610,9 +790,8 @@ sub directoryListing ($c, $pwd) { for my $name (@values) { my $file = "$dir/$name->[1]"; my ($size, $date) = (lstat($file))[ 7, 9 ]; - $name->[0] = - $c->b( - sprintf("%-${len}s%-16s%10s", $name->[0], -d $file ? ('', '') : (getDate($date), getSize($size))) + $name->[0] = $c->b( + sprintf("%-${len}s%-16s%10s", $name->[0], -d $file ? ('', '') : (getDate($date), getSize($size))) =~ s/\s/ /gr); } } diff --git a/lib/WeBWorK/Utils.pm b/lib/WeBWorK/Utils.pm index 8a799b884b..5f9edbfaf3 100644 --- a/lib/WeBWorK/Utils.pm +++ b/lib/WeBWorK/Utils.pm @@ -81,6 +81,7 @@ our @EXPORT_OK = qw( list2hash listFilesRecursive makeTempDirectory + min max nfreeze_base64 not_blank @@ -1049,15 +1050,22 @@ sub thaw_base64 { } -sub max(@) { - my $soFar; - foreach my $item (@_) { - $soFar = $item unless defined $soFar; - if ($item > $soFar) { - $soFar = $item; - } +sub min { + my @items = @_; + my $min = (shift @items) // 0; + for my $item (@items) { + $min = $item if ($item < $min); + } + return $min; +} + +sub max { + my @items = @_; + my $max = (shift @items) // 0; + for my $item (@items) { + $max = $item if ($item > $max); } - return defined $soFar ? $soFar : 0; + return $max; } sub wwRound(@) { diff --git a/templates/ContentGenerator/Instructor/FileManager/archive.html.ep b/templates/ContentGenerator/Instructor/FileManager/archive.html.ep new file mode 100644 index 0000000000..324bd542f4 --- /dev/null +++ b/templates/ContentGenerator/Instructor/FileManager/archive.html.ep @@ -0,0 +1,64 @@ +% use Mojo::File qw(path); +% +
+
+
+ <%= maketext('The following files have been selected for archiving. Select the type ' + . 'of archive and any subset of the requested files.') =%> +
+
+
+
+ + <%= text_field archive_filename => + @$files == 1 ? $files->[0] =~ s/(\..*)?$/.zip/r : 'webwork_files.zip', + id => 'archive-filename', placeholder => maketext('Archive Filename'), + class => 'form-control', size => 30, dir => 'ltr' =%> +
+
+
+
+
+
+ + <%= select_field archive_type => [ + [ maketext('By extension') => '', selected => undef ], + [ 'zip' => 'zip' ], + [ 'tar' => 'tgz' ] + ], + class => 'form-select', id => 'archive-type' =%> +
+
+
+
+
+ +
+
+
+
+ % + % my @files_to_compress; + % for my $file (@$files) { + % push(@files_to_compress, $file); + % my $path = path("$dir/$file"); + % push(@files_to_compress, @{ $path->list_tree({ dir => 1, hidden => 1 })->map('to_rel', $dir) }) + % if (-d $path && !-l $path); + % } + % + % # Select all files initially. Even those that are in previously selected directories or subdirectories. + % param('files', \@files_to_compress) unless param('confirmed'); + <%= select_field files => \@files_to_compress, id => 'archive-files', class => 'form-select mb-2', + 'aria-labelledby' => 'files-label', size => 20, multiple => undef, dir => 'ltr' =%> + % +
+ <%= submit_button maketext('Cancel'), name => 'action', class => 'btn btn-sm btn-secondary' =%> + <%= submit_button maketext('Make Archive'), name => 'action', class => 'btn btn-sm btn-primary' =%> +
+
+
+<%= hidden_field confirmed => 'MakeArchive' =%> +<%= $c->HiddenFlags =%> diff --git a/templates/ContentGenerator/Instructor/FileManager/refresh.html.ep b/templates/ContentGenerator/Instructor/FileManager/refresh.html.ep index e7c5db0ac6..42f1053130 100644 --- a/templates/ContentGenerator/Instructor/FileManager/refresh.html.ep +++ b/templates/ContentGenerator/Instructor/FileManager/refresh.html.ep @@ -34,13 +34,13 @@ dir => 'ltr', size => 17, multiple => undef =%>
-
+
<%= submit_button maketext('View'), id => 'View', %button =%> <%= submit_button maketext('Edit'), id => 'Edit', %button =%> <%= submit_button maketext('Download'), id => 'Download', %button =%> <%= submit_button maketext('Rename'), id => 'Rename', %button =%> <%= submit_button maketext('Copy'), id => 'Copy', %button =%> - <%= submit_button maketext('Delete'), id => 'Delete', %button =%>\ + <%= submit_button maketext('Delete'), id => 'Delete', %button =%> <%= submit_button maketext('Make Archive'), id => 'MakeArchive', data => { archive_text => maketext('Make Archive'), @@ -50,7 +50,7 @@ % unless ($c->{courseName} eq 'admin') { <%= submit_button maketext('Archive Course'), id => 'ArchiveCourse', %button =%> % } -
+
<%= submit_button maketext('New File'), id => 'NewFile', %button =%> <%= submit_button maketext('New Folder'), id => 'NewFolder', %button =%> <%= submit_button maketext('Refresh'), id => 'Refresh', %button =%> diff --git a/templates/HelpFiles/InstructorFileManager.html.ep b/templates/HelpFiles/InstructorFileManager.html.ep index bf2ecb0fb5..0698dd47f4 100644 --- a/templates/HelpFiles/InstructorFileManager.html.ep +++ b/templates/HelpFiles/InstructorFileManager.html.ep @@ -18,10 +18,10 @@ %

<%= maketext('This allows for the viewing, downloading, uploading and other management ' - . 'of files in the course. Select a file or set of files (using CTRL or SHIFT) and click ' - . 'the desired button on the right. Many actions can only be done with a single file (like ' - . 'view). Selecting a directory or set of files and clicking "Make Archive" creates a compressed ' - . 'tar file with the name COURSE_NAME.tgz' ) =%> + . 'of files in the course. Select a file or set of files (using CTRL or SHIFT) and click ' + . 'the desired button on the right. Many actions can only be done with a single file (like ' + . 'view). Selecting a directory or set of files and clicking "Make Archive" allows the creation ' + . 'of a compressed tar or zip file.') =%>

<%= maketext('The list of files include regular files, directories (ending in a "/") ' @@ -29,14 +29,13 @@

<%= maketext('Below the file list is a button and options for uploading files. Click the "Choose File" ' - . 'button, select the file, then click "Upload". ' - . 'A single file or a compressed tar (.tgz) file can be uploaded and if the option is selected, ' - . 'the archive is automatically unpacked and deleted. Generally the "automatic" option on Format ' - . 'will correctly pick the correct type of file.') =%> + . 'button, select the file, then click "Upload". Generally the "automatic" option on Format will ' + . 'correctly pick the correct type of file. A single file or a compressed tar (.tgz) file can be ' + . 'uploaded and if the options are selected, the archive is automatically unpacked and deleted.') =%>

- <%= maketext('WeBWorK expects many files to be in certain locations. The following describe this. ' - . 'Note that by default the File Manager shows the "templates" directory. Other directories mentioned ' + <%= maketext('WeBWorK expects many files to be in certain locations. The following describe this. ' + . 'Note that by default the File Manager shows the "templates" directory. Other directories mentioned ' . 'below are at the same level and need to be accessed by going up a directory by clicking the "^" button ' . 'above the file list.') =%>

@@ -48,7 +47,7 @@ . 'Set Definition files is described in the Set Definition specification. ' . 'Set definition files are mainly useful for ' . 'transferring set assignments from one course to another and are created when exporting a problem ' - . 'set from the "Hmwk Sets Editor". Each set defintion file contains a list of problems used and the ' + . 'set from the "Hmwk Sets Editor". Each set defintion file contains a list of problems used and the ' . 'dates and times. These definitions can be imported into the current course.', 'href="https://webwork.maa.org/wiki/Set_Definition_Files" target="Webworkdocs"') =%> @@ -58,10 +57,10 @@ . 'a large number of students into your class. To view the format for ClassList files see ' . 'the ClassList specification or download the [_2] file and use it as a model. ' . 'ClassList files can be prepared using a spreadsheet and then saved as [_3] (comma separated values) ' - . 'text files. However, to access as a classlist file, the file suffix needs to be changed to [_4], ' + . 'text files. However, to access as a classlist file, the file suffix needs to be changed to [_4], ' . 'which can be done with the "Rename" button.', 'href="http://webwork.maa.org/wiki/Classlist_Files#Format_of_classlist_files" target="Webworkdocs"', - 'demoCourse.lst','.csv','.lst') =%> + 'demoCourse.lst', '.csv', '.lst') =%>
<%= maketext('Scoring (".csv") files') %>