diff --git a/.gitignore b/.gitignore
index 7ff5de2bd8..3e658cb327 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,4 +58,4 @@ developer.code-workspace
__pycache__
playwright/tests/enterprise
-.vscode/settings.json
+.vscode
diff --git a/docs.openc3.com/docs/guides/scripting-api.md b/docs.openc3.com/docs/guides/scripting-api.md
index f210d51fc0..bf69756524 100644
--- a/docs.openc3.com/docs/guides/scripting-api.md
+++ b/docs.openc3.com/docs/guides/scripting-api.md
@@ -409,6 +409,59 @@ for file in files:
+### open_bucket_dialog
+
+> Since 7.0.0
+
+The open_bucket_dialog method creates a dialog box that allows the user to browse S3 bucket files and select one. It presents the available buckets (similar to Bucket Explorer) and allows navigating directories within the selected bucket. The selected file is downloaded and returned as a file object, similar to open_file_dialog.
+
+
+
+
+```ruby
+open_bucket_dialog("", "")
+```
+
+
+
+
+
+```python
+open_bucket_dialog("", "")
+```
+
+
+
+
+| Parameter | Description |
+| --------- | ------------------------------------------------------------- |
+| Title | The title to put on the dialog. Required. |
+| Message | The message to display in the dialog box. Optional parameter. |
+
+
+
+
+```ruby
+file = open_bucket_dialog("Select a File", "Choose a file from a bucket")
+puts file.filename # The name of the selected file
+puts file.read
+file.delete
+```
+
+
+
+
+
+```python
+file = open_bucket_dialog("Select a File", "Choose a file from a bucket")
+print(file.filename) # The name of the selected file
+print(file.read())
+file.close()
+```
+
+
+
+
## File Manipulation
These methods provide capability to interact with files in the target directory.
diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/file_dialog.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/file_dialog.rb
index 8d2ca6f386..76edf22595 100644
--- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/file_dialog.rb
+++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/file_dialog.rb
@@ -13,3 +13,11 @@
puts file.read
file.delete
end
+
+# Specify the title and message and filter to txt files
+file = open_bucket_dialog("Open a file from the buckets", "Choose something interesting")
+puts file # Ruby File object
+puts file.path # Path of the tempfile (generally not used)
+puts file.filename # Filename that was selected in the dialog
+puts file.read
+file.delete
diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/file_dialog.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/file_dialog.py
index 0398d255f4..70377f30f0 100644
--- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/file_dialog.py
+++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/file_dialog.py
@@ -15,3 +15,12 @@
print(file.filename())
print(file.read())
file.close()
+
+# Specify the title and message
+file = open_bucket_dialog(
+ "Open a file from the buckets", "Choose something interesting"
+)
+print(file) # Python tempfile.NamedTemporaryFile object
+print(file.filename()) # Filename that was selected in the dialog
+print(file.read())
+file.close()
diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-bucketexplorer/src/tools/BucketExplorer/BucketExplorer.vue b/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-bucketexplorer/src/tools/BucketExplorer/BucketExplorer.vue
index 29fa0c11ae..a178c27cef 100644
--- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-bucketexplorer/src/tools/BucketExplorer/BucketExplorer.vue
+++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-bucketexplorer/src/tools/BucketExplorer/BucketExplorer.vue
@@ -65,7 +65,7 @@
+
+
+
+
+
+
+ Bucket Dialog
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+ mdi-bucket
+ {{ bucket }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
+ {{ item.icon }}
+
+ {{ item.name }}
+
+ {{
+ formatSize(item.size)
+ }}
+
+
+
+
+ No files found
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Ok
+
+
+
+
+
+
+
+
+
diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue
index 8f3fafe2d5..8baa661ccd 100644
--- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue
+++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue
@@ -556,6 +556,13 @@
:filter="file.filter"
@response="fileDialogCallback"
/>
+
{},
},
+ bucket: {
+ show: false,
+ title: '',
+ message: '',
+ },
prompt: {
show: false,
title: '',
@@ -2247,6 +2261,7 @@ export default {
this.prompt.show = false
this.ask.show = false
this.file.show = false
+ this.bucket.show = false
return
}
this.activePromptId = data.prompt_id
@@ -2382,6 +2397,11 @@ export default {
}
this.showMetadata()
break
+ case 'open_bucket_dialog':
+ this.bucket.title = data.args[0]
+ this.bucket.message = data.args[1]
+ this.bucket.show = true
+ break
// This is called continuously by the backend
case 'open_file_dialog':
case 'open_files_dialog':
@@ -2444,6 +2464,16 @@ export default {
this.file.show = false // Close the dialog immediately to avoid race condition
})
},
+ bucketDialogCallback(response) {
+ this.bucket.show = false
+ Api.post(`/script-api/running-script/${this.scriptId}/prompt`, {
+ data: {
+ method: 'open_bucket_dialog',
+ answer: response,
+ prompt_id: this.activePromptId,
+ },
+ })
+ },
setError(event) {
this.alertType = 'error'
this.alertText = `Error: ${event}`
diff --git a/openc3-cosmos-script-runner-api/app/controllers/running_script_controller.rb b/openc3-cosmos-script-runner-api/app/controllers/running_script_controller.rb
index 8d7d988135..10ba60ec94 100644
--- a/openc3-cosmos-script-runner-api/app/controllers/running_script_controller.rb
+++ b/openc3-cosmos-script-runner-api/app/controllers/running_script_controller.rb
@@ -173,7 +173,9 @@ def prompt
elsif params[:multiple]
running_script_publish("cmd-running-script-channel:#{params[:id]}", { method: params[:method], multiple: JSON.generate(params[:answer], allow_nan: true), prompt_id: params[:prompt_id] })
else
- running_script_publish("cmd-running-script-channel:#{params[:id]}", { method: params[:method], answer: params[:answer], prompt_id: params[:prompt_id] })
+ # Convert ActionController::Parameters to a plain hash for nested objects (e.g. open_bucket_dialog)
+ answer = params[:answer].respond_to?(:to_unsafe_h) ? params[:answer].to_unsafe_h : params[:answer]
+ running_script_publish("cmd-running-script-channel:#{params[:id]}", { method: params[:method], answer: answer, prompt_id: params[:prompt_id] })
end
OpenC3::Logger.info("Script prompt action #{params[:method]}: #{running_script['filename']}", scope: params[:scope], user: username())
head :ok
diff --git a/openc3-cosmos-script-runner-api/scripts/run_script.py b/openc3-cosmos-script-runner-api/scripts/run_script.py
index b5c209e8c3..9a2a9ffaef 100644
--- a/openc3-cosmos-script-runner-api/scripts/run_script.py
+++ b/openc3-cosmos-script-runner-api/scripts/run_script.py
@@ -153,6 +153,7 @@ def run_script_log(script_id, message, color="BLACK", message_log=True):
| "metadata_input"
| "open_file_dialog"
| "open_files_dialog"
+ | "open_bucket_dialog"
):
if running_script.prompt_id is not None:
if "prompt_id" in parsed_cmd and running_script.prompt_id == parsed_cmd["prompt_id"]:
@@ -164,6 +165,12 @@ def run_script_log(script_id, message, color="BLACK", message_log=True):
script_id,
f"Multiple input: {running_script.user_input}",
)
+ elif parsed_cmd["method"] == "open_bucket_dialog":
+ answer = parsed_cmd["answer"]
+ if isinstance(answer, str):
+ answer = json.loads(answer)
+ running_script.user_input = answer
+ run_script_log(script_id, f"Bucket file: {running_script.user_input}")
elif "open_file" in parsed_cmd["method"]:
running_script.user_input = parsed_cmd["answer"]
run_script_log(script_id, f"File(s): {running_script.user_input}")
diff --git a/openc3-cosmos-script-runner-api/scripts/run_script.rb b/openc3-cosmos-script-runner-api/scripts/run_script.rb
index 12b192eda1..268ee001e6 100644
--- a/openc3-cosmos-script-runner-api/scripts/run_script.rb
+++ b/openc3-cosmos-script-runner-api/scripts/run_script.rb
@@ -106,7 +106,7 @@ def run_script_log(id, message, color = 'BLACK', message_log = true)
case parsed_cmd["method"]
# This list matches the list in running_script.rb:44
when "ask", "ask_string", "message_box", "vertical_message_box", "combo_box", "prompt", "prompt_for_hazardous",
- "prompt_for_critical_cmd", "metadata_input", "open_file_dialog", "open_files_dialog"
+ "prompt_for_critical_cmd", "metadata_input", "open_file_dialog", "open_files_dialog", "open_bucket_dialog"
unless running_script.prompt_id.nil?
if running_script.prompt_id == parsed_cmd["prompt_id"]
if parsed_cmd["password"]
@@ -114,6 +114,9 @@ def run_script_log(id, message, color = 'BLACK', message_log = true)
elsif parsed_cmd["multiple"]
running_script.user_input = JSON.parse(parsed_cmd["multiple"])
run_script_log(id, "Multiple input: #{running_script.user_input}")
+ elsif parsed_cmd["method"] == 'open_bucket_dialog'
+ running_script.user_input = parsed_cmd["answer"]
+ run_script_log(id, "Bucket file: #{running_script.user_input}")
elsif parsed_cmd["method"].include?('open_file')
running_script.user_input = parsed_cmd["answer"]
run_script_log(id, "File(s): #{running_script.user_input}")
diff --git a/openc3/lib/openc3/script/script.rb b/openc3/lib/openc3/script/script.rb
index 2168f0c801..8e5f7f41c5 100644
--- a/openc3/lib/openc3/script/script.rb
+++ b/openc3/lib/openc3/script/script.rb
@@ -199,6 +199,16 @@ def open_files_dialog(title, message = "Open File(s)", filter:)
_file_dialog(title, message, filter)
end
+ def open_bucket_dialog(title, message = "Open Bucket File")
+ answer = ''
+ while answer.empty?
+ print "#{title}\n#{message}\n:"
+ answer = gets
+ answer.chomp!
+ end
+ return answer
+ end
+
def prompt(string, text_color: nil, background_color: nil, font_size: nil, font_family: nil, details: nil)
print "#{string}: "
print "Details: #{details}\n" if details
diff --git a/openc3/lib/openc3/script/storage.rb b/openc3/lib/openc3/script/storage.rb
index 8c9a363402..55d05fe339 100644
--- a/openc3/lib/openc3/script/storage.rb
+++ b/openc3/lib/openc3/script/storage.rb
@@ -141,13 +141,13 @@ def _get_download_url(path, scope: $openc3_scope)
return result['url']
end
- def _get_storage_file(path, scope: $openc3_scope)
+ def _get_storage_file(path, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
# Create Tempfile to store data
file = Tempfile.new('target', binmode: true)
file.filename = path
endpoint = "/openc3-api/storage/download/#{scope}/#{path}"
- result = _get_presigned_request(endpoint, scope: scope)
+ result = _get_presigned_request(endpoint, bucket: bucket, scope: scope)
puts "Reading #{scope}/#{path}"
# Try to get the file
@@ -186,11 +186,11 @@ def _get_uri(url)
end
end
- def _get_presigned_request(endpoint, external: nil, scope: $openc3_scope)
+ def _get_presigned_request(endpoint, external: nil, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
if external or !$openc3_in_cluster
- response = $api_server.request('get', endpoint, query: { bucket: 'OPENC3_CONFIG_BUCKET' }, scope: scope)
+ response = $api_server.request('get', endpoint, query: { bucket: bucket }, scope: scope)
else
- response = $api_server.request('get', endpoint, query: { bucket: 'OPENC3_CONFIG_BUCKET', internal: true }, scope: scope)
+ response = $api_server.request('get', endpoint, query: { bucket: bucket, internal: true }, scope: scope)
end
if response.nil? || response.status != 201
raise "Failed to get presigned URL for #{endpoint}"
diff --git a/openc3/lib/openc3/utilities/running_script.rb b/openc3/lib/openc3/utilities/running_script.rb
index 1fa505f5bf..9a219f7760 100644
--- a/openc3/lib/openc3/utilities/running_script.rb
+++ b/openc3/lib/openc3/utilities/running_script.rb
@@ -61,7 +61,7 @@ module Script
# Define all the user input methods used in scripting which we need to broadcast to the frontend
# Note: This list matches the list in run_script.rb:116
SCRIPT_METHODS = %i[ask ask_string message_box vertical_message_box combo_box prompt prompt_for_hazardous
- prompt_for_critical_cmd metadata_input open_file_dialog open_files_dialog]
+ prompt_for_critical_cmd metadata_input open_file_dialog open_files_dialog open_bucket_dialog]
SCRIPT_METHODS.each do |method|
define_method(method) do |*args, **kwargs|
while true
@@ -75,7 +75,18 @@ module Script
if input == 'Cancel'
RunningScript.instance.perform_pause
else
- if (method.to_s.include?('open_file'))
+ if method.to_s == 'open_bucket_dialog'
+ bucket = input['bucket']
+ path = input['path']
+ filename = input['filename']
+ # Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...)
+ # _get_storage_file prepends scope, so strip it from the path to avoid doubling
+ scope = RunningScript.instance.scope
+ path = path.sub(/\A#{Regexp.escape(scope)}\//, '')
+ file = _get_storage_file(path, bucket: bucket, scope: scope)
+ file.filename = filename
+ return file
+ elsif (method.to_s.include?('open_file'))
files = input.map do |filename|
file = _get_storage_file("tmp/#{filename}", scope: RunningScript.instance.scope)
# Set filename method we added to Tempfile in the core_ext
diff --git a/openc3/python/openc3/script/__init__.py b/openc3/python/openc3/script/__init__.py
index c40ed1a7f5..78b1001758 100644
--- a/openc3/python/openc3/script/__init__.py
+++ b/openc3/python/openc3/script/__init__.py
@@ -112,6 +112,14 @@ def open_files_dialog(title, message="Open File", filter=None):
_file_dialog(title, message, filter)
+def open_bucket_dialog(title, message="Open Bucket File"):
+ answer = ""
+ while len(answer) == 0:
+ print(f"{title}\n{message}\n:")
+ answer = input()
+ return answer
+
+
def prompt(
string,
text_color=None,
diff --git a/openc3/python/openc3/script/storage.py b/openc3/python/openc3/script/storage.py
index 6601746c57..0075973bbf 100644
--- a/openc3/python/openc3/script/storage.py
+++ b/openc3/python/openc3/script/storage.py
@@ -161,12 +161,12 @@ def _get_download_url(path: str, scope: str = OPENC3_SCOPE):
return result["url"]
-def _get_storage_file(path, scope=OPENC3_SCOPE):
+def _get_storage_file(path, bucket="OPENC3_CONFIG_BUCKET", scope=OPENC3_SCOPE):
# Create Tempfile to store data
file = tempfile.NamedTemporaryFile(mode="w+b") # noqa: SIM115 - returned to caller
endpoint = f"/openc3-api/storage/download/{scope}/{path}"
- result = _get_presigned_request(endpoint, scope=scope)
+ result = _get_presigned_request(endpoint, bucket=bucket, scope=scope)
print(f"Reading {scope}/{path}")
# Try to get the file
@@ -196,16 +196,14 @@ def _get_uri(url):
return f"{openc3.script.API_SERVER.generate_url()}{url}"
-def _get_presigned_request(endpoint, external=None, scope=OPENC3_SCOPE):
+def _get_presigned_request(endpoint, external=None, bucket="OPENC3_CONFIG_BUCKET", scope=OPENC3_SCOPE):
if external or not openc3.script.OPENC3_IN_CLUSTER:
- response = openc3.script.API_SERVER.request(
- "get", endpoint, query={"bucket": "OPENC3_CONFIG_BUCKET"}, scope=scope
- )
+ response = openc3.script.API_SERVER.request("get", endpoint, query={"bucket": bucket}, scope=scope)
else:
response = openc3.script.API_SERVER.request(
"get",
endpoint,
- query={"bucket": "OPENC3_CONFIG_BUCKET", "internal": True},
+ query={"bucket": bucket, "internal": True},
scope=scope,
)
diff --git a/openc3/python/openc3/utilities/running_script.py b/openc3/python/openc3/utilities/running_script.py
index 2a171d9461..2f61bc36ce 100644
--- a/openc3/python/openc3/utilities/running_script.py
+++ b/openc3/python/openc3/utilities/running_script.py
@@ -138,6 +138,7 @@ def _openc3_script_sleep(sleep_time=None):
"metadata_input",
"open_file_dialog",
"open_files_dialog",
+ "open_bucket_dialog",
]
@@ -156,7 +157,24 @@ def running_script_method(method, *args, **kwargs):
if user_input == "Cancel":
RunningScript.instance.perform_pause()
else:
- if "open_file" in method:
+ if method == "open_bucket_dialog":
+ bucket = user_input["bucket"]
+ path = user_input["path"]
+ the_filename = user_input["filename"]
+ # Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...)
+ # _get_storage_file prepends scope, so strip it from the path to avoid doubling
+ scope = RunningScript.instance.scope()
+ if path.startswith(f"{scope}/"):
+ path = path[len(scope) + 1 :]
+ file = _get_storage_file(path, bucket=bucket, scope=scope)
+ file._filename = the_filename
+
+ def filename(self):
+ return self._filename
+
+ type(file).filename = filename
+ return file
+ elif "open_file" in method:
files = []
for the_filename in user_input:
file = _get_storage_file(f"tmp/{the_filename}", scope=RunningScript.instance.scope())
diff --git a/playwright/tests/script-runner/prompts.p.spec.ts b/playwright/tests/script-runner/prompts.p.spec.ts
index 289d43788a..3e45274a15 100644
--- a/playwright/tests/script-runner/prompts.p.spec.ts
+++ b/playwright/tests/script-runner/prompts.p.spec.ts
@@ -282,11 +282,34 @@ test('opens a dialog for prompt', async ({ page, utils }) => {
await expect(page.locator('[data-test=state] input')).toHaveValue('completed')
})
-test('opens a file dialog', async ({ page, utils }) => {
- await page.locator('textarea')
- .fill(`file = open_file_dialog("Open a single file", "Choose something interesting")
-puts file.read
-file.delete`)
+async function openFile(page, utils, filename) {
+ await page.locator('[data-test=script-runner-file]').click()
+ await page.locator('text=Open File').click()
+ await utils.sleep(500) // Allow background data to fetch
+ await expect(
+ page.locator('.v-dialog').getByText('INST2', { exact: true }),
+ ).toBeVisible()
+ let parts = filename.split('.')
+ await page.locator('[data-test=file-open-save-search] input').fill(parts[0])
+ await utils.sleep(100)
+ await page
+ .locator('[data-test=file-open-save-search] input')
+ .fill(`.${parts[1]}`)
+ await page.locator(`text=${filename}`).click()
+ await page.locator('[data-test=file-open-save-submit-btn]').click()
+ await expect(page.locator('.v-dialog')).not.toBeVisible()
+
+ // Check for potential " is editing this script"
+ // This can happen if we had to do a retry on this test
+ const someone = page.getByText('is editing this script')
+ if (await someone.isVisible()) {
+ await page.locator('[data-test="unlock-button"]').click()
+ await page.locator('[data-test="confirm-dialog-force unlock"]').click()
+ }
+}
+
+async function runScript(page, utils, filename) {
+ await openFile(page, utils, filename)
await page.locator('[data-test=start-button]').click()
await expect(page.locator('.v-dialog')).toBeVisible({
timeout: 20000,
@@ -314,13 +337,76 @@ file.delete`)
// Open the file chooser
page.getByLabel('Choose File').first().click(),
])
- await fileChooser.setFiles('package.json')
+ await fileChooser.setFiles('.prettierrc.js')
+ await page.locator('.v-dialog >> button:has-text("Ok")').click()
+ await utils.sleep(500)
+
+ await expect(page.locator('.v-dialog')).toBeVisible()
+ await expect(page.locator('.v-dialog')).toContainText('Open multiple files')
+ // Note that Promise.all prevents a race condition
+ // between clicking and waiting for the file chooser.
+ const [fileChooser2] = await Promise.all([
+ // It is important to call waitForEvent before click to set up waiting.
+ page.waitForEvent('filechooser'),
+ // Open the file chooser
+ page.getByLabel('Choose File').first().click(),
+ ])
+ await fileChooser2.setFiles(['pnpm-workspace.yaml', 'reset_storage_state.sh'])
await page.locator('.v-dialog >> button:has-text("Ok")').click()
+ await utils.sleep(500)
+
+ await expect(page.locator('.v-dialog')).toBeVisible()
+ await expect(page.locator('.v-dialog')).toContainText(
+ 'Open a file from the buckets',
+ )
+ await page.getByText('config', { exact: true }).click()
+ await page
+ .locator('[data-test="bucket-item-DEFAULT"]')
+ .getByText('DEFAULT')
+ .click()
+ await page
+ .locator('[data-test="bucket-item-targets"]')
+ .getByText('targets')
+ .click()
+ await page
+ .locator('[data-test="bucket-item-INST2"]')
+ .getByText('INST2')
+ .click()
+ await page.getByText('target.txt').click()
+ await page.locator('[data-test="bucket-ok"]').click()
+
await expect(page.locator('[data-test=state] input')).toHaveValue('completed')
await expect(page.locator('[data-test=output-messages]')).toContainText(
- 'File(s): ["package.json"]',
+ /File\(s\): \[['"].prettierrc.js['"]\]/,
+ )
+ // Verify something from .prettierrc.js
+ await expect(page.locator('[data-test=output-messages]')).toContainText(
+ 'bracketSpacing: true',
+ )
+ await expect(page.locator('[data-test=output-messages]')).toContainText(
+ /File\(s\): \[['"]pnpm-workspace.yaml['"], ['"]reset_storage_state.sh['"]\]/,
)
+ // Verify something from pnpm-workspace.yaml
await expect(page.locator('[data-test=output-messages]')).toContainText(
- 'devDependencies',
+ 'nodeLinker: hoisted',
)
+ // Verify something from reset_storage_state.sh
+ await expect(page.locator('[data-test=output-messages]')).toContainText(
+ 'Initialize an empty storageState',
+ )
+ await expect(page.locator('[data-test=output-messages]')).toContainText(
+ 'Reading DEFAULT/targets/INST2/target.txt',
+ )
+ // Verify something from INST2/target.txt
+ await expect(page.locator('[data-test=output-messages]')).toContainText(
+ 'TELEMETRY inst_tlm_override.txt',
+ )
+}
+
+test('test ruby prompts', async ({ page, utils }) => {
+ await runScript(page, utils, 'file_dialog.rb')
+})
+
+test('test python prompts', async ({ page, utils }) => {
+ await runScript(page, utils, 'file_dialog.py')
})