From 1f3563bf729270a55d7168dc672b03821aafe9b4 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Wed, 10 Jun 2026 20:36:51 -0500 Subject: [PATCH 1/4] Improvements for the "Save As" form in the PG editor. In general when editing any kind of problem the option to "Append to end of set" is now shown. Even for a new problem template or a sample problem. Furthermore, the option includes a select with which to choose a set for the course. When editing a problem that is in a set, the set the problem is in is selected by default. Otherwise, the default "Select a Set" option is selected. When the form is submitted and this option is selected, then validation occurs to ensure that a set has been chosen. Also, server side the parameter is validated. Note that if the server receives a request that has the radio selected but a target set is not in the parameters, then the file will be saved, but it will not be added to any set. However, this generally will not happen for those using the problem editor. It will only happen for someone that is properly authenticated and with sufficient permissions, that is hacking on parameters. Wnen editing a course info file, set header file, or hardcopy header file, do not show the "Copy auxiliary files" option. That option should only ever be shown for problem files. When editing a set or hardcopy header, don't show the options to "Replace current problem", "Append to end of set", or "Create unattached problem". Those don't make sense at all for a header file. They are not problems. Instead show options to "Set as set header for set" or "Create unattached header file". The option to "Set as set header for set" also includes a select with which to choose a set for the course that the file will be set as the header for. The set that the file is a set header for is selected by default. When a default set or hardcopy header is being edited don't include `opt/webwork/webwork2/pg` in the default save to file name that is shown. When a sample problem is being edited, use the original file name of the sample problem for the default file name to save the file to. This can still be changed by the author, but it doesn't need to be "newProblem.pg" for these. Note that the message that states that "You can change the file path for this problem manually from the Sets Manager page" when the file that is being saved already exists has been removed. That message has been there for a long time, but it doesn't really make sense to state it at this point. The user has chosen a file name, not knowing that a file by that name already exists. The intent was not to use the existing file for the problem. The intent was to save the current content as the chosen file name and use it for the problem. In addition, this message was shown for any file type, and does not apply to set headers at all. IMPORTANT NOTE: The problem editor no longer attempts to use a set version and nothing should try to open it with a set version. The only case where that was done has been removed. That is from the set detail page when editing a set version for a user. Now on that page, that only sends the set without the version. The reason this was done is because doing so in many cases results in an exception being thrown when you try to save or do many of the things in the problem editor. Even when saving doesn't result in an exception, the save doesn't go where you might think it would go. Also, don't try to use the effective user anywhere in the problem editor. That also does not do what you might think. Generally, these were things that were not really thought out. You really shouldn't be editing a problem for a particular user or set version. So the problem editor never tries to change the source file for a user problem. It always works with the global problem. Also there are a few minor changes to a couple of other tabs. These are below. Don't show the "Convert the code to PGML" and "Analyze code with PG Critic" options on the "Code Maintenance" tab when editing a set header. A set header might have antiquated methods such as BEGIN_TEXT/END_TEXT blocks, but I don't think that it is a very good idea to try the PGML conversion on these. Although the PG critic will work on these files, there are many things that the PG critic will report that don't apply at all to set headers, such as having metadata or a needing solution. Not showing "Convert the code to PGML" is perhaps debatable, but not showing "Analyze code with PG Critic" is really not debatable. On the "View/Reload" and "Generate Hardcopy" tabs do not show the options to change the seed when editing a set header. The seed doesn't really apply to set headers. I don't think a set header should ever use the random methods. Note that the primary intent of this pull request is to address issue #2992. --- htdocs/js/PGProblemEditor/pgproblemeditor.js | 34 +++ .../Instructor/PGProblemEditor.pm | 251 +++++++++--------- .../Instructor/ProblemSetDetail.pm | 1 - .../Instructor/PGProblemEditor.html.ep | 7 +- .../PGProblemEditor/add_problem_form.html.ep | 8 +- .../code_maintenance_form.html.ep | 62 ++--- .../PGProblemEditor/hardcopy_form.html.ep | 26 +- .../PGProblemEditor/save_as_form.html.ep | 120 +++++---- .../PGProblemEditor/view_form.html.ep | 25 +- .../Instructor/ProblemSetDetail.html.ep | 2 +- 10 files changed, 298 insertions(+), 238 deletions(-) diff --git a/htdocs/js/PGProblemEditor/pgproblemeditor.js b/htdocs/js/PGProblemEditor/pgproblemeditor.js index ede9b97c35..5eb6663b55 100644 --- a/htdocs/js/PGProblemEditor/pgproblemeditor.js +++ b/htdocs/js/PGProblemEditor/pgproblemeditor.js @@ -382,6 +382,40 @@ } }); + // Validation of the target set for the save as tab. + const saveAsSaveModeRadios = document.getElementsByName('action.save_as.saveMode'); + const saveToTargetSetRadio = Array.from(saveAsSaveModeRadios).find( + (r) => r.id === 'action_save_as_saveMode_new_problem_id' || r.id === 'action_save_as_saveMode_set_header_id' + ); + const targetSetSelect = document.getElementsByName('action.save_as.targetSet')?.[0]; + const actionSaveAs = document.getElementById('save_as'); + const removeSetSelectErrors = () => { + targetSetSelect?.classList.remove('is-invalid'); + targetSetSelect?.setCustomValidity(''); + }; + for (const radio of saveAsSaveModeRadios) { + radio.addEventListener('change', removeSetSelectErrors); + } + const saveToTargetSetSelected = () => { + saveToTargetSetRadio.checked = true; + if (targetSetSelect?.value) removeSetSelectErrors(); + }; + targetSetSelect?.addEventListener('change', saveToTargetSetSelected); + targetSetSelect?.addEventListener('focusin', saveToTargetSetSelected); + + document.forms.editor?.addEventListener('submit', (e) => { + if (actionSaveAs && actionSaveAs.classList.contains('active')) { + if (saveToTargetSetRadio?.checked && !targetSetSelect?.value) { + e.preventDefault(); + targetSetSelect.classList.add('is-invalid'); + targetSetSelect.setCustomValidity(targetSetSelect.dataset.errorMessage ?? 'Please select a set.'); + targetSetSelect.reportValidity(); + return; + } + removeSetSelectErrors(); + } + }); + const fileType = document.getElementsByName('file_type')[0]?.value; // This is either the div containing the CodeMirror editor or the problemContents textarea in the case that diff --git a/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm b/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm index 54aa863de6..c245731f39 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm @@ -145,26 +145,12 @@ sub pre_header_initialize ($c) { $c->{setID} = $c->stash('setID'); $c->{problemID} = $c->stash('problemID'); - # Parse setID which may come in with version data - $c->{fullSetID} = $c->{setID}; - if (defined $c->{fullSetID} && $c->{fullSetID} =~ /^([^,]*),v(\d+)$/) { - $c->{setID} = $1; - $c->{versionID} = $2; - } - # Determine displayMode and problemSeed that are needed for viewing the problem. # They are also two of the parameters which can be set by the editor. # Note that the problem seed may be overridden by the value obtained from the problem record later. $c->{displayMode} = $c->param('displayMode') // $ce->{pg}{options}{displayMode}; $c->{problemSeed} = (($c->param('problemSeed') // '') =~ s/^\s*|\s*$//gr) || DEFAULT_SEED(); - # Save file to permanent or temporary file, then redirect for viewing if it was requested to view in a new window. - # Any problem file "saved as" should be assigned to "Undefined_Set" and redirected to be viewed again in the editor. - # Problems "saved" or 'refreshed' are to be redirected to the Problem.pm module - # Set headers which are "saved" are to be redirected to the ProblemSet.pm page - # Hardcopy headers which are "saved" are also to be redirected to the ProblemSet.pm page - # Course info files are redirected to the ProblemSets.pm page - # Insure that file_type is defined $c->{file_type} = ($c->param('file_type') // '') =~ s/^\s*|\s*$//gr; @@ -239,7 +225,7 @@ sub initialize ($c) { } # 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'); + $c->{is_pg} = $c->{file_type} =~ /problem/ || $c->{file_type} =~ /header/; # Check permissions return @@ -341,6 +327,8 @@ sub initialize ($c) { $c->{prettyProblemNumber} = join('.', jitar_id_to_seq($c->{prettyProblemNumber})) if $c->{set} && $c->{set}->assignment_type eq 'jitar'; + $c->{globalSets} = [ map { $_->[0] } $db->listGlobalSetsWhere({}, 'set_id') ] if $c->{is_pg}; + return; } @@ -523,36 +511,28 @@ sub getFilePaths ($c) { } elsif ($c->{file_type} eq 'set_header' || $c->{file_type} eq 'hardcopy_header') { my $set_record = $db->getGlobalSet($c->{setID}); - if (defined $set_record) { - my $header_file = $set_record->{ $c->{file_type} }; - if ($header_file && $header_file ne 'defaultHeader') { - if ($header_file =~ m|^/|) { - # Absolute address - $editFilePath = $header_file; - } else { - $editFilePath = "$ce->{courseDirs}{templates}/$header_file"; - } + my $header_file = defined $set_record ? $set_record->{ $c->{file_type} } : ''; + if ($header_file && $header_file ne 'defaultHeader') { + if ($header_file =~ m|^/|) { + # Absolute address + $editFilePath = $header_file; } else { - # If the set record doesn't specify the filename for a header or it specifies the defaultHeader, - # then the set uses the default from assets/pg. - $editFilePath = $ce->{webworkFiles}{screenSnippets}{setHeader} - if $c->{file_type} eq 'set_header'; - $editFilePath = $ce->{webworkFiles}{hardcopySnippets}{setHeader} - if $c->{file_type} eq 'hardcopy_header'; + $editFilePath = "$ce->{courseDirs}{templates}/$header_file"; } } else { - $c->addbadmessage("Cannot find a set record for set $c->{setID}"); - return; + # If the set record does not exist, or the set record doesn't specify the filename for a header or it + # specifies the defaultHeader, then the set uses the default from assets/pg. + $editFilePath = $ce->{webworkFiles}{screenSnippets}{setHeader} + if $c->{file_type} eq 'set_header'; + $editFilePath = $ce->{webworkFiles}{hardcopySnippets}{setHeader} + if $c->{file_type} eq 'hardcopy_header'; } } elsif ($c->{file_type} eq 'problem') { - # First try getting the merged problem for the effective user. - my $effectiveUserName = $c->param('effectiveUser'); - my $problem_record = - $c->{versionID} - ? $db->getMergedProblemVersion($effectiveUserName, $c->{setID}, $c->{versionID}, $c->{problemID}) - : $db->getMergedProblem($effectiveUserName, $c->{setID}, $c->{problemID}); - - # If that doesn't work, then the problem is not yet assigned. So get the global record. + # First try getting the merged problem for the current user. + my $problem_record = $db->getMergedProblem($c->param('user'), $c->{setID}, $c->{problemID}); + + # If that doesn't work, then the problem is not assigned to this user (or this problem belongs to a gateway + # test, since the problem editor can not deal with problems from versioned sets). So get the global record. $problem_record = $db->getGlobalProblem($c->{setID}, $c->{problemID}) unless defined $problem_record; if (defined $problem_record) { @@ -866,6 +846,7 @@ sub hardcopy_handler ($c) { hardcopy_theme => $c->param('action.hardcopy.theme') } )); + return; } sub add_problem_handler ($c) { @@ -1131,9 +1112,6 @@ sub save_as_handler ($c) { 'File "[_1]" exists. File not saved. No changes have been made.', $c->shortPath($outputFilePath) )); - $c->addbadmessage( - $c->maketext('You can change the file path for this problem manually from the "Sets Manager" page')) - if defined $c->{setID}; } if ($do_not_save) { @@ -1141,9 +1119,13 @@ sub save_as_handler ($c) { 'The text box now contains the source of the original problem. ' . 'You can recover lost edits by using the Back button on your browser.' )); - } - unless ($do_not_save) { + # If the save mode is 'add_to_set_as_new_problem', but this problem is not in a set (for example for a sample + # problem), then the redirect for this save mode will fail since there is no set or problem id. So switch to the + # 'new_independent_file' save mode. That will work since it fills in the 'Undefined_Set' for the set id and 1 + # for the problem id. + $saveMode = 'new_independent_file' if $saveMode eq 'add_to_set_as_new_problem' && !defined $c->{setID}; + } else { $c->{editFilePath} = $outputFilePath; # saveFileChanges will update the tempFilePath and inputFilePath as needed. Don't do that here. @@ -1155,91 +1137,108 @@ sub save_as_handler ($c) { # presented in the form. So set that here so that the correct redirect is chosen below. $saveMode = "new_$file_type"; } elsif ($saveMode eq 'rename' && -r $outputFilePath) { - # Modify source file path in problem. - if ($file_type eq 'set_header') { - my $setRecord = $db->getGlobalSet($c->{setID}); - $setRecord->set_header($new_file_name); - if ($db->putGlobalSet($setRecord)) { - $c->addgoodmessage($c->maketext( - 'The set header for set [_1] has been renamed to "[_2]".', $c->{setID}, - $c->shortPath($outputFilePath) - )); - } else { - $c->addbadmessage($c->maketext( - 'Unable to change the set header for set [_1]. Unknown error.', $c->{setID})); - } - } elsif ($file_type eq 'hardcopy_header') { - my $setRecord = $db->getGlobalSet($c->{setID}); - $setRecord->hardcopy_header($new_file_name); - if ($db->putGlobalSet($setRecord)) { - $c->addgoodmessage($c->maketext( - 'The hardcopy header for set [_1] has been renamed to "[_2]".', $c->{setID}, - $c->shortPath($outputFilePath) - )); - } else { - $c->addbadmessage($c->maketext( - 'Unable to change the hardcopy header for set [_1]. Unknown error.', - $c->{setID} - )); - } + my $problemRecord = $db->getGlobalProblem($c->{setID}, $c->{problemID}); + $problemRecord->source_file($new_file_name); + if ($db->putGlobalProblem($problemRecord)) { + $c->addgoodmessage($c->maketext( + 'The source file for "set [_1] / problem [_2]" has been changed from "[_3]" to "[_4]".', + $c->{setID}, + $c->{prettyProblemNumber}, + $c->shortPath($c->{sourceFilePath}), + $c->shortPath($outputFilePath) + )); } else { - my $problemRecord; - if ($c->{versionID}) { - $problemRecord = - $db->getMergedProblemVersion($c->param('effectiveUser'), $c->{setID}, $1, $c->{problemID}); - } else { - $problemRecord = $db->getGlobalProblem($c->{setID}, $c->{problemID}); - } - $problemRecord->source_file($new_file_name); - my $result = - $c->{versionID} ? $db->putProblemVersion($problemRecord) : $db->putGlobalProblem($problemRecord); - - if ($result) { - $c->addgoodmessage($c->maketext( - 'The source file for "set [_1] / problem [_2]" has been changed from "[_3]" to "[_4]".', - $c->{fullSetID}, - $c->{prettyProblemNumber}, - $c->shortPath($c->{sourceFilePath}), - $c->shortPath($outputFilePath) - )); - } else { - $c->addbadmessage($c->maketext( - 'Unable to change the source file path for set [_1], problem [_2]. Unknown error.', - $c->{fullSetID}, $c->{prettyProblemNumber} - )); + $c->addbadmessage($c->maketext( + 'Unable to change the source file path for set [_1], problem [_2]. Unknown error.', + $c->{setID}, $c->{prettyProblemNumber} + )); + } + } elsif ($saveMode eq 'set_as_heaader_for_set' && -r $outputFilePath) { + my $setID = $c->param('action.save_as.targetSet'); + if (defined $setID && $setID =~ /\S/) { + $c->{setID} = $setID; + if ($file_type eq 'set_header') { + my $setRecord = $db->getGlobalSet($c->{setID}); + $setRecord->set_header($new_file_name); + if ($db->putGlobalSet($setRecord)) { + $c->addgoodmessage($c->maketext( + 'The set header for set [_1] has been set to "[_2]".', $c->{setID}, + $c->shortPath($outputFilePath) + )); + } else { + $c->addbadmessage($c->maketext( + 'Unable to change the set header for set [_1]. Unknown error.', + $c->{setID} + )); + } + } elsif ($file_type eq 'hardcopy_header') { + my $setRecord = $db->getGlobalSet($c->{setID}); + $setRecord->hardcopy_header($new_file_name); + if ($db->putGlobalSet($setRecord)) { + $c->addgoodmessage($c->maketext( + 'The hardcopy header for set [_1] has been set to "[_2]".', $c->{setID}, + $c->shortPath($outputFilePath) + )); + } else { + $c->addbadmessage($c->maketext( + 'Unable to change the hardcopy header for set [_1]. Unknown error.', + $c->{setID} + )); + } } + } else { + my $headerType = + $file_type eq 'set_header' ? $c->maketext('set header') : $c->maketext('hardcopy header'); + $c->addbadmessage($c->maketext( + 'A new file has been created at "[_1]" with the contents below. However, ' + . 'the file has not been set as the [_2] for a set, since no target set was specified.', + $c->shortPath($outputFilePath), + $headerType + )); + $saveMode = 'new_independent_file'; } } elsif ($saveMode eq 'add_to_set_as_new_problem') { - my $set = $db->getGlobalSet($c->{setID}); + my $setID = $c->param('action.save_as.targetSet'); + if (defined $setID && $setID =~ /\S/) { + $c->{setID} = $c->param('action.save_as.targetSet'); + my $set = $db->getGlobalSet($c->{setID}); + + # For jitar sets new problems are put as top level problems at the end. + if ($set->assignment_type eq 'jitar') { + my @problemIDs = $db->listGlobalProblems($c->{setID}); + @problemIDs = sort { $a <=> $b } @problemIDs; + my @seq = jitar_id_to_seq($problemIDs[-1]); + $targetProblemNumber = seq_to_jitar_id($seq[0] + 1); + } else { + $targetProblemNumber = 1 + max($db->listGlobalProblems($c->{setID})); + } - # For jitar sets new problems are put as top level problems at the end. - if ($set->assignment_type eq 'jitar') { - my @problemIDs = $db->listGlobalProblems($c->{setID}); - @problemIDs = sort { $a <=> $b } @problemIDs; - my @seq = jitar_id_to_seq($problemIDs[-1]); - $targetProblemNumber = seq_to_jitar_id($seq[0] + 1); + my $problemRecord = addProblemToSet( + $db, $c->ce->{problemDefaults}, + setName => $c->{setID}, + sourceFile => $new_file_name, + problemID => $targetProblemNumber, + ); + assignProblemToAllSetUsers($db, $problemRecord); + $c->addgoodmessage($c->maketext( + 'Added [_1] to [_2] as problem [_3].', + $new_file_name, + $c->{setID}, + ( + $set->assignment_type eq 'jitar' + ? join('.', jitar_id_to_seq($targetProblemNumber)) + : $targetProblemNumber + ) + )); } else { - $targetProblemNumber = 1 + max($db->listGlobalProblems($c->{setID})); + $c->addbadmessage($c->maketext( + 'A new file has been created at "[_1]" with the contents below. ' + . 'However, the problem has not been added to a set, since no target set was specified.', + $c->shortPath($outputFilePath) + )); + $saveMode = 'new_independent_file'; } - - my $problemRecord = addProblemToSet( - $db, $c->ce->{problemDefaults}, - setName => $c->{setID}, - sourceFile => $new_file_name, - problemID => $targetProblemNumber, # Added to end of set - ); - assignProblemToAllSetUsers($db, $problemRecord); - $c->addgoodmessage($c->maketext( - 'Added [_1] to [_2] as problem [_3].', - $new_file_name, - $c->{setID}, - ( - $set->assignment_type eq 'jitar' - ? join('.', jitar_id_to_seq($targetProblemNumber)) - : $targetProblemNumber - ) - )); - } elsif ($saveMode eq 'new_independent_problem') { + } elsif ($saveMode eq 'new_independent_file') { $c->addgoodmessage($c->maketext( 'A new file has been created at "[_1]" with the contents below.', $c->shortPath($outputFilePath) @@ -1260,15 +1259,15 @@ sub save_as_handler ($c) { if ($saveMode eq 'new_course_info') { $problemPage = $c->url_for('instructor_problem_editor'); $new_file_type = 'course_info'; - } elsif ($saveMode eq 'new_independent_problem') { + } elsif ($saveMode eq 'new_independent_file') { $problemPage = $c->url_for('instructor_problem_editor_withset_withproblem', setID => 'Undefined_Set', problemID => 1); - $new_file_type = 'source_path_for_problem_file'; + $new_file_type = $file_type =~ /header/ ? $file_type : 'source_path_for_problem_file'; } elsif ($saveMode eq 'new_hardcopy_theme') { $problemPage = $c->url_for('instructor_problem_editor'); $new_file_type = 'hardcopy_theme'; $extra_params{hardcopy_theme} = $new_file_name =~ s|^.*\/([^/]*\.xml)|$1|r; - } elsif ($saveMode eq 'rename') { + } elsif ($saveMode eq 'rename' || $saveMode eq 'set_as_heaader_for_set') { $problemPage = $c->url_for( 'instructor_problem_editor_withset_withproblem', setID => $c->{setID}, diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm index 9bb9fd7994..3edfc46755 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm @@ -1298,7 +1298,6 @@ sub initialize ($c) { my $setID = $c->stash('setID'); # Make sure these are defined for the templates. - $c->stash->{fullSetID} = $setID; $c->stash->{headers} = HEADER_ORDER(); $c->stash->{field_properties} = FIELD_PROPERTIES(); $c->stash->{display_modes} = WeBWorK::PG::DISPLAY_MODES(); diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep index 90f16ba4ab..8543dfe25c 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep @@ -47,8 +47,7 @@ % source_path_for_problem_file => x('Editing unassigned problem file "[_1]".') % ); % -% my $setName = stash('setID') // ''; -% my $fullSetName = $c->{fullSetID} // $setName; +% my $setName = stash('setID') // ''; % % my $fileInfo = begin
@@ -59,7 +58,7 @@ ? maketext( 'Editing problem [_1] of set [_2] in file "[_3]".', $c->{prettyProblemNumber}, - tag('span', dir => 'ltr', format_set_name_display($fullSetName)), + tag('span', dir => 'ltr', format_set_name_display($setName)), tag('span', dir => 'ltr', class => 'current-file', data => { tmp_file => $c->shortPath($c->{tempFilePath}) }, $c->shortPath($c->{inputFilePath})) @@ -81,7 +80,7 @@ <%= $c->hidden_authen_fields =%> <%= hidden_field file_type => $c->{file_type} =%> <%= hidden_field courseID => $c->{courseID} =%> - % if (defined $setName) { + % if ($setName ne '') { <%= hidden_field hidden_set_id => $setName =%> % } % if (not_blank($c->{sourceFilePath})) { diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor/add_problem_form.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor/add_problem_form.html.ep index 5e6c4efbb3..04a008d9f5 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor/add_problem_form.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor/add_problem_form.html.ep @@ -2,17 +2,15 @@ % % last unless $c->{is_pg} && $c->{file_type} ne 'blank_problem' && $c->{file_type} ne 'sample_problem'; % -% my $allSetNames = [ map { $_->[0] =~ s/^set|\.def$//gr } $db->listGlobalSetsWhere({}, 'set_id') ]; -%
<%= label_for action_add_problem_target_set_id => maketext('Add to what set?'), class => 'col-form-label col-auto' =%>
<%= select_field 'action.add_problem.target_set' => [ - map { [ - format_set_name_display($_) => $_, $_ eq ($c->{setID} // '') ? (selected => undef) : () - ] } @$allSetNames + map { + [ format_set_name_display($_) => $_, $_ eq ($c->{setID} // '') ? (selected => undef) : () ] + } @{ $c->{globalSets} } ], id => 'action_add_problem_target_set_id', class => 'form-select form-select-sm d-inline w-auto', dir => 'ltr' =%> diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor/code_maintenance_form.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor/code_maintenance_form.html.ep index f05df5d277..f606f8aee4 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor/code_maintenance_form.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor/code_maintenance_form.html.ep @@ -14,34 +14,36 @@ <%= maketext('Perltidy Help') %>
-
- <%= radio_button 'action.code_maintenance' => 'convertCodeToPGML', - id => 'action_code_maintenance_convert_PGML', class => 'form-check-input'=%> - <%= label_for 'action_code_maintenance_convert_PGML', class => 'form-check-label', begin =%> - <%== maketext('Convert the code to PGML') =%> - <% end =%> - - - <%= maketext('PGML Conversion Help') %> - -
-
- <%= radio_button 'action.code_maintenance' => 'runPGCritic', - id => 'action_code_maintenance_run_pgcritic', class => 'form-check-input'=%> - <%= label_for 'action_code_maintenance_run_pgcritic', class => 'form-check-label', begin =%> - <%== maketext('Analyze code with PG Critic') =%> - <% end =%> - - - <%= maketext('PG Critic Help') %> - -
+ % if ($c->{file_type} !~ /header/) { +
+ <%= radio_button 'action.code_maintenance' => 'convertCodeToPGML', + id => 'action_code_maintenance_convert_PGML', class => 'form-check-input'=%> + <%= label_for 'action_code_maintenance_convert_PGML', class => 'form-check-label', begin =%> + <%== maketext('Convert the code to PGML') =%> + <% end =%> + + + <%= maketext('PGML Conversion Help') %> + +
+
+ <%= radio_button 'action.code_maintenance' => 'runPGCritic', + id => 'action_code_maintenance_run_pgcritic', class => 'form-check-input'=%> + <%= label_for 'action_code_maintenance_run_pgcritic', class => 'form-check-label', begin =%> + <%== maketext('Analyze code with PG Critic') =%> + <% end =%> + + + <%= maketext('PG Critic Help') %> + +
+ % }
diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor/hardcopy_form.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor/hardcopy_form.html.ep index 79b1d08e7b..c3796749cc 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor/hardcopy_form.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor/hardcopy_form.html.ep @@ -1,19 +1,21 @@ % last unless $c->{is_pg}; %
-
- <%= label_for action_hardcopy_seed_id => maketext('Using what seed?'), - class => 'col-form-label col-auto mb-2' =%> -
- <%= text_field 'action.hardcopy.seed' => value => $c->{problemSeed}, - id => 'action_hardcopy_seed_id', class => 'form-control form-control-sm' =%> + % if ($c->{file_type} !~ /header/) { +
+ <%= label_for action_hardcopy_seed_id => maketext('Using what seed?'), + class => 'col-form-label col-auto mb-2' =%> +
+ <%= text_field 'action.hardcopy.seed' => value => $c->{problemSeed}, + id => 'action_hardcopy_seed_id', class => 'form-control form-control-sm' =%> +
+
+ +
-
- -
-
+ % }
<%= label_for 'action_hardcopy_format_id' , class => 'col-form-label col-auto', begin =%> <%= maketext('Using which hardcopy format?') =%> diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor/save_as_form.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor/save_as_form.html.ep index 91b0364a82..db69e9e767 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor/save_as_form.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor/save_as_form.html.ep @@ -1,38 +1,28 @@ -% use File::Basename qw(dirname); +% use File::Basename qw(dirname basename); % -% use WeBWorK::Utils qw(not_blank); % use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); % use WeBWorK::Utils::Sets qw(format_set_name_display); % % # Don't show the save as form when editing an existing course info file. % last if $c->{file_type} eq 'course_info' && -e $c->{editFilePath}; % -% my $isBlank = $c->{file_type} eq 'blank_problem' || $c->{file_type} eq 'sample_problem'; +% my $isTemplate = $c->{file_type} eq 'blank_problem' || $c->{file_type} eq 'sample_problem'; % my $isHardcopyTheme = $c->{file_type} eq 'hardcopy_theme'; -% my $templatesDir = $ce->{courseDirs}{templates}; % my $shortFilePath = - % $isBlank ? 'newProblem.pg' + % $c->{file_type} eq 'blank_problem' ? 'newProblem.pg' + % : $c->{file_type} eq 'sample_problem' ? basename($c->{editFilePath}) % : $isHardcopyTheme ? 'hardcopyThemes/' . ($c->{editFilePath} =~ s|^.*\/||r) - % : $c->{editFilePath} =~ s|^$templatesDir/||r; + % : ($c->{editFilePath} =~ s|^$ce->{courseDirs}{templates}/||r) =~ s|^$ce->{webworkDirs}{assets}/pg/||r; % % # Suggest that modifications be saved to the "local" subdirectory if its not in a writeable directory -% $shortFilePath = "local/$shortFilePath" unless $isBlank || $isHardcopyTheme || -w dirname($c->{editFilePath}); +% $shortFilePath = "local/$shortFilePath" unless $isTemplate || $isHardcopyTheme || -w dirname($c->{editFilePath}); % % # If it is an absolute path make it relative. % $shortFilePath =~ s|^/*|| if $shortFilePath =~ m|^/|; % -% my $probNum = $c->{file_type} eq 'problem' ? $c->{problemID} : 'header'; -% -% # Don't add or replace problems to sets if the set is the Undefined_Set or +% # Don't add to or replace problems in sets or replace set/hardcopy headers if the set is the Undefined_Set or % # if the problem is the blank_problem or a sample problem. -% my $can_add_problem_to_set = not_blank($c->{setID}) && $c->{setID} ne 'Undefined_Set' && !$isBlank; -% -% my $prettyProbNum = $probNum; -% if ($c->{setID}) { - % my $set = $db->getGlobalSet($c->{setID}); - % $prettyProbNum = join('.', jitar_id_to_seq($probNum)) - % if ($c->{file_type} eq 'problem' && $set && $set->assignment_type eq 'jitar'); -% } +% my $fileIsAsociatedWithSet = $c->{setID} && $c->{setID} ne 'Undefined_Set' && !$isTemplate; %
@@ -52,46 +42,80 @@ <%= hidden_field 'action.save_as.source_file' => $c->{editFilePath} =%> <%= hidden_field 'action.save_as.file_type' => $c->{file_type} =%>
-
- % param('copyAuxFiles', 1) unless defined param('copyAuxFiles'); - <%= check_box copyAuxFiles => 1, id => 'copyAuxFiles', class => 'form-check-input' =%> - <%= hidden_field copyAuxFiles => 0 =%> - <%= label_for copyAuxFiles => maketext('Copy auxiliary files.'), class => 'form-check-label' =%> -
- % if ($can_add_problem_to_set) { + % if ($c->{file_type} =~ /problem/) {
- <%= radio_button 'action.save_as.saveMode' => 'rename', id => 'action_save_as_saveMode_rename_id', - checked => undef, class => 'form-check-input' =%> - <%= label_for 'action_save_as_saveMode_rename_id', class => 'form-check-label', begin =%> - <%== maketext('Replace current problem: [_1]', - tag( - 'strong', - c( - tag('span', dir => 'ltr', format_set_name_display($c->{fullSetID})), - "/$prettyProbNum" - )->join('') - ) - ) =%> - <% end =%> + % param('copyAuxFiles', 1) unless defined param('copyAuxFiles'); + <%= check_box copyAuxFiles => 1, id => 'copyAuxFiles', class => 'form-check-input' =%> + <%= hidden_field copyAuxFiles => 0 =%> + <%= label_for copyAuxFiles => maketext('Copy auxiliary files.'), class => 'form-check-label' =%>
+ % if ($fileIsAsociatedWithSet) { +
+ <%= radio_button 'action.save_as.saveMode' => 'rename', id => 'action_save_as_saveMode_rename_id', + checked => undef, class => 'form-check-input' =%> + <%= label_for 'action_save_as_saveMode_rename_id', class => 'form-check-label', begin =%> + <%== maketext('Replace current problem [_1] of set [_2]', + tag('strong', + $c->{set} && $c->{set}->assignment_type eq 'jitar' + ? join('.', jitar_id_to_seq($c->{problemID})) + : $c->{problemID}), + tag('strong', tag('span', dir => 'ltr', format_set_name_display($c->{setID}))) + ) =%> + <% end =%> +
+ % }
<%= radio_button 'action.save_as.saveMode' => 'add_to_set_as_new_problem', id => 'action_save_as_saveMode_new_problem_id', class => 'form-check-input' =%> - <%= label_for 'action_save_as_saveMode_new_problem_id', class => 'form-check-label', begin =%> - <%== maketext( - 'Append to end of [_1] set', - tag('strong', dir => 'ltr', format_set_name_display($c->{fullSetID})) - ) =%> - <% end =%> + <%= label_for action_save_as_saveMode_new_problem_id => maketext('Append to end of'), + class => 'form-check-label' %> + <%= label_for action_save_as_target_set_id => maketext('set:'), class => 'form-check-label' =%> +
+ <%= select_field 'action.save_as.targetSet' => [ + [ maketext('Select a Set') => '', disabled => undef, selected => undef ], + map { + [ format_set_name_display($_) => $_, $_ eq ($c->{setID} // '') ? (selected => undef) : () ] + } @{ $c->{globalSets} } + ], + id => 'action_save_as_target_set_id', class => 'form-select form-select-sm d-inline w-auto', + data => { error_message => maketext('Please select a set.') }, + dir => 'ltr' =%> +
- % } - % if ($c->{is_pg}) {
- <%= radio_button 'action.save_as.saveMode' => 'new_independent_problem', + <%= radio_button 'action.save_as.saveMode' => 'new_independent_file', id => 'action_save_as_saveMode_independent_problem_id', class => 'form-check-input', - $can_add_problem_to_set ? () : (checked => undef) =%> + $fileIsAsociatedWithSet ? () : (checked => undef) =%> <%= label_for action_save_as_saveMode_independent_problem_id => maketext('Create unattached problem'), class => 'form-check-label' =%>
+ % } elsif ($c->{file_type} =~ /header/) { + % my $headerLabel = $c->{file_type} eq 'hardcopy_header' ? maketext('hardcopy header') : maketext('set header'); +
+ <%= radio_button 'action.save_as.saveMode' => 'set_as_heaader_for_set', + id => 'action_save_as_saveMode_set_header_id', class => 'form-check-input', + $fileIsAsociatedWithSet ? (checked => undef) : () =%> + <%= label_for action_save_as_saveMode_set_header_id => maketext('Set as [_1] for', $headerLabel), + class => 'form-check-label' %> + <%= label_for action_save_as_target_set_id => maketext('set:'), class => 'form-check-label' =%> +
+ <%= select_field 'action.save_as.targetSet' => [ + [ maketext('Select a Set') => '', disabled => undef, selected => undef ], + map { + [ format_set_name_display($_) => $_, $_ eq ($c->{setID} // '') ? (selected => undef) : () ] + } @{ $c->{globalSets} } + ], + id => 'action_save_as_target_set_id', class => 'form-select form-select-sm d-inline w-auto', + data => { error_message => maketext('Please select a set.') }, + dir => 'ltr' =%> +
+
+
+ <%= radio_button 'action.save_as.saveMode' => 'new_independent_file', + id => 'action_save_as_saveMode_independent_problem_id', class => 'form-check-input', + $fileIsAsociatedWithSet ? () : (checked => undef) =%> + <%= label_for action_save_as_saveMode_independent_problem_id => maketext('Create unattached header file'), + class => 'form-check-label' =%> +
% }
diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor/view_form.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor/view_form.html.ep index 3af03f728c..af826cb70e 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor/view_form.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor/view_form.html.ep @@ -3,18 +3,21 @@ %
% if ($c->{is_pg}) { -
- <%= label_for action_view_seed_id => maketext('Using what seed?'), class => 'col-form-label col-auto mb-2' =%> -
- <%= text_field 'action.view.seed' => $c->{problemSeed}, - id => 'action_view_seed_id', class => 'form-control form-control-sm' =%> + % if ($c->{file_type} !~ /header/) { +
+ <%= label_for action_view_seed_id => maketext('Using what seed?'), + class => 'col-form-label col-auto mb-2' =%> +
+ <%= text_field 'action.view.seed' => $c->{problemSeed}, + id => 'action_view_seed_id', class => 'form-control form-control-sm' =%> +
+
+ +
-
- -
-
+ % }
<%= label_for action_view_displayMode_id => maketext('Using what display mode?'), class => 'col-form-label col-auto' =%> diff --git a/templates/ContentGenerator/Instructor/ProblemSetDetail.html.ep b/templates/ContentGenerator/Instructor/ProblemSetDetail.html.ep index 5c1991d475..c291ffbe12 100644 --- a/templates/ContentGenerator/Instructor/ProblemSetDetail.html.ep +++ b/templates/ContentGenerator/Instructor/ProblemSetDetail.html.ep @@ -488,7 +488,7 @@ <%= link_to $c->systemLink(url_for( 'instructor_problem_editor_withset_withproblem', - setID => $fullSetID, problemID => $problemID + setID => $setID, problemID => $problemID )), class => 'psd_edit btn btn-secondary btn-sm', target => 'WW_Editor', From ea0bc2a6af7e003984df03e42f5a1aabf58ab093 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 12 Jun 2026 05:41:54 -0500 Subject: [PATCH 2/4] Add validation of the target file name in the "Save As" tab. --- htdocs/js/PGProblemEditor/pgproblemeditor.js | 44 +++++++++++++------ .../Instructor/PGProblemEditor.html.ep | 2 +- .../PGProblemEditor/save_as_form.html.ep | 1 + 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/htdocs/js/PGProblemEditor/pgproblemeditor.js b/htdocs/js/PGProblemEditor/pgproblemeditor.js index 5eb6663b55..70c6be34f6 100644 --- a/htdocs/js/PGProblemEditor/pgproblemeditor.js +++ b/htdocs/js/PGProblemEditor/pgproblemeditor.js @@ -382,6 +382,29 @@ } }); + const removeValidationErrors = (input) => { + input?.classList.remove('is-invalid'); + input.setCustomValidity(''); + }; + + const validateInput = (e, input, isInvalid, defaultMessage, report) => { + if (isInvalid) { + e.preventDefault(); + input.classList.add('is-invalid'); + input.setCustomValidity(input.dataset.errorMessage ?? defaultMessage); + if (report) input.reportValidity(); + return false; + } + removeValidationErrors(input); + return true; + }; + + // Validation of the target file for the save as tab. + const saveAsTargetFile = document.getElementsByName('action.save_as.target_file')?.[0]; + saveAsTargetFile?.addEventListener('keyup', () => { + if (saveAsTargetFile.value) removeValidationErrors(saveAsTargetFile); + }); + // Validation of the target set for the save as tab. const saveAsSaveModeRadios = document.getElementsByName('action.save_as.saveMode'); const saveToTargetSetRadio = Array.from(saveAsSaveModeRadios).find( @@ -389,30 +412,25 @@ ); const targetSetSelect = document.getElementsByName('action.save_as.targetSet')?.[0]; const actionSaveAs = document.getElementById('save_as'); - const removeSetSelectErrors = () => { - targetSetSelect?.classList.remove('is-invalid'); - targetSetSelect?.setCustomValidity(''); - }; for (const radio of saveAsSaveModeRadios) { - radio.addEventListener('change', removeSetSelectErrors); + radio.addEventListener('change', () => removeValidationErrors(targetSetSelect)); } const saveToTargetSetSelected = () => { saveToTargetSetRadio.checked = true; - if (targetSetSelect?.value) removeSetSelectErrors(); + if (targetSetSelect?.value) removeValidationErrors(targetSetSelect); }; targetSetSelect?.addEventListener('change', saveToTargetSetSelected); targetSetSelect?.addEventListener('focusin', saveToTargetSetSelected); document.forms.editor?.addEventListener('submit', (e) => { if (actionSaveAs && actionSaveAs.classList.contains('active')) { - if (saveToTargetSetRadio?.checked && !targetSetSelect?.value) { - e.preventDefault(); - targetSetSelect.classList.add('is-invalid'); - targetSetSelect.setCustomValidity(targetSetSelect.dataset.errorMessage ?? 'Please select a set.'); - targetSetSelect.reportValidity(); - return; + let report = true; + for (const validationData of [ + [saveAsTargetFile, saveAsTargetFile?.value === '', 'Please enter a filename.'], + [targetSetSelect, saveToTargetSetRadio?.checked && !targetSetSelect?.value, 'Please select a set.'] + ]) { + if (!validateInput(e, ...validationData, report)) report = false; } - removeSetSelectErrors(); } }); diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep index 8543dfe25c..5d7f86dbe6 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep @@ -76,7 +76,7 @@ <%= $fileInfo->() %> % <%= form_for current_route, method => 'POST', id => 'editor', name => 'editor', - enctype => 'application/x-www-form-urlencoded', class => 'col-12', begin =%> + enctype => 'application/x-www-form-urlencoded', class => 'col-12', novalidate => undef, begin =%> <%= $c->hidden_authen_fields =%> <%= hidden_field file_type => $c->{file_type} =%> <%= hidden_field courseID => $c->{courseID} =%> diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor/save_as_form.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor/save_as_form.html.ep index db69e9e767..5a3c4144b4 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor/save_as_form.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor/save_as_form.html.ep @@ -34,6 +34,7 @@ <%= text_field 'action.save_as.target_file' => $shortFilePath, id => 'action_save_as_target_file_id', class => 'form-control form-control-sm', size => 60, dir => 'ltr', + data => { error_message => maketext('Please enter a filename.') }, # Don't allow changing the file name for course info files. # The filename needs to be what is set in the course environment. $c->{file_type} eq 'course_info' ? (readonly => undef) : () =%> From c1b551edcc095ad5247541bc2b8cf30c2e59b6ef Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 12 Jun 2026 06:40:58 -0500 Subject: [PATCH 3/4] Rework when the "save" tab is shown, and when the file being edited is considered a template. Basically, any file that is not in the course's templates directory is now considered a template, and for these files the "save" tab is not shown. Note that this uses the `WeBWorK::Utils::Files::path_is_subdir` method, and so files that are linked to from the course's templates directory are not considered templates. Although, if those files are not writable by the server, then the message `The file "[_1]" is protected. You may use "Save As" to create a new file.` will be shown. So OPL or Contrib problems will not be considered templates, but if file permissions are correct on your server, then the message above will be shown. However, if a file is in a location in `$webworkDirs{valid_symlinks}` array, and file permissions are correct on your server, then no message will be shown, and the file can be saved. --- .../Instructor/PGProblemEditor.pm | 17 ++++++----------- .../PGProblemEditor/save_form.html.ep | 8 +++----- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm b/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm index c245731f39..40ffe5b045 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/PGProblemEditor.pm @@ -129,8 +129,6 @@ use constant ACTION_FORM_TITLES => { revert => x('Revert'), }; -my $BLANKPROBLEM = 'newProblem.pg'; - sub pre_header_initialize ($c) { my $ce = $c->ce; my $authz = $c->authz; @@ -247,14 +245,11 @@ sub initialize ($c) { )); } - 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( - 'The file "[_1]" is a template. You may use "Save As" to create a new file.', - $c->shortPath($c->{inputFilePath}) - )); - } + $c->addbadmessage($c->maketext( + 'The file "[_1]" is a template. You may use "Save As" to create a new file.', + $c->shortPath($c->{inputFilePath}) + )) + if !path_is_subdir($c->{editFilePath}, $ce->{courseDirs}{templates}); # Find the text for the editor, either in the temporary file if it exists, in the original file in the template # directory, or in the problem contents gathered in the initialization phase. @@ -1280,7 +1275,7 @@ sub save_as_handler ($c) { setID => $c->{setID}, problemID => $do_not_save ? $c->{problemID} : max($db->listGlobalProblems($c->{setID})) ); - $new_file_type = $file_type; + $new_file_type = 'problem'; } else { $c->addbadmessage($c->maketext( 'Please use radio buttons to choose the method for saving this file. Unknown saveMode: [_1].', $saveMode diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor/save_form.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor/save_form.html.ep index 02e08b7529..5fc90e6ea5 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor/save_form.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor/save_form.html.ep @@ -1,8 +1,6 @@ -% # Can't save blank problems without changing names, and can't save if lacking write permissions. -% last if $c->{file_type} eq 'blank_problem' - % || $c->{file_type} eq 'sample_problem' - % || $c->{editFilePath} =~ /newProblem\.pg$/ - % || !-w $c->{editFilePath}; +% use WeBWorK::Utils::Files qw(path_is_subdir); +% # Can't save files outside of the course templates directory, and can't save if lacking write permissions. +% last if !path_is_subdir($c->{editFilePath}, $ce->{courseDirs}{templates}, 1) || !-w $c->{editFilePath}; %
From 558704e05e00f77ad5a2069ddca221fdff93514c Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 12 Jun 2026 11:13:30 -0500 Subject: [PATCH 4/4] Update the PG problem editor help. --- templates/HelpFiles/InstructorPGProblemEditor.html.ep | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/templates/HelpFiles/InstructorPGProblemEditor.html.ep b/templates/HelpFiles/InstructorPGProblemEditor.html.ep index 553c222bfb..a5b67013c4 100644 --- a/templates/HelpFiles/InstructorPGProblemEditor.html.ep +++ b/templates/HelpFiles/InstructorPGProblemEditor.html.ep @@ -183,8 +183,8 @@

<%= maketext(q{Makes a new copy of the file you are editing at the location relative to the course's } . 'templates (~[TMPL~]) directory. You may choose to replace the current problem in the current set, ' - . 'append to the end to then end of the current set as a new problem, or create a problem that is not ' - . 'attached to a problem set.') =%> + . 'append to the end of an existing set as a new problem, or create a problem that is not attached ' + . 'to a problem set.') =%>

<%= maketext('When saving the problem in a new location (directory), by default all auxiliary files, such ' @@ -196,16 +196,12 @@ . 'for a new problem. You can add the new file to a homework set from the Library Browser or via the ' . 'set detail page of the "Sets Manager".') =%>

-

+

<%= maketext('If the original problem cannot be edited than the path name must be changed in order to be ' . 'allowed to save the problem. Adding "local/" to the beginning of the original path is the default ' . 'solution. All locally created and edited files will then appear in a subdirectory named ' . '"local".') =%>

-

- <%= maketext('A new problem whose path ends in newProblem.pg should be given a new name, for example, ' - . '"myNewProblem.pg".') =%> -

<%= maketext('Append') %>