OpenC3/cosmos

View on GitHub
openc3-cosmos-init/plugins/packages/openc3-tool-common/src/components/FileOpenSaveDialog.vue

Summary

Maintainability
Test Coverage
<!--
# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# Modified by OpenC3, Inc.
# All changes Copyright 2022, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.
-->

<template>
  <v-dialog v-model="show" width="600" scrollable @keydown.enter="success()">
    <v-card>
      <v-overlay :value="loading">
        <v-progress-circular
          indeterminate
          absolute
          size="64"
        ></v-progress-circular>
      </v-overlay>
      <form v-on:submit.prevent="success">
        <v-system-bar>
          <v-spacer />
          <span> {{ title }} </span>
          <v-spacer />
        </v-system-bar>
        <v-card-text>
          <div class="pa-3">
            <v-row>{{ helpText }} </v-row>
            <v-row dense class="mt-5">
              <v-text-field
                @input="handleSearch"
                v-model="search"
                flat
                autofocus
                solo-inverted
                hide-details
                clearable
                label="Search"
                prepend-inner-icon="mdi-magnify"
                outlined
                dense
                data-test="file-open-save-search"
              />
            </v-row>
            <v-row dense class="mt-2">
              <v-treeview
                v-model="tree"
                @update:active="activeFile"
                dense
                activatable
                return-object
                ref="tree"
                style="width: 100%; max-height: 60vh; overflow: auto"
                :items="items"
                :search="search"
                :open-on-click="type === 'open'"
              >
                <template v-slot:prepend="{ item, open }">
                  <v-icon v-if="!item.file">
                    {{ open ? 'mdi-folder-open' : 'mdi-folder' }}
                  </v-icon>
                  <v-icon v-else> {{ calcIcon(item.name) }} </v-icon>
                </template>
                <template v-slot:append="{ item }">
                  <!-- See ScriptRunner.vue const TEMP_FOLDER -->
                  <v-btn
                    v-if="item.name === '__TEMP__'"
                    icon
                    @click="deleteTemp"
                  >
                    <v-icon> mdi-delete </v-icon>
                  </v-btn>
                </template>
              </v-treeview>
            </v-row>
            <v-row class="my-2">
              <v-text-field
                v-model="selectedFile"
                hide-details
                label="Filename"
                data-test="file-open-save-filename"
                :disabled="type === 'open'"
              />
            </v-row>
            <v-row dense>
              <div
                class="my-2 red--text"
                style="white-space: pre-line"
                v-show="error"
              >
                {{ error }}
              </div>
            </v-row>
            <v-row class="mt-2">
              <v-spacer />
              <v-btn
                @click="show = false"
                outlined
                class="mx-2"
                data-test="file-open-save-cancel-btn"
                :disabled="disableButtons"
              >
                Cancel
              </v-btn>
              <v-btn
                @click.prevent="success"
                type="submit"
                color="primary"
                class="mx-2"
                data-test="file-open-save-submit-btn"
                :disabled="disableButtons || !!error"
              >
                {{ submit }}
              </v-btn>
            </v-row>
          </div>
        </v-card-text>
      </form>
    </v-card>
  </v-dialog>
</template>

<script>
import Api from '../services/api'
import { fileIcon } from '../tools/base/util/fileIcon'

