file-dialog.js

// @ts-check

const exec = require('util').promisify(require('child_process').exec)
const path = require('path')

// to fix the white space issue
const pathFixer = (pathString = '') => {
   const prefix = pathString.includes('/') ? '/' : '\\'

   pathString = pathString
      .replaceAll(`${prefix + prefix}`, prefix)
      .split(prefix)
      .map((p) => {
         return `${p.includes(' ') ? `"${p}"` : p}`
      })
      .join(prefix)

   return path.resolve(pathString)
}

/**
 * @typedef {Object} Config
 * @prop {string} vendorPath The path to the library executable. It is set according to the operating system.
 * @prop {Object} availableCommand The available commands and their attributes.
 * @prop {AvailableCommandItem} availableCommand.openFile The <code>open file</code> command and its attributes.
 * @prop {AvailableCommandItem} availableCommand.saveFile The <code>save file</code> command and its attributes.
 * @prop {AvailableCommandItem} availableCommand.openDirectory The <code>open directory</code> command and its attributes.
 * @prop {AvailableCommandItem} availableCommand.messageBox The <code>message box</code> command and its attributes.
 */

/**
 * @typedef {Object} AvailableCommandItem
 * @prop {string} name The name of the command. This is used by all library methods to invoke the executable that opens the popups.
 * @prop {AvailableCommandItemFlags} flags The differents flags of the command.
 */

/**
 * @typedef {Object} AvailableCommandItemFlags
 * @prop {AvailableCommandItemFlagsItem} title The title of the popup.
 * @prop {AvailableCommandItemFlagsItem} [message] The message written in the popup.
 * @prop {AvailableCommandItemFlagsItem} [dialogType] The type of message box.
 * @prop {AvailableCommandItemFlagsItem} [startPath] The path to the folder where the popup will be opened.
 * @prop {AvailableCommandItemFlagsItem} [filterPatterns] The pattern used to filter the files.
 * @prop {AvailableCommandItemFlagsItem} [filterPatternsDescription] The description of the filter patterns, wiewed by the user.
 * @prop {AvailableCommandItemFlagsItem} [allowMultipleSelects] The value that defines if the user can select several files.
 * @prop {AvailableCommandItemFlagsItem} [iconType] The type of icon (and sound) of the message box.
 * @prop {AvailableCommandItemFlagsDefaultDelected} [defaultSelected] The button that is selected by default in the message box.
 */

/**
 * @typedef {Object} AvailableCommandItemFlagsItem
 * @prop {string} name The name of the flag. For exemple, <code>--title</code>.
 * @prop {string} defaultValue The default value.
 * @prop {Object<string, string>} [typesMapper] An object that contains all the possible values of the flag.
 * @prop {Array<string>} [types] An array that contains all the possible values of the flag.
 */

/**
 * @typedef {Object} AvailableCommandItemFlagsDefaultDelected
 * @prop {string} name The name of the flag. For exemple, <code>--title</code>.
 * @prop {Object<string, number>} [typesMapper] An object that contains all the possible values of the flag.
 * @prop {number} default The default value.
 */

/**
 * An object that contains the entire configuration of the library.
 * @type {Config}
 */
exports.config = {
   vendorPath: pathFixer(
      path.join(
         __dirname,
         'lib',
         'vendors',
         'bin',
         `${process.platform}${process.platform === 'win32' ? '.exe' : '.app'}`
      )
   ),
   availableCommand: {
      openFile: {
         name: '-open-file',
         flags: {
            title: {
               name: '--title',
               defaultValue: 'open',
            },
            startPath: {
               name: '--startPath',
               defaultValue: path.resolve('./'),
            },
            filterPatterns: {
               name: '--filterPatterns',
               defaultValue: '*',
            },
            filterPatternsDescription: {
               name: '--filterPatternsDescription',
               defaultValue: '',
            },
            allowMultipleSelects: {
               name: '--allowMultipleSelects',
               defaultValue: '0',
            },
         },
      },
      saveFile: {
         name: '-save-file',
         flags: {
            title: {
               name: '--title',
               defaultValue: 'save',
            },
            startPath: {
               name: '--startPath',
               defaultValue: path.resolve('./'),
            },
            filterPatterns: {
               name: '--filterPatterns',
               defaultValue: '*',
            },
            filterPatternsDescription: {
               name: '--filterPatternsDescription',
               defaultValue: '',
            },
         },
      },
      openDirectory: {
         name: '-open-folder',
         flags: {
            title: {
               name: '--title',
               defaultValue: 'message',
            },
         },
      },
      messageBox: {
         name: '-message',
         flags: {
            title: {
               name: '--title',
               defaultValue: 'message',
            },
            message: {
               name: '--message',
               defaultValue: 'message',
            },
            dialogType: {
               name: '--type-D',
               typesMapper: {
                  ok: 'ok',
                  okCancel: 'okcancel',
                  yes: 'yes',
                  yesNo: 'yesno',
                  yesNoCancel: 'yesnocancel',
               },
               defaultValue: 'ok', // ok okcancel yesno yesnocancel
            },
            iconType: {
               name: '--type-I',
               types: ['info', 'warning', 'error', 'question'],
               defaultValue: 'info', // ok okcancel yesno yesnocancel
            },

            defaultSelected: {
               name: '-default',
               typesMapper: {
                  no: 2,
                  cancel: 0,
                  yes: 1,
                  ok: 1,
               },
               default: 1,
            },
         },
      },
   },
}

