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/bin/dev_scripts/update-localization-files b/bin/dev_scripts/update-localization-files
index 37c62152ee..6b2193d97f 100755
--- a/bin/dev_scripts/update-localization-files
+++ b/bin/dev_scripts/update-localization-files
@@ -39,8 +39,8 @@ do
esac
done
-if [ -z "$WEBWORK_ROOT" ] || [ -z "$PG_ROOT" ]; then
- echo >&2 "You need to set both the WEBWORK_ROOT and PG_ROOT environment variables. Aborting."
+if [ -z "$WEBWORK_ROOT" ]; then
+ echo >&2 "You need to set the WEBWORK_ROOT environment variable. Aborting."
exit 1
fi
@@ -53,9 +53,9 @@ LOCDIR=$WEBWORK_ROOT/lib/WeBWorK/Localize
cd $LOCDIR
-echo "Updating $WEBWORK_ROOT/webwork2.pot"
+echo "Updating $LOCDIR/webwork2.pot"
-xgettext.pl -o webwork2.pot -D $WEBWORK_ROOT/lib -D $PG_ROOT/lib -D $PG_ROOT/macros -D $WEBWORK_ROOT/templates \
+xgettext.pl -o webwork2.pot -D $WEBWORK_ROOT/lib -D $WEBWORK_ROOT/templates \
$WEBWORK_ROOT/conf/defaults.config $WEBWORK_ROOT/conf/LTIConfigValues.config
if $UPDATE_PO; then
diff --git a/bin/dev_scripts/webwork2-morbo b/bin/dev_scripts/webwork2-morbo
index f951d94c35..37dfe8121e 100755
--- a/bin/dev_scripts/webwork2-morbo
+++ b/bin/dev_scripts/webwork2-morbo
@@ -87,9 +87,10 @@ push(@watch,
"$webwork_root/lib", "$webwork_root/templates", "$webwork_root/htdocs/js",
"$webwork_root/htdocs/themes", "$webwork_root/conf");
-# Add the pg lib and pg htdocs directory if they are readable.
-push(@watch, "$config->{pg_dir}/lib") if -r "$config->{pg_dir}/lib";
-push(@watch, "$config->{pg_dir}/htdocs") if -r "$config->{pg_dir}/htdocs";
+# Add the pg lib and pg htdocs directory and the PG.pl macro if they are readable.
+push(@watch, "$config->{pg_dir}/lib") if -r "$config->{pg_dir}/lib";
+push(@watch, "$config->{pg_dir}/htdocs") if -r "$config->{pg_dir}/htdocs";
+push(@watch, "$config->{pg_dir}/macros/PG.pl") if -r "$config->{pg_dir}/macros/PG.pl";
my $morbo = Mojo::Server::Morbo->new(silent => !$verbose);
$morbo->daemon->listen(\@listen) if @listen;
diff --git a/conf/defaults.config b/conf/defaults.config
index 5c9cb4b210..96dbe4b120 100644
--- a/conf/defaults.config
+++ b/conf/defaults.config
@@ -1045,12 +1045,6 @@ $pg{options}{showEvaluatedAnswers} = 1;
# propogate to the main process. So this really should never be set to 0.
$pg{options}{catchWarnings} = 1;
-# decorations for correct input blanks -- apparently you can't define and name attribute collections in a .css file
-$pg{options}{correct_answer} = "{border-width:2;border-style:solid;border-color:#8F8}"; #matches resultsWithOutError class in math2.css
-
-# decorations for incorrect input blanks
-$pg{options}{incorrect_answer} = "{border-width:2;border-style:solid;border-color:#F55}"; #matches resultsWithError class in math2.css
-
##### Settings for various display modes
# "images" mode has several settings:
@@ -1258,7 +1252,7 @@ ${pg}{modules} = [
[qw(Applet MIME::Base64)],
[qw(PGcore PGalias PGresource PGloadfiles PGanswergroup PGresponsegroup Tie::IxHash)],
[qw(Locale::Maketext)],
- [qw(WeBWorK::Localize)],
+ [qw(WeBWorK::PG::Localize)],
[qw(JSON)],
[qw(Rserve Class::Tiny IO::Handle)],
[qw(DragNDrop)],
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/generate-assets.js b/htdocs/generate-assets.js
index d297e8b214..5f275e266f 100755
--- a/htdocs/generate-assets.js
+++ b/htdocs/generate-assets.js
@@ -212,8 +212,6 @@ if (argv.watchFiles) console.log('\x1b[32mEstablishing watches and performing in
chokidar.watch(['js', 'themes'], {
ignored: /layouts|\.min\.(js|css)$/,
cwd: __dirname, // Make sure all paths are given relative to the htdocs directory.
- usePolling: true, // Needed to get changes to symlinks.
- interval: 500,
awaitWriteFinish: { stabilityThreshold: 500 },
persistent: argv.watchFiles ? true : false
})
diff --git a/htdocs/js/ActionTabs/actiontabs.js b/htdocs/js/ActionTabs/actiontabs.js
index b7c7aca97f..50c43da164 100644
--- a/htdocs/js/ActionTabs/actiontabs.js
+++ b/htdocs/js/ActionTabs/actiontabs.js
@@ -1,11 +1,48 @@
(() => {
const takeAction = document.getElementById('take_action');
+ const currentAction = document.getElementById('current_action');
document.querySelectorAll('.action-link').forEach((actionLink) => {
- const currentAction = document.getElementById('current_action');
actionLink.addEventListener('show.bs.tab', () => {
if (takeAction) takeAction.value = actionLink.textContent;
if (currentAction) currentAction.value = actionLink.dataset.action;
});
});
+
+ // Submit the form when a sort header is clicked or enter or space is pressed when it has focus.
+ if (currentAction) {
+ for (const header of document.querySelectorAll('.sort-header')) {
+ const submitSortMethod = (e) => {
+ e.preventDefault();
+
+ currentAction.value = 'sort';
+
+ const sortInput = document.createElement('input');
+ sortInput.name = 'labelSortMethod';
+ sortInput.value = header.dataset.sortField;
+ sortInput.type = 'hidden';
+ currentAction.form.append(sortInput);
+
+ currentAction.form.submit();
+ };
+
+ header.addEventListener('click', submitSortMethod);
+ header.addEventListener('keydown', (e) => {
+ if (e.key === ' ' || e.key === 'Enter') submitSortMethod(e);
+ });
+
+ const orderToggleButton = header.parentElement.querySelector('button.sort-order');
+ orderToggleButton?.addEventListener('click', () => {
+ currentAction.value = 'sort';
+
+ const sortOrderInput = document.createElement('input');
+ sortOrderInput.name = 'labelSortOrder';
+ sortOrderInput.value = orderToggleButton.dataset.sortPriority;
+ sortOrderInput.type = 'hidden';
+ currentAction.form.append(sortOrderInput);
+
+ currentAction.form.submit();
+ });
+ }
+ }
})();
diff --git a/htdocs/js/FileManager/filemanager.js b/htdocs/js/FileManager/filemanager.js
index 8f579d6bf8..4883ebd15a 100644
--- a/htdocs/js/FileManager/filemanager.js
+++ b/htdocs/js/FileManager/filemanager.js
@@ -12,31 +12,83 @@
document.getElementsByName('directory')[0]?.addEventListener('change', () => doAction('Go'));
document.getElementsByName('dates')[0]?.addEventListener('click', () => doAction('Refresh'));
- files?.addEventListener('dblclick', () => doAction('View'));
+ files?.addEventListener('dblclick', () => {
+ if (files.selectedOptions[0].dataset.type & 0b11010) doAction('View');
+ else {
+ const container = document.createElement('div');
+ container.classList.add('toast-container', 'top-50', 'start-50', 'translate-middle');
+
+ const toast = document.createElement('div');
+ toast.classList.add('toast');
+ toast.setAttribute('role', 'alert');
+ toast.setAttribute('aria-live', 'assertive');
+ toast.setAttribute('aria-atomit', 'true');
+ const toastContent = document.createElement('div');
+ toastContent.classList.add('d-flex', 'alert', 'alert-danger', 'mb-0', 'p-0');
+
+ const toastBody = document.createElement('div');
+ toastBody.classList.add('toast-body');
+ toastBody.textContent =
+ files.selectedOptions[0].dataset.type & 0b1
+ ? files.dataset.linkMessage || 'Symbolic links can not be followed.'
+ : files.dataset.nonViewableMessage || 'This is not a viewable file type.';
+
+ const closeButton = document.createElement('button');
+ closeButton.type = 'button';
+ closeButton.classList.add('btn-close', 'me-2', 'm-auto');
+ closeButton.dataset.bsDismiss = 'toast';
+ closeButton.setAttribute('aria-label', files.dataset.closeTitle || 'Close');
+
+ toastContent.append(toastBody, closeButton);
+
+ toast.append(toastContent);
+ container.append(toast);
+ document.body.append(container);
+
+ const bsToast = new bootstrap.Toast(toast);
+ bsToast.show();
+ toast.addEventListener('hidden.bs.toast', () => {
+ bsToast.dispose();
+ container.remove();
+ });
+ }
+ });
// If on the confirmation page, then focus the "name" input.
form.querySelector('input[name="name"]')?.focus();
}
- const fileActionButtons = ['View', 'Edit', 'Download', 'Rename', 'Copy', 'Delete', 'MakeArchive'].map((buttonId) =>
- document.getElementById(buttonId)
- );
+ // The bits for types from least to most significant digit are set in the directoryListing method of
+ // lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm to mean a file is a
+ // link, directory, regular file, text file, or image file.
+ const fileActions = [
+ { id: 'View', types: 0b11010, multiple: 0 },
+ { id: 'Edit', types: 0b01000, multiple: 0 },
+ { id: 'Download', types: 0b100, multiple: 0 },
+ { id: 'Rename', types: 0b111, multiple: 0 },
+ { id: 'Copy', types: 0b100, multiple: 0 },
+ { id: 'Delete', types: 0b111, multiple: 1 },
+ { id: 'MakeArchive', types: 0b111, multiple: 1 }
+ ];
+ fileActions.map((button) => (button.elt = document.getElementById(button.id)));
const archiveButton = document.getElementById('MakeArchive');
const checkFiles = () => {
- const state = files.selectedIndex < 0;
+ const selectedFiles = files.selectedOptions;
- for (const button of fileActionButtons) {
- if (button) button.disabled = state;
+ for (const button of fileActions) {
+ if (!button.elt) continue;
+ if (selectedFiles.length) {
+ if (selectedFiles.length == 1 && !button.multiple)
+ button.elt.disabled = !(button.types & selectedFiles[0].dataset.type);
+ else button.elt.disabled = !button.multiple;
+ } else {
+ button.elt.disabled = true;
+ }
}
- if (archiveButton && !state) {
- const numSelected = files.querySelectorAll('option:checked').length;
- if (
- numSelected === 0 ||
- numSelected > 1 ||
- !/\.(tar|tar\.gz|tgz)$/.test(files.children[files.selectedIndex].value)
- )
+ if (archiveButton && selectedFiles.length) {
+ if (selectedFiles.length > 1 || !/\.(tar|tar\.gz|tgz|zip)$/.test(selectedFiles[0].value))
archiveButton.value = archiveButton.dataset.archiveText;
else archiveButton.value = archiveButton.dataset.unarchiveText;
}
@@ -45,6 +97,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/js/PGCodeMirror/pgeditor.js b/htdocs/js/PGCodeMirror/pgeditor.js
index f57a8eeb0c..d00c18d6d5 100644
--- a/htdocs/js/PGCodeMirror/pgeditor.js
+++ b/htdocs/js/PGCodeMirror/pgeditor.js
@@ -105,6 +105,10 @@
= 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);
diff --git a/htdocs/js/ProblemGrader/problemgrader.js b/htdocs/js/ProblemGrader/problemgrader.js
index fb86daa368..c1cd644e4e 100644
--- a/htdocs/js/ProblemGrader/problemgrader.js
+++ b/htdocs/js/ProblemGrader/problemgrader.js
@@ -120,15 +120,16 @@
} else {
// Update the hidden problem status fields and score table for gateway quizzes
if (saveData.versionId !== '0') {
- document.gwquiz.elements['probstatus' + saveData.problemId].value =
- parseInt(scoreInput.value) / 100;
+ const probStatus = document.gwquiz.elements[`probstatus${saveData.problemId}`];
+ if (probStatus) probStatus.value = parseInt(scoreInput.value) / 100;
let testValue = 0;
for (const scoreCell of document.querySelectorAll('table.gwNavigation td.score')) {
if (scoreCell.dataset.problemId == saveData.problemId) {
scoreCell.textContent = scoreInput.value == '100' ? '\u{1F4AF}' : scoreInput.value;
}
- testValue += document.gwquiz.elements['probstatus'
- + scoreCell.dataset.problemId].value * scoreCell.dataset.problemValue;
+ testValue +=
+ (document.gwquiz.elements[`probstatus${scoreCell.dataset.problemId}`]?.value ?? 0) *
+ scoreCell.dataset.problemValue;
}
const recordedScore = document.getElementById('test-recorded-score');
if (recordedScore) {
diff --git a/htdocs/js/RenderProblem/renderproblem.js b/htdocs/js/RenderProblem/renderproblem.js
index 726ff789a7..3041912cf1 100644
--- a/htdocs/js/RenderProblem/renderproblem.js
+++ b/htdocs/js/RenderProblem/renderproblem.js
@@ -28,7 +28,7 @@
send_pg_flags: 1,
extra_header_text:
'',
...renderOptions
};
diff --git a/htdocs/js/ShowHide/show_hide.js b/htdocs/js/ShowHide/show_hide.js
deleted file mode 100644
index 0604716470..0000000000
--- a/htdocs/js/ShowHide/show_hide.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/* This Javascript attaches the proper event handler to the "Show/Hide Description" button */
-
-(() => {
- const showHide = document.getElementById('show_hide');
- showHide?.addEventListener('click', () => {
- const description = document.getElementById("site_description");
- if (description.style.display === "none") description.style.display = "block";
- else description.style.display = "none";
- });
-})();
diff --git a/htdocs/js/TagWidget/tagwidget.js b/htdocs/js/TagWidget/tagwidget.js
index 3a54609b53..27cbd206c1 100644
--- a/htdocs/js/TagWidget/tagwidget.js
+++ b/htdocs/js/TagWidget/tagwidget.js
@@ -317,7 +317,7 @@
if (!response.ok) return showMessage('Unable to save problem tags.');
const data = await response.json();
if (data.error) return showMessage(data.error);
- showMessage(data.server_response);
+ showMessage(data.server_response, true);
}
}
diff --git a/htdocs/js/UserList/userlist.js b/htdocs/js/UserList/userlist.js
index ec41c40e0f..cbda5ab719 100644
--- a/htdocs/js/UserList/userlist.js
+++ b/htdocs/js/UserList/userlist.js
@@ -1,15 +1,14 @@
(() => {
+ // Show/hide the filter elements depending on if the field matching option is selected.
const filter_select = document.getElementById('filter_select');
- if (filter_select) {
- const classlist_add_filter_elements = () => {
- const filter_elements = document.getElementById('filter_elements');
-
- if (filter_select.selectedIndex === 3) filter_elements.style.display = 'block';
+ const filter_elements = document.getElementById('filter_elements');
+ if (filter_select && filter_elements) {
+ const toggle_filter_elements = () => {
+ if (filter_select.value === 'match_regex') filter_elements.style.display = 'block';
else filter_elements.style.display = 'none';
};
-
- filter_select.addEventListener('change', classlist_add_filter_elements);
- classlist_add_filter_elements();
+ filter_select.addEventListener('change', toggle_filter_elements);
+ toggle_filter_elements();
}
const export_select_target = document.getElementById('export_select_target');
@@ -25,31 +24,4 @@
export_select_target.addEventListener('change', classlist_add_export_elements);
classlist_add_export_elements();
}
-
- // Submit the user list form when a sort header is clicked or enter or space is pressed when it has focus.
- const userListForm = document.forms['userlist'];
- const currentAction = document.getElementById('current_action');
-
- if (userListForm && currentAction) {
- for (const header of document.querySelectorAll('.sort-header')) {
- const submitSortMethod = (e) => {
- e.preventDefault();
-
- currentAction.value = '';
-
- const sortInput = document.createElement('input');
- sortInput.name = 'labelSortMethod';
- sortInput.value = header.dataset.sortField;
- sortInput.type = 'hidden';
- userListForm.append(sortInput);
-
- userListForm.submit();
- };
-
- header.addEventListener('click', submitSortMethod);
- header.addEventListener('keydown', (e) => {
- if (e.key === ' ' || e.key === 'Enter') submitSortMethod(e);
- });
- }
- }
})();
diff --git a/htdocs/package-lock.json b/htdocs/package-lock.json
index 8e81a4a6ea..2f3dc8c4ba 100644
--- a/htdocs/package-lock.json
+++ b/htdocs/package-lock.json
@@ -23,7 +23,7 @@
"autoprefixer": "^10.4.13",
"chokidar": "^3.5.3",
"cssnano": "^6.0.0",
- "postcss": "^8.4.21",
+ "postcss": "^8.4.31",
"rtlcss": "^4.0.0",
"sass": "^1.57.1",
"terser": "^5.16.1",
@@ -290,9 +290,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001517",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
- "integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==",
+ "version": "1.0.30001547",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz",
+ "integrity": "sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==",
"dev": true,
"funding": [
{
@@ -837,10 +837,16 @@
"dev": true
},
"node_modules/nanoid": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
- "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
+ "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -903,9 +909,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.21",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
- "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dev": true,
"funding": [
{
@@ -915,10 +921,14 @@
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
- "nanoid": "^3.3.4",
+ "nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -1813,9 +1823,9 @@
}
},
"caniuse-lite": {
- "version": "1.0.30001517",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
- "integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==",
+ "version": "1.0.30001547",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz",
+ "integrity": "sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==",
"dev": true
},
"chokidar": {
@@ -2198,9 +2208,9 @@
"dev": true
},
"nanoid": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
- "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
+ "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"dev": true
},
"node-releases": {
@@ -2243,12 +2253,12 @@
"dev": true
},
"postcss": {
- "version": "8.4.21",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
- "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dev": true,
"requires": {
- "nanoid": "^3.3.4",
+ "nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
}
diff --git a/htdocs/package.json b/htdocs/package.json
index af460d4ef8..c5dd1673b3 100644
--- a/htdocs/package.json
+++ b/htdocs/package.json
@@ -27,7 +27,7 @@
"autoprefixer": "^10.4.13",
"chokidar": "^3.5.3",
"cssnano": "^6.0.0",
- "postcss": "^8.4.21",
+ "postcss": "^8.4.31",
"rtlcss": "^4.0.0",
"sass": "^1.57.1",
"terser": "^5.16.1",
diff --git a/htdocs/themes/math4/math4.js b/htdocs/themes/math4/math4.js
index 7787fd155e..4a29c2ac2a 100644
--- a/htdocs/themes/math4/math4.js
+++ b/htdocs/themes/math4/math4.js
@@ -80,11 +80,6 @@
(el) => new bootstrap.Tooltip(el, {trigger: 'hover', fallbackPlacements: []})
);
- // Set up popovers in the attemptResults table.
- document.querySelectorAll('table.attemptResults td div.answer-preview').forEach((popover) => {
- if (popover.dataset.bsContent) new bootstrap.Popover(popover, {trigger: 'click', html: true, sanitize: false});
- });
-
// Sets up problems to rescale the image accoring to attr height width and not native height width.
const rescaleImage = (_index, element) => {
if (element.height != element.naturalHeight || element.width != element.naturalWidth) {
diff --git a/htdocs/themes/math4/math4.scss b/htdocs/themes/math4/math4.scss
index f0fee9b40f..07844b815b 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;
}
}
@@ -687,14 +687,6 @@ ul.courses-list {
}
}
-div.AuthorComment {
- background-color: #00e0e0;
- color: black;
- padding: 0.25rem;
- border: 1px solid transparent;
- border-radius: 0.25rem;
-}
-
/* Footer */
#footer {
font-size: 0.8em;
@@ -901,22 +893,22 @@ input.changed[type=text] { /* orange */
min-width: 2.5em;
text-align: center;
}
-}
-span {
- &.correct {
- color: inherit; /* green */
- background-color: #8f8;
- }
+ span {
+ &.correct {
+ color: inherit;
+ background-color: #8f8;
+ }
- &.incorrect {
- color: #bf5454; /* red */
- background-color: inherit;
- }
+ &.incorrect {
+ color: #bf5454; /* red */
+ background-color: inherit;
+ }
- &.unattempted {
- color: inherit;
- background-color: #88ecff;
+ &.unattempted {
+ color: inherit;
+ background-color: #88ecff;
+ }
}
}
diff --git a/lib/FormatRenderedProblem.pm b/lib/FormatRenderedProblem.pm
index 005b9c2c37..db96efad8e 100644
--- a/lib/FormatRenderedProblem.pm
+++ b/lib/FormatRenderedProblem.pm
@@ -29,7 +29,6 @@ use Digest::SHA qw(sha1_base64);
use Mojo::Util qw(xml_escape);
use Mojo::DOM;
-use WeBWorK::HTML::AttemptsTable;
use WeBWorK::Utils qw(getAssetURL);
use WeBWorK::Utils::LanguageAndDirection;
@@ -145,33 +144,30 @@ sub formatRenderedProblem {
my $showCorrectMode = defined($ws->{inputs_ref}{WWcorrectAns}) || 0;
# A problemUUID should be added to the request as a parameter. It is used by PG to create a proper UUID for use in
# aliases for resources. It should be unique for a course, user, set, problem, and version.
- my $problemUUID = $ws->{inputs_ref}{problemUUID} // '';
- my $problemResult = $rh_result->{problem_result} // {};
- my $showSummary = $ws->{inputs_ref}{showSummary} // 1;
- my $showAnswerNumbers = $ws->{inputs_ref}{showAnswerNumbers} // 1;
-
- # Attempts table
- my $answerTemplate = '';
-
- # Do not produce an AttemptsTable when we had a rendering error.
- if (!$renderErrorOccurred) {
- my $tbl = WeBWorK::HTML::AttemptsTable->new(
- $rh_result->{answers} // {}, $ws->c,
- answersSubmitted => $ws->{inputs_ref}{answersSubmitted} // 0,
- answerOrder => $rh_result->{flags}{ANSWER_ENTRY_ORDER} // [],
- displayMode => $displayMode,
- showAnswerNumbers => $showAnswerNumbers,
- ce => $ce,
- showAttemptPreviews => $previewMode || $submitMode || $showCorrectMode,
- showAttemptResults => $submitMode || $showCorrectMode,
- showCorrectAnswers => $showCorrectMode,
- showMessages => $previewMode || $submitMode || $showCorrectMode,
- showSummary => (($showSummary && ($submitMode || $showCorrectMode)) // 0) ? 1 : 0,
- maketext => WeBWorK::Localize::getLoc($formLanguage),
- summary => $problemResult->{summary} // '', # can be set by problem grader
- );
- $answerTemplate = $tbl->answerTemplate;
- $tbl->imgGen->render(refresh => 1) if $tbl->displayMode eq 'images';
+ my $problemUUID = $ws->{inputs_ref}{problemUUID} // '';
+ my $problemResult = $rh_result->{problem_result} // {};
+ my $showSummary = $ws->{inputs_ref}{showSummary} // 1;
+
+ # Result summary
+ my $resultSummary = '';
+
+ my $lh = WeBWorK::Localize::getLangHandle($formLanguage);
+
+ # Do not produce a result summary when we had a rendering error.
+ if (!$renderErrorOccurred
+ && $showSummary
+ && !$previewMode
+ && ($submitMode || $showCorrectMode)
+ && $problemResult->{summary})
+ {
+ $resultSummary = $ws->c->c(
+ $ws->c->tag(
+ 'h2',
+ class => 'fs-3 mb-2',
+ $ws->c->maketext('Results for this submission')
+ )
+ . $ws->c->tag('div', role => 'alert', $ws->c->b($problemResult->{summary}))
+ )->join('');
}
# Answer hash in XML format used by the PTX format.
@@ -214,7 +210,7 @@ sub formatRenderedProblem {
$output->{input} = $ws->{input};
# The following could be constructed from the above, but this is a convenience
- $output->{answerTemplate} = $answerTemplate->to_string if $answerTemplate;
+ $output->{resultSummary} = $resultSummary->to_string if $resultSummary;
$output->{lang} = $PROBLEM_LANG_AND_DIR{lang};
$output->{dir} = $PROBLEM_LANG_AND_DIR{dir};
$output->{extra_css_files} = \@extra_css_files;
@@ -242,7 +238,7 @@ sub formatRenderedProblem {
formatName => $formatName,
ws => $ws,
ce => $ce,
- lh => WeBWorK::Localize::getLangHandle($ws->{inputs_ref}{language} // 'en'),
+ lh => $lh,
rh_result => $rh_result,
SITE_URL => $SITE_URL,
FORM_ACTION_URL => $SITE_URL . $ws->c->webwork_url . '/render_rpc',
@@ -263,7 +259,7 @@ sub formatRenderedProblem {
extra_js_files => \@extra_js_files,
problemText => $problemText,
extra_header_text => $ws->{inputs_ref}{extra_header_text} // '',
- answerTemplate => $answerTemplate,
+ resultSummary => $resultSummary,
showScoreSummary => $submitMode && !$renderErrorOccurred && $problemResult,
answerhashXML => $answerhashXML,
LTIGradeMessage => $LTIGradeMessage,
@@ -275,9 +271,8 @@ sub formatRenderedProblem {
isInstructor => $ws->{inputs_ref}{isInstructor} // '',
forceScaffoldsOpen => $ws->{inputs_ref}{forceScaffoldsOpen} // '',
showSummary => $showSummary,
- showHints => $ws->{inputs_ref}{showHints} // '',
- showSolutions => $ws->{inputs_ref}{showSolutions} // '',
- showAnswerNumbers => $showAnswerNumbers,
+ showHints => $ws->{inputs_ref}{showHints} // '',
+ showSolutions => $ws->{inputs_ref}{showSolutions} // '',
showPreviewButton => $ws->{inputs_ref}{showPreviewButton} // '',
showCheckAnswersButton => $ws->{inputs_ref}{showCheckAnswersButton} // '',
showCorrectAnswersButton => $ws->{inputs_ref}{showCorrectAnswersButton} // '',
@@ -403,20 +398,28 @@ EOS
# Nice output for debugging
sub pretty_print {
my ($r_input, $level) = @_;
+ return 'undef' unless defined $r_input;
+
$level //= 4;
$level--;
- return '' unless $level > 0; # Only print three levels of hashes (safety feature)
- my $out = '';
- if (!ref $r_input) {
- $out = $r_input if defined $r_input;
- $out =~ s/</g; # protect for HTML output
- } elsif (eval { %$r_input && 1 }) {
- # eval { %$r_input && 1 } will pick up all objectes that can be accessed like a hash and so works better than
- # "ref $r_input". Do not use "$r_input" =~ /hash/i" because that will pick up strings containing the word hash,
- # and that will cause an error below.
- local $^W = 0;
- $out .= qq{$r_input
};
-
+ return 'too deep' unless $level > 0;
+
+ my $ref = ref($r_input);
+
+ if (!$ref) {
+ return xml_escape($r_input);
+ } elsif (eval { %$r_input || 1 }) {
+ # `eval { %$r_input || 1 }` will pick up all objectes that can be accessed like a hash and so works better than
+ # `ref $r_input`. Do not use `"$r_input" =~ /hash/i` because that will pick up strings containing the word
+ # hash, and that will cause an error below.
+ my $out =
+ '
'
+ . ($ref eq 'HASH'
+ ? ''
+ : '
'
+ . "$ref
")
+ . '
';
for my $key (sort keys %$r_input) {
# Safety feature - we do not want to display the contents of %seed_ce which
# contains the database password and lots of other things, and explicitly hide
@@ -429,24 +432,24 @@ sub pretty_print {
|| ($key eq "externalPrograms")
|| ($key eq "permissionLevels")
|| ($key eq "seed_ce"));
- $out .= "
wrapper.
-sub formatToolTip ($self, $answer, $formattedAnswer) {
- return $self->c->tag(
- 'td',
- $self->c->tag(
- 'div',
- class => 'answer-preview',
- data => {
- bs_toggle => 'popover',
- bs_content => $answer,
- bs_placement => 'bottom',
- },
- $self->nbsp($formattedAnswer)
- )
- );
-}
-
-1;
diff --git a/lib/WeBWorK/Localize.pm b/lib/WeBWorK/Localize.pm
index fad943e9de..dd0a53f8e1 100644
--- a/lib/WeBWorK/Localize.pm
+++ b/lib/WeBWorK/Localize.pm
@@ -1,70 +1,49 @@
package WeBWorK::Localize;
+use parent 'Locale::Maketext';
+
+use strict;
+use warnings;
use File::Spec;
-use Locale::Maketext;
use Locale::Maketext::Lexicon;
use WeBWorK::Utils qw(x);
-my $path = "$ENV{WEBWORK_ROOT}/lib/WeBWorK/Localize";
-my $pattern = File::Spec->catfile($path, '*.[pm]o');
-my $decode = 1;
-my $encoding = undef;
-
-# For some reason this next stanza needs to be evaluated
-# separately. I'm not sure why it can't be
-# directly entered into the code.
-# This code was cribbed from Locale::Maketext::Simple if I remember correctly
-#
-
-eval "
- package WeBWorK::Localize::I18N;
- use base 'Locale::Maketext';
- %WeBWorK::Localize::I18N::Lexicon = ( '_AUTO' => 1 );
- Locale::Maketext::Lexicon->import({
- 'i-default' => [ 'Auto' ],
- '*' => [ Gettext => \$pattern ],
- _decode => \$decode,
- _encoding => \$encoding,
- });
- *tense = sub { \$_[1] . ((\$_[2] eq 'present') ? 'ing' : 'ed') };
-
-" or die "Can't process eval in WeBWorK/Localize.pm: line 35: " . $@;
-
-package WeBWorK::Localize;
+Locale::Maketext::Lexicon->import({
+ 'i-default' => ['Auto'],
+ '*' => [ Gettext => File::Spec->catfile("$ENV{WEBWORK_ROOT}/lib/WeBWorK/Localize", '*.[pm]o') ],
+ _decode => 1,
+ _encoding => undef,
+});
+*tense = sub { \$_[1] . ((\$_[2] eq 'present') ? 'ing' : 'ed') };
-# This subroutine is shared with the safe compartment in PG to
-# allow maketext() to be constructed in PG problems and macros
-# It seems to be a little fragile -- possibly it breaks
-# on perl 5.8.8
+# This subroutine is used to pass a language handle to job queue tasks
+# so that tasks can use maketext.
sub getLoc {
my $lang = shift;
- my $lh = WeBWorK::Localize::I18N->get_handle($lang);
+ my $lh = WeBWorK::Localize->get_handle($lang);
return sub { $lh->maketext(@_) };
}
sub getLangHandle {
my $lang = shift;
- my $lh = WeBWorK::Localize::I18N->get_handle($lang);
- return $lh;
+ return WeBWorK::Localize->get_handle($lang);
}
-# this is like [quant] but it doesn't write the number
+# This is like [quant] but it doesn't write the number.
# usage: [quant,_1,,,]
-
sub plural {
my ($handle, $num, @forms) = @_;
return '' if @forms == 0;
- return $forms[2] if @forms > 2 and $num == 0;
+ return $forms[2] if @forms > 2 && $num == 0;
# Normal case:
- return ($handle->numerate($num, @forms));
+ return $handle->numerate($num, @forms);
}
-# this is like [quant] but it also has -1 case
-# usage: [negquant,_1,,,,]
-
+# This is like [quant] but it also has a negative case. (The one usage in the code interprets this as unlimited.)
+# usage: [negquant,_1,,,,]
sub negquant {
my ($handle, $num, @forms) = @_;
@@ -73,12 +52,11 @@ sub negquant {
my $negcase = shift @forms;
return $negcase if $num < 0;
- return $forms[2] if @forms > 2 and $num == 0;
- return ($handle->numf($num) . ' ' . $handle->numerate($num, @forms));
+ return $forms[2] if @forms > 2 && $num == 0;
+ return $handle->numf($num) . ' ' . $handle->numerate($num, @forms);
}
-# we use x to mark the strings for translation
-%Lexicon = (
+our %Lexicon = (
'_AUTO' => 1,
'_ONE_COLUMN' => x('One Column'),
@@ -93,7 +71,4 @@ sub negquant {
'_STATUS' => [ x('Enrolled'), x('Audit'), x('Drop'), x('Proctor') ],
);
-package WeBWorK::Localize::I18N;
-use base(WeBWorK::Localize);
-
1;
diff --git a/lib/WeBWorK/Utils.pm b/lib/WeBWorK/Utils.pm
index 7b333a7cda..e8616e2222 100644
--- a/lib/WeBWorK/Utils.pm
+++ b/lib/WeBWorK/Utils.pm
@@ -82,6 +82,7 @@ our @EXPORT_OK = qw(
list2hash
listFilesRecursive
makeTempDirectory
+ min
max
nfreeze_base64
not_blank
@@ -253,7 +254,7 @@ Creates a directory with the given name, permission bits, and group ID.
sub createDirectory {
my ($dirName, $permission, $numgid) = @_;
- $permission = (defined($permission)) ? $permission : '0770';
+ $permission //= 0770;
my $errors = '';
mkdir($dirName, $permission)
or $errors .= "Can't do mkdir($dirName, $permission): $!\n" . caller(3);
@@ -1055,15 +1056,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/lib/WeBWorK/Utils/CourseIntegrityCheck.pm b/lib/WeBWorK/Utils/CourseIntegrityCheck.pm
index 70a9b6b2eb..375b5ca137 100644
--- a/lib/WeBWorK/Utils/CourseIntegrityCheck.pm
+++ b/lib/WeBWorK/Utils/CourseIntegrityCheck.pm
@@ -25,8 +25,9 @@ with database schema and that course directory structure is correct.
use strict;
use warnings;
+use Mojo::File qw(path);
+
use WeBWorK::Debug;
-use WeBWorK::Utils qw/createDirectory/;
use WeBWorK::Utils::CourseManagement qw/listCourses/;
# Developer note: This file should not format messages in html. Instead return an array of tuples. Each tuple should
@@ -79,8 +80,7 @@ sub confirm { my ($self, @args) = @_; my $sub = $self->{confirm_sub}; return &$s
sub DESTROY {
my ($self) = @_;
- $self->unlock_database;
- $self->SUPER::DESTROY if $self->can("SUPER::DESTROY");
+ $self->unlock_database if $self->{db_locked};
return;
}
@@ -324,85 +324,100 @@ sub checkCourseDirectories {
=item $CIchecker->updateCourseDirectories($courseName);
-Creates some course directories automatically.
+Check to see if all course directories exist and have the correct permissions.
-=cut
+If a directory does not exist, then it is copied from the model course if the
+corresponding directory exists in the model course, and is created otherwise.
-# FIXME: This method needs work. It should give better messages, and should at least attempt to fix permissions if
-# possible. It also should deal with some of the other course directories that it skips.
+If the permissions are not correct, then an attempt is made to correct the
+permissions. The permissions are expected to match the course root directory.
+If the permissions of the course root directory are not correct, then that will
+need to be manually fixed. This method does not check that.
+
+=cut
sub updateCourseDirectories {
my $self = shift;
my $ce = $self->{ce};
- my @courseDirectories = keys %{ $ce->{courseDirs} };
+ my @messages;
+
+ # Sort courseDirs by path. The important thing for the order is that a directory that is a subdirectory of
+ # another is listed after the directory containing it.
+ my @course_dirs =
+ grep { $_ ne 'root' } sort { $ce->{courseDirs}{$a} =~ /^$ce->{courseDirs}{$b}/ } keys %{ $ce->{courseDirs} };
+
+ # These are the directories in the model course that can be copied if not found in this course.
+ my %model_course_dirs = (
+ templates => 'templates',
+ html => 'html',
+ achievements => 'templates/achievements',
+ email => 'templates/email',
+ achievements_html => 'html/achievements'
+ );
- #FIXME this is hardwired for the time being.
- my %updateable_directories = (html_temp => 1, mailmerge => 1, tmpEditFileDir => 1, hardcopyThemes => 1);
+ my $permissions = path($ce->{courseDirs}{root})->stat->mode & 0777;
- my @messages;
+ for my $dir (@course_dirs) {
+ my $path = path($ce->{courseDirs}{$dir});
+ next if -r $path && -w $path && -x $path;
- for my $dir (sort @courseDirectories) {
- # Hack for upgrading the achievements directory.
- if ($dir eq 'achievements') {
- my $modelCourseAchievementsDir = "$ce->{webworkDirs}{courses}/modelCourse/templates/achievements";
- my $modelCourseAchievementsHtmlDir = "$ce->{webworkDirs}{courses}/modelCourse/html/achievements";
- my $courseAchievementsDir = $ce->{courseDirs}{achievements};
- my $courseAchievementsHtmlDir = $ce->{courseDirs}{achievements_html};
- my $courseTemplatesDir = $ce->{courseDirs}{templates};
- my $courseHtmlDir = $ce->{courseDirs}{html};
- unless (-e $modelCourseAchievementsDir && -e $modelCourseAchievementsHtmlDir) {
+ my $path_exists_initially = -e $path;
+
+ # Create the directory if it doesn't exist.
+ if (!$path_exists_initially) {
+ eval {
+ $path->make_path({ mode => $permissions });
+ push(@messages, [ "Created directory $path.", 1 ]);
+ };
+ if ($@) {
+ push(@messages, [ "Failed to create directory $path.", 0 ]);
+ next;
+ }
+ }
+
+ # Fix permissions if those are not correct.
+ if (($path->stat->mode & 0777) != $permissions) {
+ eval {
+ $path->chmod($permissions);
+ push(@messages, [ "Changed permissions for directory $path.", 1 ]);
+ };
+ push(@messages, [ "Failed to change permissions for directory $path.", 0 ]) if $@;
+ }
+
+ # If the path did not exist to begin with and there is a corresponding model course directory,
+ # then copy the contents of the model course directory.
+ if (!$path_exists_initially && $model_course_dirs{$dir}) {
+ my $modelCoursePath = "$ce->{webworkDirs}{courses}/modelCourse/$model_course_dirs{$dir}";
+ if (!-r $modelCoursePath) {
push(
@messages,
[
'Your modelCourse in the "courses" directory is out of date or missing. Please update it from '
- . 'webwork/webwork2/courses.dist directory before upgrading the other courses. Cannot find '
- . "MathAchievements directory $modelCourseAchievementsDir nor MathAchievements picture "
- . "directory $modelCourseAchievementsHtmlDir",
+ . "the webwork2/courses.dist directory. Cannot find directory $modelCoursePath. The "
+ . "directory $path has been created, but may be missing the files it should contain.",
0
]
);
- } else {
- unless (-e $courseAchievementsDir && -e $courseAchievementsHtmlDir) {
- push(@messages,
- [ "Attempting to update the achievements directory for $ce->{courseDirs}{root}", 1 ]);
- if (-e $courseAchievementsDir) {
- push(@messages, [ 'Achievements directory is already present', 1 ]);
- } else {
- system "cp -RPpi $modelCourseAchievementsDir $courseTemplatesDir";
- push(@messages, [ 'Achievements directory created', 1 ]);
- }
- if (-e $courseAchievementsHtmlDir) {
- push(@messages, [ 'Achievements html directory is already present', 1 ]);
+ next;
+ }
+
+ eval {
+ for (path($modelCoursePath)->list_tree({ dir => 1 })->each) {
+ my $destPath = $_ =~ s!$modelCoursePath!$path!r;
+ if (-l $_) {
+ symlink(readlink $_, $destPath);
+ } elsif (-d $_) {
+ path($destPath)->make_path({ mode => $permissions });
} else {
- system "cp -RPpi $modelCourseAchievementsHtmlDir $courseHtmlDir ";
- push(@messages, [ 'Achievements html directory created', 1 ]);
+ $_->copy_to($destPath);
}
}
- }
+ push(@messages, [ "Copied model course directory $modelCoursePath to $path.", 1 ]);
+ };
+ push(@messages, [ "Failed to copy model course directory $modelCoursePath to $path: $@.", 0 ]) if $@;
}
- next unless exists $updateable_directories{$dir};
- my $path = $ce->{courseDirs}->{$dir};
- unless (-e $path) { # If the directory does not exist, create it.
- my $parentDirectory = $path;
- $parentDirectory =~ s|/$||; # Remove a trailing forward slash
- $parentDirectory =~ s|/[^/]*$||; # Remove last node
- my ($perms, $groupID) = (stat $parentDirectory)[ 2, 5 ];
- if (-w $parentDirectory) {
- createDirectory($path, $perms, $groupID)
- or push(@messages, [ "Failed to create directory at $path.", 0 ]);
- } else {
- push(
- @messages,
- [
- "Permissions error. Can't create directory at $path. "
- . "Lack write permission on $parentDirectory.",
- 0
- ]
- );
- }
- }
}
return \@messages;
@@ -412,29 +427,28 @@ sub updateCourseDirectories {
# Database utilities -- borrowed from DBUpgrade.pm ??use or modify??? --MEG
##############################################################################
-sub lock_database { # lock named 'webwork.dbugrade' times out after 10 seconds
- my $self = shift;
- my $dbh = $self->dbh;
- my ($lock_status) = $dbh->selectrow_array("SELECT GET_LOCK('webwork.dbupgrade', 10)");
- if (not defined $lock_status) {
- die "Couldn't obtain lock because an error occurred.\n";
- }
- if (!$lock_status) {
+# Create a lock named 'webwork.dbugrade' that times out after 10 seconds.
+sub lock_database {
+ my $self = shift;
+ my ($lock_status) = $self->dbh->selectrow_array("SELECT GET_LOCK('webwork.dbupgrade', 10)");
+ if (!defined $lock_status) {
+ die "Couldn't obtain lock because a database error occurred.\n";
+ } elsif (!$lock_status) {
die "Timed out while waiting for lock.\n";
}
+ $self->{db_locked} = 1;
return;
}
sub unlock_database {
- my $self = shift;
- my $dbh = $self->dbh;
- my ($lock_status) = $dbh->selectrow_array("SELECT RELEASE_LOCK('webwork.dbupgrade')");
- if (not defined $lock_status) {
- # die "Couldn't release lock because the lock does not exist.\n";
- } elsif ($lock_status) {
- return;
+ my $self = shift;
+ my ($lock_status) = $self->dbh->selectrow_array("SELECT RELEASE_LOCK('webwork.dbupgrade')");
+ if ($lock_status) {
+ delete $self->{db_locked};
+ } elsif (defined $lock_status) {
+ warn "Couldn't release lock because the lock is not held by this thread.\n";
} else {
- die "Couldn't release lock because the lock is not held by this thread.\n";
+ warn "Unable to release lock because a database error occurred.\n";
}
return;
}
diff --git a/lib/WeBWorK/Utils/LibraryStats.pm b/lib/WeBWorK/Utils/LibraryStats.pm
index 186935de2a..a4a39027dd 100644
--- a/lib/WeBWorK/Utils/LibraryStats.pm
+++ b/lib/WeBWorK/Utils/LibraryStats.pm
@@ -13,61 +13,43 @@
# Artistic License for more details.
################################################################################
-###########################
-# Utils::LibraryLocalStats
-#
-# This is an interface for getting local statistics about library problems
-# for display
-###########################
-
+# This is an interface for getting global and local statistics about library problems for display.
package WeBWorK::Utils::LibraryStats;
-use base qw(Exporter);
use strict;
use warnings;
-use DBI;
-our @EXPORT = ();
-our @EXPORT_OK = qw();
+use DBI;
sub new {
- my $class = shift;
- my $ce = shift;
+ my ($class, $ce) = @_;
- my $dbh = DBI->connect(
- $ce->{problemLibrary_db}->{dbsource},
- $ce->{problemLibrary_db}->{user},
- $ce->{problemLibrary_db}->{passwd},
+ my $dbh = DBI->connect_cached(
+ $ce->{problemLibrary_db}{dbsource},
+ $ce->{problemLibrary_db}{user},
+ $ce->{problemLibrary_db}{passwd},
{
PrintError => 0,
RaiseError => 0,
},
);
- my $localselectstm = $dbh->prepare("SELECT * FROM OPL_local_statistics WHERE source_file = ?");
-
- my $globalselectstm = $dbh->prepare("SELECT * FROM OPL_global_statistics WHERE source_file = ?");
-
- my $self = {
+ return bless {
dbh => $dbh,
- localselectstm => $localselectstm,
- globalselectstm => $globalselectstm,
- };
-
- bless($self, $class);
- return $self;
+ localselectstm => $dbh->prepare("SELECT * FROM OPL_local_statistics WHERE source_file = ?"),
+ globalselectstm => $dbh->prepare("SELECT * FROM OPL_global_statistics WHERE source_file = ?"),
+ }, $class;
}
sub getLocalStats {
- my $self = shift;
- my $source_file = shift;
+ my ($self, $source_file) = @_;
my $selectstm = $self->{localselectstm};
unless ($selectstm->execute($source_file)) {
if ($selectstm->errstr =~ /Table .* doesn't exist/) {
- warn
- "Couldn't find the OPL local statistics table. Did you download the latest OPL and run update-OPL-statistics.pl?";
+ warn "Couldn't find the OPL local statistics table. "
+ . "Did you download the latest OPL and run update-OPL-statistics.pl?";
}
die $selectstm->errstr;
}
@@ -87,15 +69,14 @@ sub getLocalStats {
}
sub getGlobalStats {
- my $self = shift;
- my $source_file = shift;
+ my ($self, $source_file) = @_;
my $selectstm = $self->{globalselectstm};
unless ($selectstm->execute($source_file)) {
if ($selectstm->errstr =~ /Table .* doesn't exist/) {
- warn
- "Couldn't find the OPL global statistics table. Did you download the latest OPL and run load-OPL-global-statistics.pl?";
+ warn "Couldn't find the OPL global statistics table. "
+ . "Did you download the latest OPL and run load-OPL-global-statistics.pl?";
}
die $selectstm->errstr;
}
diff --git a/lib/WeBWorK/Utils/ListingDB.pm b/lib/WeBWorK/Utils/ListingDB.pm
index 7bcce145fd..60483182ce 100644
--- a/lib/WeBWorK/Utils/ListingDB.pm
+++ b/lib/WeBWorK/Utils/ListingDB.pm
@@ -105,10 +105,10 @@ sub getTables {
sub getDB {
my $ce = shift;
- my $dbh = DBI->connect(
- $ce->{problemLibrary_db}->{dbsource},
- $ce->{problemLibrary_db}->{user},
- $ce->{problemLibrary_db}->{passwd},
+ my $dbh = DBI->connect_cached(
+ $ce->{problemLibrary_db}{dbsource},
+ $ce->{problemLibrary_db}{user},
+ $ce->{problemLibrary_db}{passwd},
{
PrintError => 0,
RaiseError => 1,
diff --git a/lib/WeBWorK/Utils/Rendering.pm b/lib/WeBWorK/Utils/Rendering.pm
index b80c6c96c4..f10e27641c 100644
--- a/lib/WeBWorK/Utils/Rendering.pm
+++ b/lib/WeBWorK/Utils/Rendering.pm
@@ -122,8 +122,7 @@ sub constructPGOptions ($ce, $user, $set, $problem, $psvn, $formFields, $transla
$options{PERSISTENCE_HASH} = decode_json($problem->problem_data || '{}');
# Language
- $options{language} = $ce->{language};
- $options{language_subroutine} = WeBWorK::Localize::getLoc($options{language});
+ $options{language} = $ce->{language};
# Student and course Information
$options{courseName} = $ce->{courseName};
@@ -152,6 +151,15 @@ sub constructPGOptions ($ce, $user, $set, $problem, $psvn, $formFields, $transla
$options{inputs_ref} = $formFields;
$options{processAnswers} = $translationOptions->{processAnswers};
+ # Attempt Results
+ $options{showFeedback} = $translationOptions->{showFeedback};
+ $options{showAttemptAnswers} = $translationOptions->{showAttemptAnswers};
+ $options{showAttemptPreviews} = $translationOptions->{showAttemptPreviews};
+ $options{forceShowAttemptResults} = $translationOptions->{forceShowAttemptResults};
+ $options{showAttemptResults} = $translationOptions->{showAttemptResults};
+ $options{showMessages} = $translationOptions->{showMessages};
+ $options{showCorrectAnswers} = $translationOptions->{showCorrectAnswers};
+
# External Data
$options{external_data} = decode_json($set->{external_data} || '{}');
@@ -245,7 +253,7 @@ sub renderPG ($c, $effectiveUser, $set, $problem, $psvn, $formFields, $translati
flags => $pg->{flags},
};
- if (ref $pg->{pgcore}) {
+ if (ref($pg->{pgcore}) eq 'PGcore') {
$ret->{internal_debug_messages} = $pg->{pgcore}->get_internal_debug_messages;
$ret->{warning_messages} = $pg->{pgcore}->get_warning_messages();
$ret->{debug_messages} = $pg->{pgcore}->get_debug_messages();
diff --git a/lib/WebworkWebservice/RenderProblem.pm b/lib/WebworkWebservice/RenderProblem.pm
index ac2f5ef6ab..f039322613 100644
--- a/lib/WebworkWebservice/RenderProblem.pm
+++ b/lib/WebworkWebservice/RenderProblem.pm
@@ -228,7 +228,15 @@ async sub renderProblem {
isInstructor => $rh->{isInstructor} // 0,
forceScaffoldsOpen => $rh->{forceScaffoldsOpen} // 0,
QUIZ_PREFIX => $rh->{answerPrefix},
- debuggingOptions => {
+ showFeedback => $rh->{preview} || $rh->{WWsubmit} || $rh->{WWcorrectAns},
+ showAttemptAnswers => $rh->{showAttemptAnswers} // 1,
+ showAttemptPreviews => $rh->{showAttemptPreviews}
+ // ($rh->{preview} || $rh->{WWsubmit} || $rh->{WWcorrectAns}),
+ showAttemptResults => $rh->{showAttemptResults} // ($rh->{WWsubmit} || $rh->{WWcorrectAns}),
+ forceShowAttemptResults => $rh->{forceShowAttemptResults},
+ showMessages => $rh->{showMessages} // ($rh->{preview} || $rh->{WWsubmit} || $rh->{WWcorrectAns}),
+ showCorrectAnswers => $rh->{showCorrectAnswers} // $rh->{WWcorrectAns},
+ debuggingOptions => {
show_resource_info => $rh->{show_resource_info} // 0,
view_problem_debugging_info => $rh->{view_problem_debugging_info} // 0,
show_pg_info => $rh->{show_pg_info} // 0,
diff --git a/templates/ContentGenerator/Base/links.html.ep b/templates/ContentGenerator/Base/links.html.ep
index add6effaa7..ee3d9857b6 100644
--- a/templates/ContentGenerator/Base/links.html.ep
+++ b/templates/ContentGenerator/Base/links.html.ep
@@ -138,8 +138,13 @@
% }
% # Problem Editor
+ <%= link_to maketext('Login Name') => '#', class => 'sort-header',
+ data => { sort_field => 'user_id' } =%>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'user_id' =%>
+
<%= maketext('Login Status') %>
<%= maketext('Assigned Sets') %>
- <%= link_to maketext('First Name') => '#', class => 'sort-header',
- data => { sort_field => 'first_name' } %>
+
+ <%= link_to maketext('First Name') => '#', class => 'sort-header',
+ data => { sort_field => 'first_name' } %>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'first_name' =%>
+
- <%= link_to maketext('Last Name') => '#', class => 'sort-header',
- data => { sort_field => 'last_name' } =%>
+
+ <%= link_to maketext('Last Name') => '#', class => 'sort-header',
+ data => { sort_field => 'last_name' } =%>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'last_name' =%>
+
<%= maketext('Email Link') %>
- <%= link_to maketext('Student ID') => '#', class => 'sort-header',
- data => { sort_field => 'student_id' } =%>
+
+ <%= link_to maketext('Student ID') => '#', class => 'sort-header',
+ data => { sort_field => 'student_id' } =%>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'student_id' =%>
+
- <%= link_to maketext('Status') => '#', class => 'sort-header',
- data => { sort_field => 'status' } =%>
+
+ <%= link_to maketext('Status') => '#', class => 'sort-header',
+ data => { sort_field => 'status' } =%>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'status' =%>
+
- <%= link_to maketext('Section') => '#', class => 'sort-header',
- data => { sort_field => 'section' } =%>
+
+ <%= link_to maketext('Section') => '#', class => 'sort-header',
+ data => { sort_field => 'section' } =%>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'section' =%>
+
- <%= link_to maketext('Recitation') => '#', class => 'sort-header',
- data => { sort_field => 'recitation' } =%>
+
+ <%= link_to maketext('Recitation') => '#', class => 'sort-header',
+ data => { sort_field => 'recitation' } =%>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'recitation' =%>
+
- <%= link_to maketext('Comment') => '#', class => 'sort-header',
- data => { sort_field => 'comment' } =%>
+
+ <%= link_to maketext('Comment') => '#', class => 'sort-header',
+ data => { sort_field => 'comment' } =%>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'comment' =%>
+
- <%= link_to maketext('Permission Level') => '#', class => 'sort-header',
- data => { sort_field => 'permission' } =%>
+
+ <%= link_to maketext('Permission Level') => '#', class => 'sort-header',
+ data => { sort_field => 'permission' } =%>
+ <%= include 'ContentGenerator/Instructor/UserList/sort_button', field => 'permission' =%>
+
% end
% } else {
diff --git a/templates/ContentGenerator/Instructor/UserList/user_row.html.ep b/templates/ContentGenerator/Instructor/UserList/user_row.html.ep
index 73e95b1f10..ef14d6205b 100644
--- a/templates/ContentGenerator/Instructor/UserList/user_row.html.ep
+++ b/templates/ContentGenerator/Instructor/UserList/user_row.html.ep
@@ -11,7 +11,7 @@
% # User id
-
+
% # Make the user id the label for the select user checkbox.
<%= label_for $user->user_id . '_checkbox', begin =%>
% if (!$authz->hasPermissions(param('user'), 'become_student')) {
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') %>
diff --git a/templates/HelpFiles/InstructorProblemSetList.html.ep b/templates/HelpFiles/InstructorProblemSetList.html.ep
index 5ae78ad5cb..3c656f512a 100644
--- a/templates/HelpFiles/InstructorProblemSetList.html.ep
+++ b/templates/HelpFiles/InstructorProblemSetList.html.ep
@@ -21,14 +21,14 @@
. 'The following allow editing directly of a single problem set.') =%>
-
<%= maketext('"Edit Problems" column') %>
+
<%= maketext('"Problems" column') %>
<%= maketext('Indicates the number of problems in the set. Clicking on the link opens the set detail page '
. 'which allows you to modify set parameters, edit set headers, and change parameters of problems in the '
. 'set such as the number of allowed attempts or the weight (credit value). You can also add, remove, '
. 'view, edit, and reorder the problems in the set.') =%>
-
<%= maketext('"Edit Assigned Users" column') %>
+
<%= maketext('"Assigned Users" column') %>
<%= maketext('Shows how many instructors and students have been assigned this problem set, out of the total '
. 'number in the class. Clicking on this link allows you to assign the set to users, unassign this '
@@ -37,7 +37,7 @@
<%= maketext('Changing dates') %>
- <%= maketext('Dates for problem sets can be edited by clicking the pencil icon in the "Edit Set Data" column '
+ <%= maketext('Dates for problem sets can be edited by clicking the pencil icon in the "Set Name" column '
. 'next to the set name. To change dates for several sets at once, click the check box in the "Select" '
. 'column and choose "Edit selected" from the tasks above.') =%>
@@ -48,7 +48,10 @@
<%= maketext('Display sets matching a selected criteria. Useful if there are many sets.') %>
<%= maketext('Sort') %>
-
<%= maketext('Sorts the lists displayed by due date, name, etc.') %>
+
+ <%= maketext('Sorts the table rows by set name, due date, etc. The table rows can also be sorted by '
+ . 'clicking on an active link at the top of the column.') %>
+
<%= maketext('Edit') %>
<%= maketext('The sets checked below will become available for editing the due dates and visibility.') %>
diff --git a/templates/HelpFiles/InstructorSetMaker.html.ep b/templates/HelpFiles/InstructorSetMaker.html.ep
index 9731e164de..c551ab21cc 100644
--- a/templates/HelpFiles/InstructorSetMaker.html.ep
+++ b/templates/HelpFiles/InstructorSetMaker.html.ep
@@ -38,7 +38,7 @@
. q{haven't been vetted as thoroughly as OPL problems. If you want hints or solutions included while }
. 'browsing select the appropriate box.') =%>
-
<%= maketext('File Manager') %>
+
<%= maketext('Course Files') %>
<%= maketext('This option shows all pg problems in the course directory structure.')%>
<%= maketext('Course Sets') %>
<%= maketext('This option shows all problems in sets that have been created in the course.') %>
-
<%= maketext('Note that if you set the permission level of a user to something other than "Student", you may '
. 'also want to set the status of the user to "Observer". Users with the "Observer" status are not '
. 'included in statistics, scoring, or instructor emails.') =%>
-