export default {
  props: {
    type: {
      type: String,
      required: true,
      validator: function (value) {
        // The value must match one of these strings
        return ['open', 'save'].indexOf(value) !== -1
      },
    },
    apiUrl: String, // Base API URL for use with scripts or cmd-tlm
    requireTargetParentDir: Boolean, // Require that the save filename be nested in a directory with the name of a target
    inputFilename: String, // passed if this is a 'save' dialog
    value: Boolean, // value is the default prop when using v-model
  },
  data() {
    return {
      tree: [],
      items: [],
      id: 1,
      search: null,
      selectedFile: null,
      disableButtons: false,
      targets: [],
      loading: true,
    }
  },
  computed: {
    show: {
      get() {
        return this.value
      },
      set(value) {
        this.$emit('input', value) // input is the default event when using v-model
      },
    },
    title: function () {
      if (this.type === 'open') {
        return 'File Open'
      } else {
        return 'File Save As...'
      }
    },
    submit: function () {
      if (this.type === 'open') {
        return 'OPEN'
      } else {
        return 'SAVE'
      }
    },
    helpText: function () {
      if (this.type === 'open') {
        return 'Click on folders to open them and then click a file to select it before clicking Open. Use the search box to filter the results.'
      } else {
        return 'Click on the folder to save into. Then complete the filename path with the desired name. Use the search box to filter the results.'
      }
    },
    error: function () {
      if (this.selectedFile === '' || this.selectedFile === null) {
        return 'No file selected must select a file'
      }
      if (
        !this.selectedFile.match(this.validFilenameRegex) ||
        this.selectedFile.match(/\.\.|\/\/|\.\/|\/\./) // Block .'s and /'s next to each other (block path traversal)
      ) {
        let message = `${this.selectedFile} is not a valid filename. Must `
        if (this.requireTargetParentDir) {
          message += 'be in a target directory and '
        }
        message +=
          'only contain alphanumeric characters (including !-_.*) and a valid extension.\n\n' +
          'For example: TGT1/procedures/test.py or TGT2/lib/inst.rb'
        return message
      }
      if (this.type === 'save' && this.selectedFile.match(/\*$/)) {
        let message = `${this.selectedFile} is not a valid filename. Must not end in '*'.`
        return message
      }
      return null
    },
    validFilenameRegex: function () {
      const alphanumeric = '0-9a-zA-Z'
      const charset = `${alphanumeric}\\/\\!\\-\\_\\.\\*\\'\\(\\)` // From https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html a-z A-Z 0-9 / ! - _ . * ' ( )
      let expression = `[${charset}]+\\.[${alphanumeric}]+`
      if (this.requireTargetParentDir) {
        const targets = `(${this.targets.join('|')})`
        expression = `\\/?${targets}\\/${expression}`
      }
      return new RegExp(expression)
    },
  },
  created() {
    this.loadFiles()
    if (this.requireTargetParentDir) {
      Api.get('/openc3-api/targets').then((response) => {
        this.targets = response.data
        this.targets.push('__TEMP__') // Also support __TEMP__
      })
    }
  },
  methods: {
    calcIcon: function (filename) {
      return fileIcon(filename)
    },
    loadFiles: function () {
      Api.get(this.apiUrl)
        .then((response) => {
          this.items = []
          this.id = 1
          for (let file of response.data) {
            // Make a copy of the entire file path before calling insertFile
            // because insertFile does recursion and needs the original path
            this.filepath = file
            this.insertFile(this.items, 1, file)
            this.id++
          }
          if (this.inputFilename) {
            this.selectedFile = this.inputFilename
          }
          this.loading = false
        })
        .catch((error) => {
          this.$emit('error', `Failed to connect to OpenC3. ${error}`)
        })
    },
    clear: function () {
      this.show = false
      this.overwrite = false
      this.disableButtons = false
    },
    handleSearch: function (input) {
      if (input) {
        this.$refs.tree.updateAll(true)
      } else {
        this.$refs.tree.updateAll(false)
      }
    },
    activeFile: function (file) {
      if (file.length === 0) {
        this.selectedFile = null
      } else {
        this.selectedFile = file[0].path
      }
    },
    exists: function (root, name) {
      let found = false
      for (let item of root) {
        if (item.path === name) {
          return true
        }
        if (item.path.length > 1) {
          if (item.path[item.path.length - 1] === '*') {
            // Try without the star too
            if (item.path.slice(0, item.path.length - 1) === name) {
              return true
            }
          }
        }
        if (item.children) {
          found = found || this.exists(item.children, name)
        }
      }
      return found
    },
    success: function () {
      // Only process the success call if a file is selected and no error
      if (this.selectedFile !== null && this.error === null) {
        if (this.type === 'open') {
          this.openSuccess()
        } else {
          this.saveSuccess()
        }
      }
    },
    deleteTemp: function () {
      this.$dialog
        .confirm(`Are you sure you want to delete all the temporary files?`, {
          okText: 'Delete',
          cancelText: 'Cancel',
        })
        .then((dialog) => {
          return Api.delete('/script-api/scripts/temp_files')
        })
        .then((response) => {
          this.$emit('clear-temp')
          this.loadFiles()
        })
        .catch((error) => {
          this.$notify.serious({
            title: 'Error',
            body: `Failed to remove script temporary files due to ${error}`,
          })
        })
    },
    openSuccess: function () {
      // Disable the buttons because the API call can take a bit
      this.disableButtons = true
      Api.get(`${this.apiUrl}/${this.selectedFile}`)
        .then((response) => {
          const file = {
            name: this.selectedFile,
            contents: response.data.contents,
          }
          if (response.data.suites) {
            file['suites'] = JSON.parse(response.data.suites)
          }
          if (response.data.error) {
            file['error'] = response.data.error
          }
          if (response.data.success) {
            file['success'] = response.data.success
          }
          const locked = response.data.locked
          const breakpoints = response.data.breakpoints
          this.$emit('file', { file, locked, breakpoints })
          this.clear()
        })
        .catch((error) => {
          this.$emit('error', `Failed to open ${this.selectedFile}. ${error}`)
          this.clear()
        })
    },
    saveSuccess: function () {
      const found = this.exists(this.items, this.selectedFile)
      if (found) {
        this.$dialog
          .confirm(`Are you sure you want to overwrite: ${this.selectedFile}`, {
            okText: 'Overwrite',
            cancelText: 'Cancel',
          })
          .then((dialog) => {
            this.$emit('filename', this.selectedFile)
            this.clear()
          })
          .catch((error) => {}) // Cancel, do nothing
      } else {
        this.$emit('filename', this.selectedFile)
        this.clear()
      }
    },
    insertFile: function (root, level, path) {
      var parts = path.split('/')
      // When there is only 1 part we're at the root so push the filename
      if (parts.length === 1) {
        root.push({
          id: this.id,
          name: parts[0],
          file: 'ruby',
          path: this.filepath,
        })
        this.id++
        return
      }
      // Look for the first part of the path
      const index = root.findIndex((item) => item.name === parts[0])
      if (index === -1) {
        // Name not found so push the item and add a children array
        root.push({
          id: this.id,
          name: parts[0],
          children: [],
          path: this.filepath.split('/').slice(0, level).join('/'),
        })
        this.id++
        this.insertFile(
          root[root.length - 1].children, // Start from the node we just added
          level + 1,
          parts.slice(1).join('/') // Strip the first part of the path
        )
      } else {
        // We already have something at this level so recursively
        // call the insertPart using the node we found and adjust the path
        this.insertFile(
          root[index].children,
          level + 1,
          parts.slice(1).join('/')
        )
      }
    },
  },
}
</script>