const commandBuilder = (command = '', opts) => {

   const referencedCommand = this.config.availableCommand[command]

   let final = ''
   if (referencedCommand.name === this.config.availableCommand.openFile.name) {
      opts?.allowMultipleSelects
         ? (opts.allowMultipleSelects = 1)
         : (opts.allowMultipleSelects = 0)

      final = `${this.config.vendorPath} ${referencedCommand.name} `

      // title
      final += `${referencedCommand.flags.title.name} "${
         opts?.title || referencedCommand.flags.title.defaultValue
      }" `

      // startPath
      final += `${referencedCommand.flags.startPath.name} "${
         opts?.startPath || referencedCommand.flags.startPath.defaultValue
      }/" `

      // filterPatterns
      final += `${referencedCommand.flags.filterPatterns.name} ",${
         opts?.filterPatterns?.join(',') ||
         referencedCommand.flags.filterPatterns.defaultValue
      }" `

      // filterPatternsDescription
      final += `${referencedCommand.flags.filterPatternsDescription.name} "${
         opts?.filterPatternsDescription ||
         referencedCommand.flags.filterPatternsDescription.defaultValue
      }" `

      // allowMultipleSelects
      final += `${referencedCommand.flags.allowMultipleSelects.name} ${
         opts?.allowMultipleSelects ||
         referencedCommand.flags.allowMultipleSelects.defaultValue
      } `
   }

   if (referencedCommand.name === this.config.availableCommand.saveFile.name) {
      final = `${this.config.vendorPath} ${referencedCommand.name} `

      // title
      final += `${referencedCommand.flags.title.name} "${
         opts?.title || referencedCommand.flags.title.defaultValue
      }" `

      // startPath
      final += `${referencedCommand.flags.startPath.name} "${
         opts?.startPath || referencedCommand.flags.startPath.defaultValue
      }" `

      // filterPatterns
      final += `${referencedCommand.flags.filterPatterns.name} ",${
         opts?.filterPatterns?.join(',') ||
         referencedCommand.flags.filterPatterns.defaultValue
      }" `

      // filterPatternsDescription
      final += `${referencedCommand.flags.filterPatternsDescription.name} "${
         opts?.filterPatternsDescription ||
         referencedCommand.flags.filterPatternsDescription.defaultValue
      }" `
   }

   if (referencedCommand.name === this.config.availableCommand.openDirectory.name) {
      final = `${this.config.vendorPath} ${referencedCommand.name} `

      // title
      final += `${referencedCommand.flags.title.name} "${
         opts?.title || referencedCommand.flags.title.defaultValue
      }" `
   }

   if (referencedCommand.name === this.config.availableCommand.messageBox.name) {
      final = `${this.config.vendorPath} ${referencedCommand.name} `

      // title
      final += `${referencedCommand.flags.title.name} "${
         opts?.title || referencedCommand.flags.title.defaultValue
      }" `

      // message
      final += `${referencedCommand.flags.message.name} "${
         opts?.message || referencedCommand.flags.message.defaultValue
      }" `

      // dialogType
      final += `${referencedCommand.flags.dialogType.name} ${
         referencedCommand.flags.dialogType.typesMapper[opts?.dialogType] ||
         referencedCommand.flags.dialogType.defaultValue
      } `

      // iconType String
      final += `${referencedCommand.flags.iconType.name} ${
         opts?.iconType || referencedCommand.flags.iconType.defaultValue
      } `

      // defaultSelected
      final += `${referencedCommand.flags.defaultSelected.name} ${
         referencedCommand.flags.defaultSelected.typesMapper[opts?.defaultSelected] ||
         referencedCommand.flags.defaultSelected.default
      } `
   }

   return final
}

