diff --git a/.gitignore b/.gitignore
index cc53b3f6..e85a4c8b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,7 +7,14 @@ Gemfile.lock
vendor/
.bundle
TODO_ja.md
+
+# Site-local runtime files. conf.yml.erb is this deployment's configuration,
+# apps/ holds its applications (apps_dir), and tmp/restart.txt is the Passenger
+# restart trigger. All three are created for the local Docker OnDemand
+# environment by ../docker_open_ondemand/bootstrap.sh; see its README.
conf.yml.erb
+apps/
+tmp/
# Regenerated from docs by misc/tests/run_tests.rb
misc/tests/samples/
diff --git a/docs/application.html b/docs/application.html
index 171a9dae..685ab205 100644
--- a/docs/application.html
+++ b/docs/application.html
@@ -36,7 +36,7 @@
#{OC_JOB_NAME}: Job Name defined in the header section
-
5.5. Disable warning
+
5.5. Loading a script back into the form
- If you try to modify a widget that affects the script or submit section after manually editing those sections, a warning like the one below will appear.
- If you click "Discard and continue", the changes you made to the section will be discarded and a new script will be generated.
+ If you change any options in the form or header, this changes what is written in the contents script. Likewise, if you change what is written in the contents script, it will be updated if possible in the form or header.
-
- To disable this warning, set overwrite_warning: false in the configuration of the section where you want to disable the warning.
+ Three rules govern whether a line can be read back:
+
+
+
It must start with literal text. A line beginning with an interpolation, such as #{command} -n 4, is never matched.
+
That leading text must be unique. Each pattern takes the first script line it matches, so two template lines sharing a prefix will both read the same one.
+
It must not use calc(), dirname() or basename(): those lose information and cannot be reversed. Such a line is still patched in place, but no field is attached to it. zeropadding() is the exception — see the table below.
+
+
+ #SBATCH --time= is handled separately. It is decoded by a dedicated parser accepting every Slurm format (D-HH:MM:SS, D-HH:MM, D-HH, HH:MM:SS, MM:SS, MM), which fills the line's fields with days, hours, minutes and seconds — so they must appear in the template in that order.
+
+
+ The captured text is applied according to the widget:
+
+
+
+
Widget
Read back from the script
+
+
+
number, text, email, path
Written straight into the input.
+
select, radio
The option whose value (the second element of its options entry) equals the text is selected.
+
checkbox
Split on the widget's separator, a comma by default. Matching options are ticked and the rest unticked, so deleting a value from the line clears its box.
+
multi_select
Not applied. The badges keep what they held, and an edit to the line is rebuilt from them on the next widget change.
+
+
+
+ Of the template functions, only zeropadding() can be read back:
+
+
+
+
Function
Read back from the script
+
+
+
zeropadding(field, N)
The padded digits are matched and the padding stripped, so run-0125.log puts 125 in the field. It only pads, so nothing is lost.
+
calc()
Not applied. A result has no single set of inputs: 28 could be any factor pair.
+
dirname(), basename()
Not applied. Each discards half the path, so the field's value cannot be rebuilt.
+
zeropadding(calc(...), N)
Not applied. The nested calc() is still not reversible.
+
+
+
+ A zeropadding() line is only read back when every one of its interpolations is either plain or a bare zeropadding(field, N), and when the interpolations are separated by literal text. Two captures written side by side, as in #{zeropadding(h, 2)}#{zeropadding(m, 2)}, cannot be split apart again, so such a line is patched but not parsed.
+
+
+ Loading a script may also open a hidden section: if a matched field is disabled, the enable- actions controlling it are fired so the section appears with the value already in it. This happens only when no other field could have written the same line. If two fields both write lines starting #$ -o , for instance, a loaded #$ -o out.log could have come from either, so its value is filled in but no section is opened.
-
script:
- overwrite_warning: false
- content: |
-
-
submit:
- overwrite_warning: false
- content: |
6. Check section
Variables defined in the form section can be validated in the check section using the Ruby language and the function oc_assert(condition, message).
diff --git a/docs/application_ja.html b/docs/application_ja.html
index c9084673..eab10c45 100644
--- a/docs/application_ja.html
+++ b/docs/application_ja.html
@@ -36,7 +36,7 @@
The "Script location," "Script name," and "Job name" in the header section specify the "Directory where the job script is stored," "Name of the job script file," and "Job name," respectively.
The "Cluster name" in the header section is only displayed if multiple job schedulers are configured. The job script will be submitted to the selected cluster.
An asterisk next to the label of a web form indicates that it is a required field.
-
If you try to modify a web form with a white background after manually editing the job script, a warning like the one shown below will appear.
- If you click "Discard and continue", the changes made in the job script will be discarded and a new script will be generated.
-
-
-
+
A web form with a white background writes to the job script, and each such form owns one line of it.
+ Changing a form rewrites only that line, so any edits you have made elsewhere in the job script are kept.
+
+
The reverse also holds: when you edit the job script by hand, the web forms update to match it about half a second after you stop typing.
+ Editing a line that no form owns simply leaves the forms alone.
If the background color of the web form is yellow, it indicates that neither the job script nor the script that performs processing before job submission will be modified.
-
If the background color of the web form is pink, it indicates that only the script that performs processing before job submission will be modified. If you try to modify these web forms after manually editing that script, the same warning described above will appear.
+
If the background color of the web form is pink, it indicates that only the script that performs processing before job submission will be modified.
diff --git a/lib/form.rb b/lib/form.rb
index 903f2407..59bdc807 100644
--- a/lib/form.rb
+++ b/lib/form.rb
@@ -145,8 +145,7 @@ def output_number_text_email_html(key, value, script_content, submit_content, ap
'submit'
end
if type
- html << "onfocus=\"ocForm.storePreviousValue('#{id}')\" " \
- "oninput=\"ocForm.confirmOverwrite('#{type}', '#{id}', function(){ocForm.updateArea('#{type}', '#{id}');})\""
+ html << "oninput=\"ocForm.updateArea('#{type}', '#{id}')\""
html << " style=\"background-color: #{@conf["submit_color"]};\"" if type == 'submit'
else
html << "style=\"background-color: #{@conf["non_script_color"]};\""
@@ -228,13 +227,32 @@ def escape_js_string(str)
end
# Output a JavaScript code based on a given yml, line in script, and matches data.
+ # Returns [show_js, pattern_js]:
+ # show_js - pushes the rendered line into selectedValues (script generation),
+ # pattern_js - registers the line in ocForm.scriptLinePatterns so form.js can
+ # patch just this line in place and parse it back into the widgets.
def output_script_js(form, line, app_name, dir_name)
line = normalize_interpolation(line)
line = substitute_oc_constants(line, app_name, dir_name)
+ raw_line = line.dup
line = escape_js_string(line)
matches = line.scan(/\#\{.+?\}/)
- return " selectedValues.push(\'#{line}\');\n" if matches.empty?
+ if matches.empty?
+ # A literal line has no widget behind it, but it still needs a pattern so
+ # patchScript() recognises it as template-owned rather than user-typed.
+ # literal:true marks it as never regenerated: since no widget can alter
+ # it, a difference from the template means the user edited it, and
+ # patchScript keeps their version. This must NOT be set on the
+ # prefix-only patterns emitted below for calc()/zeropadding() lines,
+ # which do change when their widgets change.
+ pattern_js = ""
+ unless raw_line.empty?
+ prefix_js = escape_js_string(raw_line)
+ pattern_js = " ocForm.scriptLinePatterns.push({prefix:'#{prefix_js}', regex:null, keys:[], widgets:[], separators:[], canHide:[], literal:true});\n"
+ end
+ return [" selectedValues.push(\'#{line}\');\n", pattern_js]
+ end
keys = matches.flat_map do |str|
inner = str[2..-2] # "#{time_1}" -> "time_1"
@@ -317,9 +335,85 @@ def output_script_js(form, line, app_name, dir_name)
can_hide_array = "[" + can_hide.map { |r| r }.join(", ") + "]"
separators_array = "[" + separators.map { |s| s.nil? ? 'null' : "'#{s}'" }.join(", ") + "]"
- return " ocForm.showLine(selectedValues, '#{line}', #{keys_array}, #{widgets_array}, #{can_hide_array}, #{separators_array});\n"
+ show_js = " ocForm.showLine(selectedValues, '#{line}', #{keys_array}, #{widgets_array}, #{can_hide_array}, #{separators_array});\n"
+
+ # The prefix (text before the first #{...}) identifies this line in the
+ # script textarea. The regex additionally captures each widget's value so
+ # the line can be read back into the form.
+ has_complex = raw_line.match?(/\#\{(calc|zeropadding|dirname|basename)\(/)
+ raw_parts = raw_line.split(/\#\{[^}]+\}/, -1)
+ prefix = raw_parts[0]
+ # The interpolation expressions in order, e.g. ["days", "zeropadding(hours, 2)"].
+ # Split and scan use the same [^}]+, so interps[i] is the expression that
+ # produced the capture between raw_parts[i] and raw_parts[i + 1].
+ interps = raw_line.scan(/\#\{([^}]+)\}/).flatten
+
+ # zeropadding(field, N) is the one function that can be inverted: it only
+ # pads, so the original value is recovered by matching digits. calc()
+ # loses its inputs, dirname()/basename() discard half the path, and a
+ # nested zeropadding(calc(...), N) inherits calc()'s problem. A line using
+ # only bare zeropadding() is therefore still parseable.
+ #
+ # exist_keys must also line up 1:1 with the captures — each bare
+ # zeropadding() contributes exactly one key, but a key naming a widget
+ # that does not exist is dropped above, which would shift every later
+ # capture onto the wrong field.
+ #
+ # Two captures with no literal text between them also cannot be split:
+ # "(\d+)(\d+)" against "0230" is ambiguous, and the first capture would
+ # greedily swallow the second field's padding. Such a line is left
+ # patch-only rather than parsed wrongly.
+ adjacent_captures = (1...(raw_parts.length - 1)).any? { |i| raw_parts[i].empty? }
+
+ zeropad_only = has_complex &&
+ !adjacent_captures &&
+ exist_keys.length == interps.length &&
+ interps.all? do |e|
+ e !~ /\A(?:calc|zeropadding|dirname|basename)\(/ ||
+ e =~ /\Azeropadding\(\s*:?[A-Za-z_]\w*\s*,\s*\d+\s*\)\z/
+ end
+
+ pattern_js = ""
+ unless prefix.empty?
+ prefix_js = escape_js_string(prefix)
+
+ if has_complex && raw_line.lstrip.start_with?("#SBATCH --time=")
+ # Keeps precedence over zeropad inversion: the dedicated parser accepts
+ # every Slurm --time format, which a regex built from one template line
+ # could not (a hand-typed "--time=60" would simply fail to match).
+ pattern_js = " ocForm.scriptLinePatterns.push({prefix:'#{prefix_js}', regex:null, keys:#{keys_array}, widgets:#{widgets_array}, separators:#{separators_array}, canHide:#{can_hide_array}, parseType:'slurm_time'});\n"
+ elsif has_complex && !zeropad_only
+ # A computed value cannot be inverted, so only the prefix is registered
+ # — enough to patch the line, not to parse it.
+ pattern_js = " ocForm.scriptLinePatterns.push({prefix:'#{prefix_js}', regex:null, keys:[], widgets:[], separators:[], canHide:[]});\n"
+ else
+ zero_pad = []
+ regex_parts = []
+ raw_parts.each_with_index do |part, i|
+ regex_parts << Regexp.escape(part)
+ next unless i < raw_parts.length - 1
+
+ if zeropad_only && interps[i] =~ /\Azeropadding\(/
+ # A padded field is always digits, so match digits rather than
+ # anything — this also stops the capture swallowing the padding
+ # of a neighbouring field when two sit next to each other.
+ regex_parts << "(\\d+)"
+ zero_pad << "true"
+ else
+ # The last capture is greedy so a trailing value keeps any spaces.
+ regex_parts << (i < raw_parts.length - 2 ? "(.*?)" : "(.*)")
+ zero_pad << "false"
+ end
+ end
+ regex_str = ("^" + regex_parts.join("") + "$").gsub("/", "\\/")
+ extra = zero_pad.include?("true") ? ", zeroPad:[#{zero_pad.join(', ')}]" : ""
+ pattern_js = " ocForm.scriptLinePatterns.push({prefix:'#{prefix_js}', regex:/#{regex_str}/, keys:#{keys_array}, widgets:#{widgets_array}, separators:#{separators_array}, canHide:#{can_hide_array}#{extra}});\n"
+ end
+ end
+
+ return [show_js, pattern_js]
else
- return " selectedValues.push('#{line}');\n"
+ return [" selectedValues.push('#{line}');\n", ""]
end
end
@@ -339,8 +433,7 @@ def output_select_html(key, value, script_content, submit_content, app_name, dir
'submit'
end
if type
- html << "onfocus=\"ocForm.storePreviousValue('#{key}')\" " \
- "onchange=\"ocForm.confirmOverwrite('#{type}', '#{key}', function(){ocForm.updateArea('#{type}', '#{key}');})\""
+ html << "onchange=\"ocForm.updateArea('#{type}', '#{key}')\""
html << " style=\"background-color: #{@conf["submit_color"]};\"" if type == 'submit'
else
html << "onchange=\"ocForm.execDynamicWidget('#{key}')\" " \
@@ -460,7 +553,7 @@ def output_radio_html(key, value, script_content, submit_content, app_name, dir_
'submit'
end
if type
- html << "onchange=\"ocForm.confirmOverwrite('#{type}', '#{id}', function(){ocForm.updateArea('#{type}', '#{id}')})\" oninput=\"ocForm.storePreviousValue('#{id}')\""
+ html << "onchange=\"ocForm.updateArea('#{type}', '#{id}')\""
html << " style=\"background-color: #{@conf["submit_button_color"]};\"" if type == 'submit'
html << ">\n"
else
@@ -510,7 +603,7 @@ def output_checkbox_html(key, value, script_content, submit_content, app_name, d
'submit'
end
if type
- html << "onchange=\"ocForm.confirmOverwrite('#{type}', '#{id}', function(){ocForm.updateArea('#{type}', '#{id}')})\""
+ html << "onchange=\"ocForm.updateArea('#{type}', '#{id}')\""
html << " style=\"background-color: #{@conf["submit_button_color"]};\"" if type == 'submit'
html << ">\n"
else
@@ -532,6 +625,24 @@ def output_checkbox_js(key, value)
return !value['required'].is_a?(Array) && value['required'].to_s == "true" ? " ocForm.validateCheckboxForSubmit('#{key}');" : ""
end
+ # Generate JS that populates ocForm.enabledBy: maps each field key to the checkbox
+ # option IDs that enable it. Used by parseScriptToWidgets() to reopen a collapsed
+ # section (e.g. "Show advanced options") when a loaded script sets a field inside it.
+ def output_enabled_by_js(key, options)
+ js = ""
+ return js if options.nil?
+ options.each_with_index do |option, i|
+ next unless option.is_a?(Array)
+ (option[2..-1] || []).each do |action|
+ next unless action.is_a?(String) && action.start_with?("enable-")
+ target = action.sub(/^enable-/, '')
+ enabler_id = "#{key}_#{i + 1}"
+ js += " (ocForm.enabledBy[#{target.to_json}] = ocForm.enabledBy[#{target.to_json}] || []).push(#{enabler_id.to_json});\n"
+ end
+ end
+ js
+ end
+
# Output a path widget.
def output_path_html(key, value, script_content, submit_content, app_name, dir_name)
favorites = value['favorites'] ? value['favorites'].select { |path| File.exist?(path) } : []
@@ -554,19 +665,14 @@ def output_path_html(key, value, script_content, submit_content, app_name, dir_n
'submit'
end
if type
- html += "oninput=\"ocForm.confirmOverwrite('#{type}', '#{key}', function(){ocForm.updateArea('#{type}', '#{key}')})\" "
- html += "onfocus=\"ocForm.storePreviousValue('#{key}')\""
+ html += "oninput=\"ocForm.updateArea('#{type}', '#{key}')\""
html += " style=\"background-color: #{@conf["submit_color"]};\"" if type == 'submit'
else
html += "style=\"background-color: #{@conf["non_script_color"]};\""
end
html += ">\n"
html += "\n"
- else
- html += "onclick=\"ocForm.loadFiles('#{@script_name}', '#{current_path}', '#{key}', #{show_files}, '#{Dir.home}', true)\">Select Path\n"
- end
+ html += "onclick=\"ocForm.loadFiles('#{@script_name}', '#{current_path}', '#{key}', #{show_files}, '#{Dir.home}', true)\">Select Path\n"
html += <<~HTML
@@ -638,7 +744,7 @@ def output_path_html(key, value, script_content, submit_content, app_name, dir_n
HTML
html += "