/**
 * An error class that occurs when the user donesn't select any file in the [openFile()]{@linkcode openFile} function.
 * @class
 * @extends Error
 */
exports.NoSelectedFileError = class extends Error {
   /**
    * Create a [NoSelectedFileError]{@linkcode NoSelectedFileError} instance.
    * @param {string} [message] The message of the error.
    * @param {ErrorOptions} [options] The error options. These are the same as those for the basic [<code>Error</code>]{@linkcode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error} class.
    */
   constructor(message, options) {
      super(message, options)
      this.name = "NoSelectedFileError"
   }
}

/**
 * Open an "Open file" window.
 * @param {Object} [opts] The options of the window.
 * @param {string} [opts.title="open"] The title of the popup
 * @param {string} [opts.startPath="./"] The start path of the popup
 * @param {Array<string>} [opts.filterPatterns=["*"]] The filter patterns of the popup. For example, <code>["*.exe", "*.txt"]</code>
 * @param {string} [opts.filterPatternsDescription=""] The filter patterns description of the popup, separated by commas; for example, <code>"Executable files,Text files"</code>
 * @param {boolean|number} [opts.allowMultipleSelects=false] The boolean that define if the window allow multiple selects of files
 * @returns {Promise<Array<string>>} A promise representing an array that contains the paths to the selected files. For example, <code>["C:\\Users\\user\\Desktop\\file.exe"]</code>
 * @throws {NoSelectedFileError} If the user didn't select any file.
 * @async
 * @example
 * // With asynchronous method
 * openFile({
 *     title: 'Open several files',
 *     filterPatterns: ["*.txt"],
 *     allowMultipleSelects: true
 * }).then(data => console.log(data.join(', '))) // E.g. C:\Users\user\Desktop\file.txt, C:\Users\user\Desktop\other-file.txt
 * 
 * // With synchronous method 
 * (async () => {
 *     const data = await openFile({
 *         title: 'Open several files',
 *         filterPatterns: ["*.txt"],
 *         allowMultipleSelects: true
 *     })
 * 
 *     console.log(data.join(', ')) // E.g. C:\Users\user\Desktop\file.txt, C:\Users\user\Desktop\other-file.txt
 * })
 */
exports.openFile = async (
   opts = {
      title: '',
      startPath: '',
      filterPatterns: [],
      filterPatternsDescription: '',
      allowMultipleSelects: 0,
   }
) => {
   let { stdout: out, stderr } = await exec(commandBuilder('openFile', opts))
   if (stderr) throw new Error(stderr)

   if (out.includes('-066944')) {
      const err = out?.slice(out?.indexOf('-066944'))?.split('~')?.at(1)
      throw new Error(err)
   }
   let files = out
      ?.slice(out?.indexOf('-066945'))
      ?.split('~')
      ?.at(1)
      ?.split('|')
      .map((p) => path.resolve(p))

   if (files?.length === 0) throw new this.NoSelectedFileError('no files selected')

   return files || []
}

/**
 * An error class that occurs when the user donesn't select any directory in the [openDirectory()]{@linkcode openDirectory} function.
 * @class
 * @extends Error
 */
exports.NoSelectedDirectoryError = class extends Error {
   /**
    * Create a [NoSelectedDirectoryError]{@linkcode NoSelectedDirectoryError} instance.
    * @param {string} [message] The message of the error.
    * @param {ErrorOptions} [options] The error options. These are the same as those for the basic [<code>Error</code>]{@linkcode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error} class.
    */
   constructor(message, options) {
      super(message, options)
      this.name = "NoSelectedDirectoryError"
   }
}

/**
 * Open an "Open directory" window.
 * @async
 * @param {Object} [opts] The options of the window.
 * @param {string} [opts.title="message"] The title of the popup.
 * @returns {Promise<string>} A Promise representing the path to the selected directory. For example, <code>"C:\\Users\\user\\Desktop\\"</code>
 * @throws {NoSelectedDirectoryError} If the user didn't select any directory.
 * @example
 * // With asynchronous method
 * openDirectory({
 *     title: 'Open a directory',
 * }).then(data => console.log(data)) // E.g. C:\Users\user\Desktop\
 * 
 * // With synchronous method 
 * (async () => {
 *     const data = await openDirectory({
 *         title: 'Open a directory',
 *     })
 * 
 *     console.log(data) // E.g. C:\Users\user\Desktop\
 * })

 */
exports.openDirectory = async (opts = { title: '' }) => {
   let { stdout: out, stderr } = await exec(
      commandBuilder('openDirectory', opts)
   )
   if (stderr) throw new this.NoSelectedDirectoryError(stderr)

   if (out.includes('-066944')) {
      const err = out?.slice(out?.indexOf('-066944'))?.split('~')?.at(1)
      throw new this.NoSelectedDirectoryError(err)
   }
   let folder = out?.slice(out?.indexOf('-066945'))?.split('~')?.at(1)

   return folder || ""
}

/**
 * Open a dialog box.
 * @async
 * @deprecated Use [dialogBox()]{@linkcode dialogBox} instead.
 * @param {Object} opts
 * @param {string} [opts.title="message"] The title of the popup
 * @param {string} [opts.message="message"] The message of the popup
 * @param {"ok"|"okCancel"|"yesNo"|"yesNoCancel"|""} [opts.dialogType="ok"] The dialog type of the popup
 * @param {"info"|"warning"|"error"|"question"|""} [opts.iconType="info"] The icon and sound types of the popup
 * @param {"ok"|"cancel"|"yes"|"no"|0} [opts.defaultSelected="ok"] The default selected button of the popup
 * @return {Promise<0|1|2>} A Promise representing the selected button number: <style type="text/css">#messagebox-return-table {border-collapse: collapse;} #messagebox-return-table td {border: 1px solid black; padding: 5px;} #messagebox-return-table tr:first-child td:first-child {border:none;}</style><br/><table id="messagebox-return-table"><tr><td><td><code>0</code><td><code>1</code><td><code>2</code><tr><td><code>"ok"</code><td><td>Ok<td><tr><td><code>"okCancel"</code><td>Cancel<td>Ok<td><tr><td><code>"yesNo"</code><td>No<td>Yes<td><tr><td><code>"yesNoCancel"</code><td>Cancel<td>Yes<td>No</table>
 * @example
 * // With asynchronous method
 * messageBox({
 *     title: 'Shutdown',
 *     message: 'Do you want to continue?',
 *     dialogType: 'yesNo',
 *     defaultSelected: 'no'
 * }).then(data => console.log(data)) // E.g. 1 if the user clicked Yes
 * 
 * // With synchronous method 
 * (async () => {
 *     const data = await messageBox({
 *         title: 'Shutdown',
 *         message: 'Do you want to continue?',
 *         dialogType: 'yesNo',
 *         defaultSelected: 'no'
 *     })
 *     console.log(data) // E.g. 1 if the user clicked Yes
 * })

 */
exports.messageBox = async (
   opts = {
      title: '',
      message: '',
      dialogType: '',
      iconType: '',
      defaultSelected: 0,
   }
) => {
   let { stdout: answer, stderr } = await exec(
      commandBuilder('messageBox', opts)
   )
   if (stderr) throw new Error(stderr)

   if (answer.includes('-066944')) {
      const err = answer?.slice(answer?.indexOf('-066944'))?.split('~')?.at(1)
      throw new Error(err)
   }

   let result = Number(
      answer // yes/ok=1 no=2 cancel=0
         ?.slice(answer?.indexOf('-066945'))
         ?.split('~')
         ?.at(1)
   )
   return /** @type {0|1|2} */ (result % 3)
}

/**
 * This object contains all the values that can be returned by [dialogBox()]{@linkcode dialogBox}, where the keys are the names of the buttons (`YES`, `CANCEL`, etc.).<br />
 * You can use it to compare the result (e.g. <code>if (result === DialogBoxResult.YES) { ... }</code>)
 * @type {Readonly<{ CANCEL: 0, OK: 1, YES: 1, NO: 2 }>}
 */
exports.DialogBoxResult = Object.freeze({
   CANCEL: 0,
   OK: 1,
   YES: 1,
   NO: 2
})

/** @typedef {0|1|2} DialogBoxValue */

/**
 * Open a dialog box.
 * @async
 * @param {Object} opts
 * @param {string} [opts.title="message"] The title of the popup
 * @param {string} [opts.message="message"] The message of the popup
 * @param {"ok"|"okCancel"|"yesNo"|"yesNoCancel"|""} [opts.dialogType="ok"] The dialog type of the popup
 * @param {"info"|"warning"|"error"|"question"|""} [opts.iconType="info"] The icon and sound types of the popup
 * @param {"ok"|"cancel"|"yes"|"no"|0} [opts.defaultSelected="ok"] The default selected button of the popup
 * @return {Promise<DialogBoxValue>} A Promise representing the selected button number: <code>0</code> = Cancel, <code>1</code> = OK or Yes, <code>3</code> = No
 * @example
 * // With asynchronous method
 * messageBox({
 *     title: 'Shutdown',
 *     message: 'Do you want to continue?',
 *     dialogType: 'yesNo',
 *     defaultSelected: 'no'
 * }).then(data => console.log(data)) // E.g. 1 if the user clicked Yes
 * 
 * // With synchronous method 
 * (async () => {
 *     const data = await messageBox({
 *         title: 'Shutdown',
 *         message: 'Do you want to continue?',
 *         dialogType: 'yesNo',
 *         defaultSelected: 'no'
 *     })
 *     console.log(data) // E.g. 1 if the user clicked Yes
 * })

 */
exports.dialogBox = async (
   opts = {
      title: '',
      message: '',
      dialogType: '',
      iconType: '',
      defaultSelected: 0,
   }
) => {
   let { stdout: answer, stderr } = await exec(
      commandBuilder('messageBox', opts)
   )
   if (stderr) throw new Error(stderr)

   if (answer.includes('-066944')) {
      const err = answer?.slice(answer?.indexOf('-066944'))?.split('~')?.at(1)
      throw new Error(err)
   }

   let rawResult = Number(
      answer // yes/ok=1 no=2 cancel=0
         ?.slice(answer?.indexOf('-066945'))
         ?.split('~')
         ?.at(1)
   )

   /** @type {0|1|2} */
   let finalResult
   switch (opts.dialogType) {
      case '':
      case 'ok':
         finalResult = this.DialogBoxResult.OK
         break
      case 'okCancel':
         finalResult = rawResult === 0
            ? this.DialogBoxResult.CANCEL
            : this.DialogBoxResult.OK
         break
      case 'yesNo':
         finalResult = rawResult === 0
            ? this.DialogBoxResult.NO
            : this.DialogBoxResult.YES
         break
      case 'yesNoCancel':
      default:
         finalResult = rawResult === 0
            ? this.DialogBoxResult.CANCEL
            : rawResult === 1
               ? this.DialogBoxResult.YES
               : this.DialogBoxResult.NO
         break

   }
   return finalResult
}




/**
 * An error class that occurs when the user donesn't select any file in the [saveFile()]{@linkcode openFile} function.
 * @class
 * @extends Error
 */
exports.NoSavedFileError = class extends Error {
   /**
    * Create a [NoSavedFileError]{@linkcode NoSavedFileError} instance.
    * @param {string} [message] The message of the error.
    * @param {ErrorOptions} [options] The error options. These are the same as those for the basic [<code>Error</code>]{@linkcode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error} class.
    */
   constructor(message, options) {
      super(message, options)
      this.name = "NoSavedFileError"
   }
}

/**
 * Open a "Save file" window.
 * @async
 * @param {Object} opts The options of the window.
 * @param {string} [opts.title="save"] The title of the popup.
 * @param {string} [opts.startPath="./default.txt"] The start path of the popup and the saved file name
 * @param {Array<string>} [opts.filterPatterns=["*"]] The filter patterns of the popup. For example, <code>["*.exe", "*.txt"]</code>
 * @param {string} [opts.filterPatternsDescription=""] The filter patterns description of the popup, separated by commas; for example, <code>"Executable files,Text files"</code>
 * @returns {Promise<string>} A Promise representing the path to the saved file. Example: <code>"C:\\Users\\user\\Desktop\\default.txt"</code>
 * @throws {NoSavedFileError} If the user cancelled and didn't select any file to save in.
 * @example
 * // With asynchronous method
 * saveFile({
 *     title: 'Save into a file',
 *     filterPatterns: ["*.txt"],
 *     allowMultipleSelects: true
 * }).then(data => console.log(data)) // E.g. C:\Users\user\Desktop\default.txt
 * 
 * // With synchronous method 
 * (async () => {
 *     const data = await saveFile({
 *         title: 'Save into a file',
 *         filterPatterns: ["*.txt"],
 *         allowMultipleSelects: true
 *     })
 * 
 *     console.log(data) // E.g. C:\Users\user\Desktop\default.txt
 * })
 */
exports.saveFile = async (
   opts = {
      title: '',
      startPath: '',
      filterPatterns: [],
      filterPatternsDescription: '',
   }
) => {
   let { stdout: out, stderr } = await exec(commandBuilder('saveFile', opts))
   if (stderr) throw new this.NoSavedFileError(stderr)

   if (out.includes('-066944')) {
      const err = out?.slice(out?.indexOf('-066944'))?.split('~')?.at(1)
      throw new this.NoSavedFileError(err)
   }
   let file = out?.slice(out?.indexOf('-066945'))?.split('~')?.at(1)

   return file || ""
}