Ahmad Kholid 2 vuotta sitten
vanhempi
commit
9e42a2394f

+ 6 - 6
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.26.2",
+  "version": "1.27.0",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",
@@ -47,7 +47,7 @@
     "@tiptap/vue-3": "^2.0.0-beta.209",
     "@viselect/vanilla": "^3.2.4",
     "@vue-flow/additional-components": "^1.3.3",
-    "@vue-flow/core": "^1.12.1",
+    "@vue-flow/core": "^1.12.5",
     "@vueuse/head": "^1.0.22",
     "@vueuse/rxjs": "^9.1.1",
     "@vuex-orm/core": "^0.36.4",
@@ -74,7 +74,7 @@
     "nanoid": "^4.0.0",
     "object-path": "^0.11.8",
     "papaparse": "^5.3.1",
-    "pinia": "^2.0.28",
+    "pinia": "^2.0.29",
     "prosemirror-commands": "^1.5.0",
     "prosemirror-dropcursor": "^1.6.1",
     "prosemirror-gapcursor": "^1.3.1",
@@ -104,7 +104,7 @@
     "@vue/compiler-sfc": "^3.2.41",
     "archiver": "^5.3.1",
     "autoprefixer": "^10.4.12",
-    "babel-loader": "^8.2.2",
+    "babel-loader": "^9.1.2",
     "clean-webpack-plugin": "4.0.0",
     "copy-webpack-plugin": "^11.0.0",
     "core-js": "^3.27.1",
@@ -115,7 +115,7 @@
     "eslint-config-prettier": "^8.6.0",
     "eslint-friendly-formatter": "^4.0.1",
     "eslint-import-resolver-webpack": "^0.13.2",
-    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-import": "^2.27.5",
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-vue": "^9.4.0",
     "file-loader": "^6.2.0",
@@ -134,7 +134,7 @@
     "vue-loader": "^17.0.0",
     "web-worker": "^1.2.0",
     "webpack": "^5.73.0",
-    "webpack-cli": "^4.10.0",
+    "webpack-cli": "^5.0.1",
     "webpack-dev-server": "^4.11.1"
   }
 }

+ 2 - 2
src/components/block/BlockBase.vue

@@ -4,10 +4,10 @@
       class="block-menu-container absolute top-0 hidden w-full"
       style="transform: translateY(-100%)"
     >
-      <div>
+      <div class="pointer-events-none">
         <p
           title="Block id (click to copy)"
-          class="block-menu text-overflow inline-block px-1 dark:text-gray-300"
+          class="block-menu pointer-events-auto text-overflow inline-block px-1 dark:text-gray-300"
           style="max-width: 96px; margin-bottom: 0"
           @click="insertToClipboard"
         >

+ 5 - 6
src/components/newtab/workflow/edit/EditConditions.vue

@@ -124,7 +124,7 @@ import { ref, watch, onMounted, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid';
 import Draggable from 'vuedraggable';
-import { sleep } from '@/utils/helper';
+import { debounce } from '@/utils/helper';
 import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
 
 const props = defineProps({
@@ -190,11 +190,10 @@ function deleteCondition(index, id) {
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
-async function onEnd() {
-  props.editor.addSelectedNodes([]);
-  await sleep(1000);
-  props.editor.addSelectedNodes([props.editor.getNode.value(props.blockId)]);
-}
+
+const onEnd = debounce(() => {
+  props.editor.updateNodeInternals([props.blockId]);
+}, 500);
 
 watch(
   conditions,

+ 138 - 0
src/components/newtab/workflow/edit/EditGoogleDrive.vue

@@ -0,0 +1,138 @@
+<template>
+  <div>
+    <div v-if="!store.integrations.googleDrive">
+      <p>
+        You haven't
+        <a
+          href="https://docs.automa.site/integrations/google-drive.html"
+          target="_blank"
+          class="underline"
+          >connected Automa to Google Drive</a
+        >.
+      </p>
+    </div>
+    <template v-else>
+      <ui-textarea
+        :model-value="data.description"
+        class="w-full"
+        :placeholder="t('common.description')"
+        @change="updateData({ description: $event })"
+      />
+      <ui-select
+        :model-value="data.action"
+        class="w-full mt-4"
+        @change="updateData({ action: $event })"
+      >
+        <option v-for="action in actions" :key="action" :value="action">
+          {{ t(`workflow.blocks.google-drive.actions.${action}`) }}
+        </option>
+      </ui-select>
+      <div class="mt-4">
+        <ul class="space-y-2">
+          <li
+            v-for="(item, index) in filePaths"
+            :key="item.id"
+            class="p-2 border rounded-lg"
+          >
+            <div class="flex items-center">
+              <ui-select
+                v-model="item.type"
+                class="grow mr-2"
+                placeholder="File location"
+              >
+                <option value="url">URL</option>
+                <option value="local" :disabled="!hasFileAccess">
+                  Local computer
+                </option>
+                <option
+                  value="downloadId"
+                  :disabled="!permissions.has.downloads"
+                >
+                  Download id
+                </option>
+              </ui-select>
+              <ui-button icon @click="filePaths.splice(index, 1)">
+                <v-remixicon name="riDeleteBin7Line" />
+              </ui-button>
+            </div>
+            <edit-autocomplete>
+              <ui-input
+                v-model="item.name"
+                placeholder="Filename (optional)"
+                class="w-full mt-2"
+              />
+            </edit-autocomplete>
+            <edit-autocomplete>
+              <ui-input
+                v-model="item.path"
+                :placeholder="placeholders[item.type]"
+                title="File location"
+                class="w-full mt-2"
+              />
+            </edit-autocomplete>
+          </li>
+        </ul>
+        <ui-button class="mt-4" variant="accent" @click="addFile">
+          Add file
+        </ui-button>
+      </div>
+    </template>
+  </div>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid/non-secure';
+import cloneDeep from 'lodash.clonedeep';
+import browser from 'webextension-polyfill';
+import { useStore } from '@/stores/main';
+import { useHasPermissions } from '@/composable/hasPermissions';
+import EditAutocomplete from './EditAutocomplete.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const store = useStore();
+store.checkGDriveIntegration();
+
+const actions = ['upload'];
+const placeholders = {
+  downloadId: '0',
+  local: 'C:\\file.zip',
+  url: 'https://example.com/file.zip',
+};
+
+const permissions = useHasPermissions(['downloads']);
+
+const filePaths = ref(cloneDeep(props.data.filePaths));
+const hasFileAccess = ref(true);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function addFile() {
+  filePaths.value.push({ path: '', type: 'url', name: '', id: nanoid(5) });
+}
+
+browser.extension.isAllowedFileSchemeAccess().then((value) => {
+  hasFileAccess.value = value;
+});
+
+watch(
+  filePaths,
+  (paths) => {
+    updateData({ filePaths: paths });
+  },
+  { deep: true }
+);
+</script>

+ 51 - 12
src/components/newtab/workflow/edit/EditGoogleSheets.vue

@@ -15,7 +15,13 @@
         {{ t(`workflow.blocks.google-sheets.select.${action}`) }}
       </option>
     </ui-select>
-    <edit-autocomplete>
+    <slot />
+    <edit-autocomplete
+      v-if="
+        !googleDrive ||
+        (data.inputSpreadsheetId === 'manually' && data.type !== 'create')
+      "
+    >
       <ui-input
         :model-value="data.spreadsheetId"
         class="w-full"
@@ -45,7 +51,7 @@
       Automa doesn't have access to the spreadsheet
       <v-remixicon name="riInformationLine" size="18" class="inline" />
     </a>
-    <edit-autocomplete>
+    <edit-autocomplete v-if="data.type !== 'create'">
       <ui-input
         :model-value="data.range"
         class="mt-1 w-full"
@@ -92,9 +98,13 @@
         {{ previewDataState.errorMessage }}
       </p>
     </template>
-    <template v-else-if="data.type === 'getRange'">
+    <template v-else-if="['getRange', 'create'].includes(data.type)">
+      <p class="mt-4">
+        {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}
+      </p>
       <insert-workflow-data :data="data" variables @update="updateData" />
       <ui-button
+        v-if="data.type === 'getRange'"
         :loading="previewDataState.status === 'loading'"
         variant="accent"
         class="mt-4"
@@ -207,8 +217,10 @@
 <script setup>
 import { shallowReactive, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { googleSheets, fetchApi } from '@/utils/api';
+import { useToast } from 'vue-toastification';
+import { fetchApi } from '@/utils/api';
 import { convert2DArrayToArrayObj, debounce } from '@/utils/helper';
+import googleSheetsApi from '@/utils/googleSheetsApi';
 import EditAutocomplete from './EditAutocomplete.vue';
 import InsertWorkflowData from './InsertWorkflowData.vue';
 
@@ -221,12 +233,25 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  googleDrive: Boolean,
+  additionalActions: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
+const toast = useToast();
 
-const actions = ['get', 'getRange', 'update', 'append', 'clear'];
+const actions = [
+  'get',
+  'getRange',
+  'update',
+  'append',
+  'clear',
+  ...props.additionalActions,
+];
 const dataFrom = ['data-columns', 'custom'];
 const valueInputOptions = ['RAW', 'USER_ENTERED'];
 const insertDataOptions = ['OVERWRITE', 'INSERT_ROWS'];
@@ -272,18 +297,32 @@ async function previewData() {
       range: props.data.range,
       spreadsheetId: props.data.spreadsheetId,
     };
+
+    if (!props.data.spreadsheetId) {
+      toast.error(
+        props.googleDrive
+          ? 'No spreadsheet is selected'
+          : 'Spreadsheet id is empty'
+      );
+      previewDataState.status = 'idle';
+      return;
+    }
+    if (!props.data.range) {
+      toast.error('Spreadsheet range is empty');
+      previewDataState.status = 'idle';
+      return;
+    }
+
     const response = await (isGetValues
-      ? googleSheets.getValues(params)
-      : googleSheets.getRange(params));
+      ? googleSheetsApi(props.googleDrive).getValues(params)
+      : googleSheetsApi(props.googleDrive).getRange(params));
 
-    if (!response.ok) {
-      const error = await response.json();
+    let result = props.googleDrive ? response : await response.json();
 
-      throw new Error(error.message || response.statusText);
+    if (!response.ok && !props.googleDrive) {
+      throw new Error(result.message || response.statusText);
     }
 
-    let result = await response.json();
-
     if (isGetValues) {
       const values = result?.values ?? [];
       result = props.data.firstRowAsKey

+ 106 - 0
src/components/newtab/workflow/edit/EditGoogleSheetsDrive.vue

@@ -0,0 +1,106 @@
+<template>
+  <div v-if="!store.integrations.googleDrive">
+    <p>
+      You haven't
+      <a
+        href="https://docs.automa.site/integrations/google-drive.html"
+        target="_blank"
+        class="underline"
+        >connected Automa to Google Drive</a
+      >.
+    </p>
+  </div>
+  <edit-google-sheets
+    v-else
+    google-drive
+    :data="data"
+    :additional-actions="['create']"
+    @update:data="updateData"
+  >
+    <ui-tabs
+      v-if="data.type !== 'create'"
+      small
+      :model-value="data.inputSpreadsheetId"
+      fill
+      class="w-full my-2"
+      type="fill"
+      @change="updateData({ inputSpreadsheetId: $event })"
+    >
+      <ui-tab value="connected"> Connected </ui-tab>
+      <ui-tab value="manually"> Manually </ui-tab>
+    </ui-tabs>
+    <div
+      v-if="data.type !== 'create' && data.inputSpreadsheetId === 'connected'"
+      class="flex items-end"
+    >
+      <ui-select
+        :model-value="data.spreadsheetId"
+        :label="t('workflow.blocks.google-sheets-drive.connected')"
+        :placeholder="t('workflow.blocks.google-sheets-drive.select')"
+        class="w-full"
+        @change="updateData({ spreadsheetId: $event })"
+      >
+        <option
+          v-for="sheet in store.connectedSheets"
+          :key="sheet.id"
+          :value="sheet.id"
+        >
+          {{ sheet.name }}
+        </option>
+      </ui-select>
+      <ui-button
+        v-tooltip="t('workflow.blocks.google-sheets-drive.connect')"
+        icon
+        class="ml-2"
+        @click="connectSheet"
+      >
+        <v-remixicon name="riLink" />
+      </ui-button>
+    </div>
+    <ui-input
+      v-if="data.type === 'create'"
+      :model-value="data.sheetName"
+      label="Sheet name"
+      placeholder="A Spreadsheet"
+      class="w-full"
+      @change="updateData({ sheetName: $event })"
+    />
+  </edit-google-sheets>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import { useStore } from '@/stores/main';
+import openGDriveFilePicker from '@/utils/openGDriveFilePicker';
+import EditGoogleSheets from './EditGoogleSheets.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const toast = useToast();
+const store = useStore();
+store.getConnectedSheets();
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function connectSheet() {
+  openGDriveFilePicker().then(({ name, id, mimeType }) => {
+    if (mimeType !== 'application/vnd.google-apps.spreadsheet') {
+      toast.error('File is not a google spreadsheet');
+      return;
+    }
+
+    const sheetExists = store.connectedSheets.some((sheet) => sheet.id === id);
+    if (sheetExists) return;
+
+    store.connectedSheets.push({ name, id });
+  });
+}
+</script>

+ 1 - 1
src/components/newtab/workflow/editor/EditorSearchBlocks.vue

@@ -49,7 +49,7 @@
         </div>
         <span
           title="Block id"
-          class="text-overflow bg-box-transparent w-16 rounded-md p-1 text-xs text-gray-600 dark:text-gray-300"
+          class="text-overflow text-center bg-box-transparent w-16 rounded-md p-1 text-xs text-gray-600 dark:text-gray-300"
         >
           {{ item.id }}
         </span>

+ 4 - 3
src/components/newtab/workflow/settings/SettingsGeneral.vue

@@ -32,7 +32,7 @@
       </span>
     </div>
   </div>
-  <div v-if="!isMV2" class="flex items-center pt-4">
+  <div v-if="!isFirefox" class="flex items-center pt-4">
     <div class="mr-4 flex-1">
       <p>Workflow Execution</p>
       <p class="text-sm leading-tight text-gray-600 dark:text-gray-200">
@@ -148,7 +148,7 @@
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
-import browser from 'webextension-polyfill';
+// import browser from 'webextension-polyfill';
 import { clearCache } from '@/utils/helper';
 import { useHasPermissions } from '@/composable/hasPermissions';
 
@@ -164,7 +164,8 @@ const { t } = useI18n();
 const toast = useToast();
 const permissions = useHasPermissions(['notifications']);
 
-const isMV2 = browser.runtime.getManifest().manifest_version === 2;
+const isFirefox = BROWSER_TYPE === 'firefox';
+// const isMV2 = browser.runtime.getManifest().manifest_version === 2;
 
 const browserType = BROWSER_TYPE;
 const onError = [

+ 57 - 5
src/content/services/webService.js

@@ -46,11 +46,6 @@ window.addEventListener('DOMContentLoaded', async () => {
 
     await db.put('store', workflows, 'workflows');
 
-    const session =
-      parseJSON(localStorage.getItem('supabase.auth.token'), null)
-        ?.currentSession ?? null;
-    await browser.storage.local.set({ session });
-
     const webListener = initWebListener();
     webListener.on('open-dashboard', ({ path }) => {
       if (!path) return;
@@ -200,3 +195,60 @@ window.addEventListener('DOMContentLoaded', async () => {
     console.error(error);
   }
 });
+
+window.addEventListener('user-logout', () => {
+  browser.storage.local.remove(['session', 'sessionToken']);
+});
+
+window.addEventListener('app-mounted', async () => {
+  try {
+    const STORAGE_KEY = 'supabase.auth.token';
+    const session = parseJSON(localStorage.getItem(STORAGE_KEY), null);
+    const storage = await browser.storage.local.get([
+      'session',
+      'sessionToken',
+    ]);
+
+    const setUserSession = async () => {
+      const saveToStorage = { session };
+
+      const isGoogleProvider =
+        session?.user?.user_metadata?.iss.includes('googleapis.com');
+      const { session: currSession, sessionToken: currSessionToken } =
+        await browser.storage.local.get(['session', 'sessionToken']);
+      if (
+        isGoogleProvider &&
+        ((session && session.user.id === currSession?.user.id) ||
+          !currSessionToken)
+      ) {
+        saveToStorage.sessionToken = {
+          access: session.provider_token,
+          refresh: session.provider_refresh_token,
+        };
+      }
+      if (!isGoogleProvider) {
+        browser.storage.local.remove('sessionToken');
+      }
+
+      await browser.storage.local.set(saveToStorage);
+    };
+
+    if (session && !storage.session) {
+      await setUserSession();
+    } else if (session && storage.session) {
+      if (session.user.id !== storage.session.id) {
+        await setUserSession();
+      } else {
+        const currentSession = { ...storage.session };
+        if (storage.sessionToken) {
+          currentSession.provider_token = storage.sessionToken.access;
+          currentSession.provider_refresh_token = storage.sessionToken.refresh;
+        }
+
+        localStorage.setItem(STORAGE_KEY, JSON.stringify(currentSession));
+      }
+    }
+  } catch (error) {
+    console.error(error);
+  }
+});

+ 4 - 0
src/lib/vRemixicon.js

@@ -93,6 +93,8 @@ import {
   riDownloadLine,
   riFileListLine,
   riDragDropLine,
+  riDriveLine,
+  riDriveFill,
   riClipboardLine,
   riCheckDoubleLine,
   riDoubleQuotesL,
@@ -229,6 +231,8 @@ export const icons = {
   riDownloadLine,
   riFileListLine,
   riDragDropLine,
+  riDriveLine,
+  riDriveFill,
   riClipboardLine,
   riCheckDoubleLine,
   riDoubleQuotesL,

+ 16 - 1
src/locales/en/blocks.json

@@ -395,6 +395,20 @@
         "insertVars": "Insert current workflow variables",
         "useCommas": "Use commas to separate the variable name"
       },
+      "google-sheets-drive": {
+        "name": "@:workflow.blocks.google-sheets.name (GDrive)",
+        "description": "@:workflow.blocks.google-sheets.description",
+        "connected": "Connected sheets",
+        "select": "Select sheet",
+        "connect": "Connect sheet"
+      },
+      "google-drive": {
+        "name": "Google Drive",
+        "description": "Upload files to Google Drive",
+        "actions": {
+          "upload": "Upload files"
+        }
+      },
       "google-sheets": {
         "name": "Google Sheets",
         "description": "Read or update Google Sheets data",
@@ -429,7 +443,8 @@
           "getRange": "Get spreadsheet range",
           "update": "Update spreadsheet cell values",
           "append": "Append spreadsheet cell values",
-          "clear": "Clear spreadsheet cell values"
+          "clear": "Clear spreadsheet cell values",
+          "create": "Create a spreadsheet"
         }
       },
       "active-tab": {

+ 1 - 0
src/locales/en/common.json

@@ -26,6 +26,7 @@
     "save": "Save",
     "data": "data",
     "stop": "Stop",
+    "sheet": "Sheet",
     "action": "Action | Actions",
     "packages": "Packages",
     "storage": "Storage",

+ 1 - 1
src/manifest.chrome.json

@@ -73,5 +73,5 @@
   "sandbox": {
     "pages": ["/sandbox.html"]
   },
-  "content_security_policy": "script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; object-src 'self'"
+  "content_security_policy": "script-src 'self' https://apis.google.com/ https://accounts.google.com/ https://www.googleapis.com https://ajax.googleapis.com; object-src 'self'"
 }

+ 47 - 0
src/stores/main.js

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
 import defu from 'defu';
 import browser from 'webextension-polyfill';
 import deepmerge from 'lodash.merge';
+import { fetchGapi } from '@/utils/api';
 
 export const useStore = defineStore('main', {
   storageMap: {
@@ -27,7 +28,15 @@ export const useStore = defineStore('main', {
         snapGrid: { 0: 15, 1: 15 },
       },
     },
+    integrations: {
+      googleDrive: false,
+    },
+    integrationsRetrieved: {
+      googleDrive: false,
+    },
     retrieved: true,
+    connectedSheets: [],
+    connectedSheetsRetrieved: false,
   }),
   actions: {
     loadSettings() {
@@ -40,5 +49,43 @@ export const useStore = defineStore('main', {
       this.settings = deepmerge(this.settings, settings);
       await this.saveToStorage('settings');
     },
+    async checkGDriveIntegration(force = false) {
+      try {
+        if (this.integrationsRetrieved.googleDrive && !force) return;
+
+        const result = await fetchGapi(
+          `https://www.googleapis.com/oauth2/v1/tokeninfo`
+        );
+        if (!result) return;
+
+        this.integrations.googleDrive =
+          result.scope.includes('auth/drive.file');
+      } catch (error) {
+        console.error(error);
+      }
+    },
+    async getConnectedSheets() {
+      try {
+        if (this.connectedSheetsRetrieved) return;
+
+        const result = await fetchGapi(
+          'https://www.googleapis.com/drive/v3/files'
+        );
+
+        this.integrations.googleDrive = true;
+        this.connectedSheets = result.files.filter(
+          (file) => file.mimeType === 'application/vnd.google-apps.spreadsheet'
+        );
+      } catch (error) {
+        if (
+          error.message === 'no-scope' ||
+          error.message.includes('insufficient authentication')
+        ) {
+          this.integrations.googleDrive = false;
+        }
+
+        console.error(error);
+      }
+    },
   },
 });

+ 128 - 55
src/utils/api.js

@@ -2,70 +2,39 @@ import secrets from 'secrets';
 import browser from 'webextension-polyfill';
 import { parseJSON, isObject } from './helper';
 
-function queryBuilder(obj) {
-  let str = '';
-
-  Object.entries(obj).forEach(([key, value], index) => {
-    if (index !== 0) str += `&`;
+export async function fetchApi(path, options) {
+  const urlPath = path.startsWith('/') ? path : `/${path}`;
+  const headers = {
+    'Content-Type': 'application/json',
+    ...(options?.headers || {}),
+  };
 
-    str += `${key}=${value}`;
-  });
+  const { session } = await browser.storage.local.get('session');
+  if (session) {
+    let token = session.access_token;
+
+    if (Date.now() > (session.expires_at - 2000) * 1000) {
+      const response = await fetch(
+        `${secrets.baseApiUrl}/me/refresh-auth-session?token=${session.refresh_token}`
+      );
+      const result = await response.json();
+      if (!response.ok) {
+        throw new Error(result.message);
+      }
 
-  return str;
-}
+      await browser.storage.local.set({ session: result });
+      token = result.access_token;
+    }
 
-export async function fetchApi(path, options) {
-  const urlPath = path.startsWith('/') ? path : `/${path}`;
+    headers.Authorization = `Bearer ${token}`;
+  }
 
   return fetch(`${secrets.baseApiUrl}${urlPath}`, {
-    headers: {
-      'Content-Type': 'application/json',
-    },
     ...options,
+    headers,
   });
 }
 
-export const googleSheets = {
-  getUrl(spreadsheetId, range) {
-    return `/services/google-sheets?spreadsheetId=${spreadsheetId}&range=${range}`;
-  },
-  getValues({ spreadsheetId, range }) {
-    const url = this.getUrl(spreadsheetId, range);
-
-    return fetchApi(url);
-  },
-  getRange({ spreadsheetId, range }) {
-    return googleSheets.updateValues({
-      range,
-      append: true,
-      spreadsheetId,
-      options: {
-        body: JSON.stringify({ values: [] }),
-        queries: {
-          valueInputOption: 'RAW',
-          includeValuesInResponse: false,
-          insertDataOption: 'INSERT_ROWS',
-        },
-      },
-    });
-  },
-  clearValues({ spreadsheetId, range }) {
-    return fetchApi(this.getUrl(spreadsheetId, range), {
-      method: 'DELETE',
-    });
-  },
-  updateValues({ spreadsheetId, range, options = {}, append }) {
-    const url = `${this.getUrl(spreadsheetId, range)}&${queryBuilder(
-      options?.queries || {}
-    )}`;
-
-    return fetchApi(url, {
-      ...options,
-      method: append ? 'POST' : 'PUT',
-    });
-  },
-};
-
 export async function cacheApi(key, callback, useCache = true) {
   const isBoolOpts = typeof useCache === 'boolean';
   const options = {
@@ -176,3 +145,107 @@ export async function getUserWorkflows(useCache = true) {
     useCache
   );
 }
+
+export function validateOauthToken() {
+  let retryCount = 0;
+
+  const startFetch = async () => {
+    try {
+      const { sessionToken } = await browser.storage.local.get('sessionToken');
+      if (!sessionToken) return null;
+
+      const response = await fetch(
+        `https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${sessionToken.access}`
+      );
+      if (response.status === 400 && sessionToken.refresh && retryCount <= 3) {
+        const refreshResponse = await fetchApi(
+          `/me/refresh-session?token=${sessionToken.refresh}`
+        );
+        const refreshResult = await refreshResponse.json();
+
+        if (!refreshResponse.ok) {
+          throw new Error(refreshResult.message);
+        }
+
+        retryCount += 1;
+
+        const result = await startFetch();
+        return result;
+      }
+
+      return null;
+    } catch (error) {
+      console.error(error);
+    }
+
+    return null;
+  };
+
+  return startFetch();
+}
+
+export async function fetchGapi(url, resource = {}, options = {}) {
+  const { sessionToken } = await browser.storage.local.get('sessionToken');
+  if (!sessionToken) throw new Error('unauthorized');
+
+  const { search, origin, pathname } = new URL(url);
+  const searchParams = new URLSearchParams(search);
+  searchParams.set('access_token', sessionToken.access);
+
+  let tryCount = 0;
+  const maxTry = options?.tryCount || 3;
+
+  const startFetch = async () => {
+    const response = await fetch(
+      `${origin}${pathname}?${searchParams.toString()}`,
+      resource
+    );
+
+    const isResJson = response.headers
+      .get('content-type')
+      ?.includes('application/json');
+    const result = isResJson && (await response.json());
+    const insufficientScope =
+      response.status === 403 &&
+      result?.error?.message.includes('insufficient authentication scopes');
+    if (
+      (response.status === 401 || insufficientScope) &&
+      sessionToken.refresh
+    ) {
+      const refreshResponse = await fetchApi(
+        `/me/refresh-session?token=${sessionToken.refresh}`
+      );
+      const refreshResult = await refreshResponse.json();
+
+      if (!refreshResponse.ok) {
+        throw new Error(refreshResult.message);
+      }
+
+      searchParams.set('access_token', refreshResult.token);
+      sessionToken.access = refreshResult.token;
+
+      await browser.storage.local.set({ sessionToken });
+
+      if (tryCount < maxTry) {
+        tryCount += 1;
+        const awaitResult = await startFetch();
+
+        return awaitResult;
+      }
+
+      throw new Error('unauthorized');
+    }
+    if (!response.ok) {
+      throw new Error(result?.error?.message, { cause: result });
+    }
+
+    if (options?.response) {
+      return { response, result };
+    }
+
+    return result;
+  };
+
+  const result = await startFetch();
+  return result;
+}

+ 116 - 0
src/utils/googleSheetsApi.js

@@ -0,0 +1,116 @@
+import { fetchGapi, fetchApi } from './api';
+
+function queryBuilder(obj) {
+  let str = '';
+
+  Object.entries(obj).forEach(([key, value], index) => {
+    if (index !== 0) str += `&`;
+
+    str += `${key}=${value}`;
+  });
+
+  return str;
+}
+
+export const googleSheetNative = {
+  getUrl(path) {
+    return `https://sheets.googleapis.com/v4/spreadsheets${path}`;
+  },
+  getValues({ spreadsheetId, range }) {
+    const url = googleSheetNative.getUrl(`/${spreadsheetId}/values/${range}`);
+
+    return fetchGapi(url);
+  },
+  getRange({ spreadsheetId, range }) {
+    const url = googleSheetNative.getUrl(
+      `/${spreadsheetId}/values/${range}:append?valueInputOption=RAW&includeValuesInResponse=false&insertDataOption=INSERT_ROWS`
+    );
+
+    return fetchGapi(url, {
+      method: 'POST',
+    });
+  },
+  clearValues({ spreadsheetId, range }) {
+    const url = googleSheetNative.getUrl(
+      `/${spreadsheetId}/values/${range}:clear`
+    );
+
+    return fetchGapi(url, { method: 'POST' });
+  },
+  updateValues({ spreadsheetId, range, options, append }) {
+    let url = '';
+    let method = '';
+
+    if (append) {
+      url = googleSheetNative.getUrl(
+        `/${spreadsheetId}/values/${range}:append`
+      );
+      method = 'POST';
+    } else {
+      url = googleSheetNative.getUrl(`/${spreadsheetId}/values/${range}`);
+      method = 'PUT';
+    }
+
+    const payload = { method };
+    if (options.body) payload.body = options.body;
+
+    return fetchGapi(`${url}?${queryBuilder(options?.queries || {})}`, payload);
+  },
+  create(name) {
+    const url = googleSheetNative.getUrl('');
+
+    return fetchGapi(url, {
+      method: 'POST',
+      body: JSON.stringify({
+        properties: {
+          title: name,
+        },
+      }),
+    });
+  },
+};
+
+export const googleSheets = {
+  getUrl(spreadsheetId, range) {
+    return `/services/google-sheets?spreadsheetId=${spreadsheetId}&range=${range}`;
+  },
+  getValues({ spreadsheetId, range }) {
+    const url = this.getUrl(spreadsheetId, range);
+
+    return fetchApi(url);
+  },
+  getRange({ spreadsheetId, range }) {
+    return googleSheets.updateValues({
+      range,
+      append: true,
+      spreadsheetId,
+      options: {
+        body: JSON.stringify({ values: [] }),
+        queries: {
+          valueInputOption: 'RAW',
+          includeValuesInResponse: false,
+          insertDataOption: 'INSERT_ROWS',
+        },
+      },
+    });
+  },
+  clearValues({ spreadsheetId, range }) {
+    return fetchApi(this.getUrl(spreadsheetId, range), {
+      method: 'DELETE',
+    });
+  },
+  updateValues({ spreadsheetId, range, options = {}, append }) {
+    const url = `${this.getUrl(spreadsheetId, range)}&${queryBuilder(
+      options?.queries || {}
+    )}`;
+
+    return fetchApi(url, {
+      ...options,
+      method: append ? 'POST' : 'PUT',
+    });
+  },
+};
+
+export default function (isDriveSheet = false) {
+  return isDriveSheet ? googleSheetNative : googleSheets;
+}

+ 95 - 0
src/utils/openGDriveFilePicker.js

@@ -0,0 +1,95 @@
+import secrets from 'secrets';
+import browser from 'webextension-polyfill';
+
+function injectFilePicker() {
+  return new Promise((resolve, reject) => {
+    const scriptExist = document.querySelector('#google-api');
+    if (scriptExist) {
+      resolve();
+      return;
+    }
+
+    let gisLoaded = false;
+    let pickerLoaded = false;
+
+    const scriptApi = document.createElement('script');
+    scriptApi.onload = () => {
+      window.gapi.load('picker', () => {
+        pickerLoaded = true;
+      });
+    };
+    scriptApi.id = 'google-api';
+    scriptApi.src = 'https://apis.google.com/js/api.js';
+
+    const scriptGis = document.createElement('script');
+    scriptGis.onload = () => {
+      gisLoaded = true;
+    };
+    scriptGis.src = 'https://accounts.google.com/gsi/client';
+
+    document.body.appendChild(scriptApi);
+    document.body.appendChild(scriptGis);
+
+    let count = 0;
+    const checkIfLoaded = () => {
+      if (count > 10) {
+        reject(new Error('Timeout'));
+        return;
+      }
+
+      if (gisLoaded && pickerLoaded) {
+        resolve();
+        return;
+      }
+
+      count += 1;
+      setTimeout(checkIfLoaded, 1500);
+    };
+
+    checkIfLoaded();
+  });
+}
+
+function selectFile(accessToken) {
+  return new Promise((resolve) => {
+    const callback = (event) => {
+      if (!event || event?.action !== 'picked') return;
+
+      const [doc] = event.docs;
+      resolve(doc);
+    };
+
+    const picker = new window.google.picker.PickerBuilder()
+      .addView(
+        new window.google.picker.DocsView(
+          window.google.picker.ViewId.SPREADSHEETS
+        ).setMode(window.google.picker.DocsViewMode.LIST)
+      )
+      .setAppId(secrets.googleProjectId)
+      .setOAuthToken(accessToken)
+      .setDeveloperKey(secrets.googleApiKey)
+      .setCallback(callback)
+      .build();
+    picker.setVisible(true);
+
+    window.gDrivePicker = picker;
+  });
+}
+
+export default async function () {
+  await injectFilePicker();
+
+  const { sessionToken, session } = await browser.storage.local.get([
+    'sessionToken',
+    'session',
+  ]);
+  if (!sessionToken || !sessionToken.access) return null;
+
+  const isGoogleProvider =
+    session?.user?.user_metadata?.iss.includes('googleapis.com');
+  if (!isGoogleProvider) return null;
+
+  const result = await selectFile(sessionToken.access);
+
+  return result;
+}

+ 54 - 0
src/utils/shared.js

@@ -598,6 +598,59 @@ export const tasks = {
       dataFrom: 'data-columns',
     },
   },
+  'google-sheets-drive': {
+    name: 'Google sheets (GDrive)',
+    description: 'Read Google Sheets data',
+    icon: 'riDriveFill',
+    component: 'BlockBasic',
+    editComponent: 'EditGoogleSheetsDrive',
+    category: 'onlineServices',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    refDataKeys: ['customData', 'range', 'spreadsheetId', 'sheetName'],
+    autocomplete: ['refKey'],
+    data: {
+      disableBlock: false,
+      range: '',
+      refKey: '',
+      type: 'get',
+      customData: '',
+      description: '',
+      spreadsheetId: '',
+      dataColumn: '',
+      inputSpreadsheetId: 'connected',
+      saveData: true,
+      sheetName: '',
+      assignVariable: false,
+      variableName: '',
+      firstRowAsKey: false,
+      keysAsFirstRow: true,
+      valueInputOption: 'RAW',
+      InsertDataOption: 'INSERT_ROWS',
+      dataFrom: 'data-columns',
+    },
+  },
+  'google-drive': {
+    name: 'Google drive',
+    description: 'Upload files to Google Drive',
+    icon: 'riDriveLine',
+    component: 'BlockBasic',
+    editComponent: 'EditGoogleDrive',
+    category: 'onlineServices',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    refDataKeys: [],
+    autocomplete: ['refKey'],
+    data: {
+      disableBlock: false,
+      action: 'upload',
+      filePaths: [],
+    },
+  },
   conditions: {
     name: 'Conditions',
     description: 'Conditional block',
@@ -950,6 +1003,7 @@ export const tasks = {
       saveData: true,
       assignVariable: false,
       variableName: '',
+      saveToGDrive: false,
     },
   },
   'press-key': {

+ 80 - 0
src/workflowEngine/blocksHandler/handlerGoogleDrive.js

@@ -0,0 +1,80 @@
+import browser from 'webextension-polyfill';
+import { fetchGapi, validateOauthToken } from '@/utils/api';
+import getFile from '@/utils/getFile';
+import renderString from '../templating/renderString';
+
+function getFilename(url) {
+  try {
+    const filename = new URL(url).pathname.split('/').pop();
+    const hasExtension = /\.[0-9a-z]+$/i.test(filename);
+
+    if (!hasExtension) return null;
+
+    return filename;
+  } catch (e) {
+    return null;
+  }
+}
+
+export async function googleDrive({ id, data }, { refData }) {
+  const { sessionToken } = await browser.storage.local.get('sessionToken');
+  if (!sessionToken) throw new Error("You haven't connect Google Drive");
+
+  await validateOauthToken();
+
+  const resultPromise = data.filePaths.map(async (item) => {
+    let path = (await renderString(item.path, refData, this.engine.isPopup))
+      .value;
+    if (item.type === 'downloadId') {
+      const [downloadItem] = await browser.downloads.search({
+        id: +path,
+        exists: true,
+        state: 'complete',
+      });
+      if (!downloadItem || !downloadItem.filename)
+        throw new Error(`Can't find download item with "${item.path}" id`);
+
+      path = downloadItem.filename;
+    }
+
+    const name =
+      (await renderString(item.name || '', refData, this.engine.isPopup))
+        .value || getFilename(path);
+
+    const blob = await getFile(path, { returnValue: true });
+    const { response: sessionResponse } = await fetchGapi(
+      'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable',
+      {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify({
+          name,
+          mimeType: blob.type,
+        }),
+      },
+      { response: true }
+    );
+    const locationUri = sessionResponse.headers.get('location');
+
+    const formData = new FormData();
+
+    formData.append('file', blob);
+
+    const result = await fetchGapi(locationUri, {
+      method: 'PUT',
+      body: formData,
+    });
+
+    return result;
+  });
+  const result = await Promise.all(resultPromise);
+
+  return {
+    data: result,
+    nextBlockId: this.getBlockConnections(id),
+  };
+}
+
+export default googleDrive;

+ 81 - 42
src/workflowEngine/blocksHandler/handlerGoogleSheets.js

@@ -1,4 +1,4 @@
-import { googleSheets } from '@/utils/api';
+import googleSheetsApi from '@/utils/googleSheetsApi';
 import {
   convert2DArrayToArrayObj,
   convertArrObjTo2DArr,
@@ -6,11 +6,19 @@ import {
   parseJSON,
 } from '@/utils/helper';
 
-async function getSpreadsheetValues({ spreadsheetId, range, firstRowAsKey }) {
-  const response = await googleSheets.getValues({ spreadsheetId, range });
-  const result = await response.json();
+async function getSpreadsheetValues({
+  spreadsheetId,
+  range,
+  firstRowAsKey,
+  isDriveSheet,
+}) {
+  const response = await googleSheetsApi(isDriveSheet).getValues({
+    spreadsheetId,
+    range,
+  });
 
-  if (!response.ok) {
+  const result = isDriveSheet ? response : await response.json();
+  if (!isDriveSheet && !response.ok) {
     throw new Error(result.message);
   }
 
@@ -20,11 +28,14 @@ async function getSpreadsheetValues({ spreadsheetId, range, firstRowAsKey }) {
 
   return sheetsData;
 }
-async function getSpreadsheetRange({ spreadsheetId, range }) {
-  const response = await googleSheets.getRange({ spreadsheetId, range });
-  const result = await response.json();
+async function getSpreadsheetRange({ spreadsheetId, range, isDriveSheet }) {
+  const response = await googleSheetsApi(isDriveSheet).getRange({
+    spreadsheetId,
+    range,
+  });
 
-  if (!response.ok) {
+  const result = isDriveSheet ? response : await response.json();
+  if (!isDriveSheet && !response.ok) {
     throw new Error(result.message);
   }
 
@@ -35,11 +46,14 @@ async function getSpreadsheetRange({ spreadsheetId, range }) {
 
   return data;
 }
-async function clearSpreadsheetValues({ spreadsheetId, range }) {
-  const response = await googleSheets.clearValues({ spreadsheetId, range });
-  const result = await response.json();
+async function clearSpreadsheetValues({ spreadsheetId, range, isDriveSheet }) {
+  const response = await googleSheetsApi(isDriveSheet).clearValues({
+    spreadsheetId,
+    range,
+  });
 
-  if (!response.ok) {
+  const result = isDriveSheet ? response : await response.json();
+  if (!isDriveSheet && !response.ok) {
     throw new Error(result.message);
   }
 
@@ -51,6 +65,7 @@ async function updateSpreadsheetValues(
     append,
     dataFrom,
     customData,
+    isDriveSheet,
     spreadsheetId,
     keysAsFirstRow,
     insertDataOption,
@@ -91,7 +106,7 @@ async function updateSpreadsheetValues(
     });
   }
 
-  const response = await googleSheets.updateValues({
+  const response = await googleSheetsApi(isDriveSheet).updateValues({
     range,
     append,
     spreadsheetId,
@@ -101,46 +116,70 @@ async function updateSpreadsheetValues(
     },
   });
 
-  if (!response.ok) {
-    const error = await response.json();
-
-    throw new Error(error.message);
+  const result = isDriveSheet ? response : await response.json();
+  if (!isDriveSheet && !response.ok) {
+    throw new Error(result.message);
   }
 }
 
 export default async function ({ data, id }, { refData }) {
-  if (isWhitespace(data.spreadsheetId)) throw new Error('empty-spreadsheet-id');
-  if (isWhitespace(data.range)) throw new Error('empty-spreadsheet-range');
+  const isNotCreateAction = data.type !== 'create';
 
-  let result = [];
-  if (data.type === 'get') {
-    const spreadsheetValues = await getSpreadsheetValues(data);
-
-    result = spreadsheetValues;
+  if (isWhitespace(data.spreadsheetId) && isNotCreateAction)
+    throw new Error('empty-spreadsheet-id');
+  if (isWhitespace(data.range) && isNotCreateAction)
+    throw new Error('empty-spreadsheet-range');
 
-    if (data.refKey && !isWhitespace(data.refKey)) {
-      refData.googleSheets[data.refKey] = spreadsheetValues;
-    }
-  } else if (data.type === 'getRange') {
-    result = await getSpreadsheetRange(data);
-
-    if (data.assignVariable) {
-      this.setVariable(data.variableName, result);
-    }
-    if (data.saveData) {
-      this.addDataToColumn(data.dataColumn, result);
-    }
-  } else if (['update', 'append'].includes(data.type)) {
+  let result = [];
+  const handleUpdate = async (append = false) => {
     result = await updateSpreadsheetValues(
       {
         ...data,
-        append: data.type === 'append',
+        append,
       },
       refData.table
     );
-  } else if (data.type === 'clear') {
-    result = await clearSpreadsheetValues(data);
-  }
+  };
+  const actionHandlers = {
+    get: async () => {
+      const spreadsheetValues = await getSpreadsheetValues(data);
+
+      result = spreadsheetValues;
+
+      if (data.refKey && !isWhitespace(data.refKey)) {
+        refData.googleSheets[data.refKey] = spreadsheetValues;
+      }
+    },
+    getRange: async () => {
+      result = await getSpreadsheetRange(data);
+
+      if (data.assignVariable) {
+        this.setVariable(data.variableName, result);
+      }
+      if (data.saveData) {
+        this.addDataToColumn(data.dataColumn, result);
+      }
+    },
+    update: () => handleUpdate(),
+    append: () => handleUpdate(true),
+    clear: async () => {
+      result = await clearSpreadsheetValues(data);
+    },
+    create: async () => {
+      const { spreadsheetId } = await googleSheetsApi(true).create(
+        data.sheetName
+      );
+      result = spreadsheetId;
+
+      if (data.assignVariable) {
+        this.setVariable(data.variableName, result);
+      }
+      if (data.saveData) {
+        this.addDataToColumn(data.dataColumn, result);
+      }
+    },
+  };
+  await actionHandlers[data.type]();
 
   return {
     data: result,

+ 6 - 0
src/workflowEngine/blocksHandler/handlerGoogleSheetsDrive.js

@@ -0,0 +1,6 @@
+import handlerGoogleSheets from './handlerGoogleSheets';
+
+export default function (blockData, additionalData) {
+  blockData.data.isDriveSheet = true;
+  return handlerGoogleSheets.call(this, blockData, additionalData);
+}

+ 12 - 6
src/workflowEngine/blocksHandler/handlerJavascriptCode.js

@@ -213,12 +213,18 @@ export async function javascriptCode({ outputs, data, ...block }, { refData }) {
       insert = result.columns.insert;
     }
 
-    if (insert && result.columns.data) {
-      const params = Array.isArray(result.columns.data)
-        ? result.columns.data
-        : [result.columns.data];
-
-      this.addDataToColumn(params);
+    const columnData = result.columns.data;
+    if (insert && columnData) {
+      const columnDataObj =
+        typeof columnData === 'string'
+          ? parseJSON(columnData, null)
+          : columnData;
+      if (columnDataObj) {
+        const params = Array.isArray(columnDataObj)
+          ? columnDataObj
+          : [columnDataObj];
+        this.addDataToColumn(params);
+      }
     }
   }
 

+ 74 - 84
yarn.lock

@@ -1555,7 +1555,7 @@
   dependencies:
     "@types/node" "*"
 
-"@types/json-schema@*", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
+"@types/json-schema@*", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
   version "7.0.11"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
   integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
@@ -1682,12 +1682,12 @@
     d3-selection "^3.0.0"
     d3-zoom "^3.0.0"
 
-"@vue-flow/core@^1.12.1":
-  version "1.12.1"
-  resolved "https://registry.yarnpkg.com/@vue-flow/core/-/core-1.12.1.tgz#f0c6ad4278a7be07e13b924ccb8942339ddaf582"
-  integrity sha512-1Rl3CQ6JS5Wz+s4qhx7KwY4q4JCoJlry92JSwKP1d+YNRll3vncSeBhdk/BjoMxlvLWKXDh/2nJDrc0t4zRqPQ==
+"@vue-flow/core@^1.12.5":
+  version "1.12.5"
+  resolved "https://registry.yarnpkg.com/@vue-flow/core/-/core-1.12.5.tgz#9d486876f603117760ee177f8d9f837664732f8c"
+  integrity sha512-SYT8QSVpHvd/ByXYy/ZuDTmbzBsY8hZmldF+4VL0kCr8uksJ/i1h8E4/VVA9ffvQpboNndLdyitSj96yfVxuuw==
   dependencies:
-    "@vueuse/core" "^9.6.0"
+    "@vueuse/core" "^9.11.0"
     d3-drag "^3.0.0"
     d3-selection "^3.0.0"
     d3-zoom "^3.0.0"
@@ -1787,14 +1787,14 @@
   resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.45.tgz#a3fffa7489eafff38d984e23d0236e230c818bc2"
   integrity sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==
 
-"@vueuse/core@^9.6.0":
-  version "9.10.0"
-  resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.10.0.tgz#2ef6e55ca773c5b2db1e3f13b8292af96dd32214"
-  integrity sha512-CxMewME07qeuzuT/AOIQGv0EhhDoojniqU6pC3F8m5VC76L47UT18DcX88kWlP3I7d3qMJ4u/PD8iSRsy3bmNA==
+"@vueuse/core@^9.11.0":
+  version "9.11.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.11.0.tgz#4871e795567c142353c4ec62694c02bf7583e3bc"
+  integrity sha512-7yZJ8LNOssA8ZmeSjd4F+wbFBA4csiP4TiaXgruqg1H4PAtzSkv93PPwFLvQkSnfo3Bar+e+6QoRvWjhz7l2Xg==
   dependencies:
     "@types/web-bluetooth" "^0.0.16"
-    "@vueuse/metadata" "9.10.0"
-    "@vueuse/shared" "9.10.0"
+    "@vueuse/metadata" "9.11.0"
+    "@vueuse/shared" "9.11.0"
     vue-demi "*"
 
 "@vueuse/head@^1.0.22":
@@ -1807,10 +1807,10 @@
     "@unhead/ssr" "^1.0.9"
     "@unhead/vue" "^1.0.9"
 
-"@vueuse/metadata@9.10.0":
-  version "9.10.0"
-  resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.10.0.tgz#1a5eb94ca755bd8e666505f47da7d88969cffdc7"
-  integrity sha512-G5VZhgTCapzU9rv0Iq2HBrVOSGzOKb+OE668NxhXNcTjUjwYxULkEhAw70FtRLMZc+hxcFAzDZlKYA0xcwNMuw==
+"@vueuse/metadata@9.11.0":
+  version "9.11.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.11.0.tgz#07133eb3d78cbea376b6ad216950b4bbfafd041c"
+  integrity sha512-HhtG2SWkcfZBLbamHdvLn7jKOCFpw/ifXjVTd5ilFkj98WVUk/3UTQ03wF1XIkuhSO4+b45hD2lfG9/GdKCF7w==
 
 "@vueuse/rxjs@^9.1.1":
   version "9.10.0"
@@ -1827,6 +1827,13 @@
   dependencies:
     vue-demi "*"
 
+"@vueuse/shared@9.11.0":
+  version "9.11.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-9.11.0.tgz#8cf7c64789040e90d0cb5c8ac73cc9d252f7306c"
+  integrity sha512-8lO7wD5abYxupKy2KynH1pSgP715ky6iCrWYb8aX2AuAVi9uHXj7qE1dw6BnmArSaLHci4x9iuzWPCpAzUkC/A==
+  dependencies:
+    vue-demi "*"
+
 "@vuex-orm/core@^0.36.4":
   version "0.36.4"
   resolved "https://registry.yarnpkg.com/@vuex-orm/core/-/core-0.36.4.tgz#9e2b1b8dfd74c2a508f1862ffa3e4a2c1e4cc60c"
@@ -1955,22 +1962,20 @@
     "@webassemblyjs/ast" "1.11.1"
     "@xtuc/long" "4.2.2"
 
-"@webpack-cli/configtest@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.2.0.tgz#7b20ce1c12533912c3b217ea68262365fa29a6f5"
-  integrity sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==
+"@webpack-cli/configtest@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.0.1.tgz#a69720f6c9bad6aef54a8fa6ba9c3533e7ef4c7f"
+  integrity sha512-njsdJXJSiS2iNbQVS0eT8A/KPnmyH4pv1APj2K0d1wrZcBLw+yppxOy4CGqa0OxDJkzfL/XELDhD8rocnIwB5A==
 
-"@webpack-cli/info@^1.5.0":
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-1.5.0.tgz#6c78c13c5874852d6e2dd17f08a41f3fe4c261b1"
-  integrity sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==
-  dependencies:
-    envinfo "^7.7.3"
+"@webpack-cli/info@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.1.tgz#eed745799c910d20081e06e5177c2b2569f166c0"
+  integrity sha512-fE1UEWTwsAxRhrJNikE7v4EotYflkEhBL7EbajfkPlf6E37/2QshOy/D48Mw8G5XMFlQtS6YV42vtbG9zBpIQA==
 
-"@webpack-cli/serve@^1.7.0":
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.7.0.tgz#e1993689ac42d2b16e9194376cfb6753f6254db1"
-  integrity sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==
+"@webpack-cli/serve@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.1.tgz#34bdc31727a1889198855913db2f270ace6d7bf8"
+  integrity sha512-0G7tNyS+yW8TdgHwZKlDWYXFA6OJQnoLCQvYKkQP0Q2X205PSQ6RNUj0M+1OB/9gRQaUZ/ccYfaxd0nhaWKfjw==
 
 "@xtuc/ieee754@^1.2.0":
   version "1.2.0"
@@ -2222,7 +2227,7 @@ array.prototype.flat@^1.3.1:
     es-abstract "^1.20.4"
     es-shim-unscopables "^1.0.0"
 
-array.prototype.flatmap@^1.3.0:
+array.prototype.flatmap@^1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz#1aae7903c2100433cb8261cd4ed310aab5c4a183"
   integrity sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==
@@ -2264,15 +2269,13 @@ available-typed-arrays@^1.0.5:
   resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
   integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==
 
-babel-loader@^8.2.2:
-  version "8.3.0"
-  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.3.0.tgz#124936e841ba4fe8176786d6ff28add1f134d6a8"
-  integrity sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==
+babel-loader@^9.1.2:
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.2.tgz#a16a080de52d08854ee14570469905a5fc00d39c"
+  integrity sha512-mN14niXW43tddohGl8HPu5yfQq70iUThvFL/4QzESA7GcZoC0eVOhvWdQ8+3UlSjaDE9MVtsW9mxDY07W7VpVA==
   dependencies:
-    find-cache-dir "^3.3.1"
-    loader-utils "^2.0.0"
-    make-dir "^3.1.0"
-    schema-utils "^2.6.5"
+    find-cache-dir "^3.3.2"
+    schema-utils "^4.0.0"
 
 babel-plugin-polyfill-corejs2@^0.3.3:
   version "0.3.3"
@@ -2627,11 +2630,6 @@ commander@^2.20.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
-commander@^7.0.0:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
-  integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
-
 commander@^8.3.0:
   version "8.3.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
@@ -3385,14 +3383,14 @@ eslint-module-utils@^2.7.4:
   dependencies:
     debug "^3.2.7"
 
-eslint-plugin-import@^2.26.0:
-  version "2.27.4"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.27.4.tgz#319c2f6f6580e1678d674a258ee5e981c10cc25b"
-  integrity sha512-Z1jVt1EGKia1X9CnBCkpAOhWy8FgQ7OmJ/IblEkT82yrFU/xJaxwujaTzLWqigewwynRQ9mmHfX9MtAfhxm0sA==
+eslint-plugin-import@^2.27.5:
+  version "2.27.5"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz#876a6d03f52608a3e5bb439c2550588e51dd6c65"
+  integrity sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==
   dependencies:
     array-includes "^3.1.6"
     array.prototype.flat "^1.3.1"
-    array.prototype.flatmap "^1.3.0"
+    array.prototype.flatmap "^1.3.1"
     debug "^3.2.7"
     doctrine "^2.1.0"
     eslint-import-resolver-node "^0.3.7"
@@ -3755,7 +3753,7 @@ finalhandler@1.2.0:
     statuses "2.0.1"
     unpipe "~1.0.0"
 
-find-cache-dir@^3.3.1:
+find-cache-dir@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b"
   integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==
@@ -4299,10 +4297,10 @@ interpret@^1.4.0:
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
   integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
 
-interpret@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
-  integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
+interpret@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4"
+  integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==
 
 ipaddr.js@1.9.1:
   version "1.9.1"
@@ -4847,7 +4845,7 @@ magic-string@^0.25.7:
   dependencies:
     sourcemap-codec "^1.4.8"
 
-make-dir@^3.0.2, make-dir@^3.1.0:
+make-dir@^3.0.2:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
   integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
@@ -5371,10 +5369,10 @@ pify@^4.0.1:
   resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
   integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
 
-pinia@^2.0.28:
-  version "2.0.28"
-  resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.0.28.tgz#887c982d854972042d9bdfd5bc4fad3b9d6ab02a"
-  integrity sha512-YClq9DkqCblq9rlyUual7ezMu/iICWdBtfJrDt4oWU9Zxpijyz7xB2xTwx57DaBQ96UGvvTMORzALr+iO5PVMw==
+pinia@^2.0.29:
+  version "2.0.29"
+  resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.0.29.tgz#b7e672980fdb14e03839e8139ce55ca97843679d"
+  integrity sha512-5z/KpFecq/cIgfeTnulJXldiLcTITRkTe3N58RKYSj0Pc1EdR6oyCdnf5A9jLoVwBqX5LtHhd0kGlpzWvk9oiQ==
   dependencies:
     "@vue/devtools-api" "^6.4.5"
     vue-demi "*"
@@ -5719,12 +5717,12 @@ readdirp@~3.6.0:
   dependencies:
     picomatch "^2.2.1"
 
-rechoir@^0.7.0:
-  version "0.7.1"
-  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.7.1.tgz#9478a96a1ca135b5e88fc027f03ee92d6c645686"
-  integrity sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==
+rechoir@^0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22"
+  integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==
   dependencies:
-    resolve "^1.9.0"
+    resolve "^1.20.0"
 
 regenerate-unicode-properties@^10.1.0:
   version "10.1.0"
@@ -5831,7 +5829,7 @@ resolve-from@^5.0.0:
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69"
   integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
 
-resolve@^1.1.7, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.1, resolve@^1.9.0:
+resolve@^1.1.7, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.1:
   version "1.22.1"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
   integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
@@ -5925,15 +5923,6 @@ safe-regex-test@^1.0.0:
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
-schema-utils@^2.6.5:
-  version "2.7.1"
-  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7"
-  integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==
-  dependencies:
-    "@types/json-schema" "^7.0.5"
-    ajv "^6.12.4"
-    ajv-keywords "^3.5.2"
-
 schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281"
@@ -6723,22 +6712,23 @@ webextension-polyfill@^0.10.0:
   resolved "https://registry.yarnpkg.com/webextension-polyfill/-/webextension-polyfill-0.10.0.tgz#ccb28101c910ba8cf955f7e6a263e662d744dbb8"
   integrity sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==
 
-webpack-cli@^4.10.0:
-  version "4.10.0"
-  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.10.0.tgz#37c1d69c8d85214c5a65e589378f53aec64dab31"
-  integrity sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==
+webpack-cli@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.0.1.tgz#95fc0495ac4065e9423a722dec9175560b6f2d9a"
+  integrity sha512-S3KVAyfwUqr0Mo/ur3NzIp6jnerNpo7GUO6so51mxLi1spqsA17YcMXy0WOIJtBSnj748lthxC6XLbNKh/ZC+A==
   dependencies:
     "@discoveryjs/json-ext" "^0.5.0"
-    "@webpack-cli/configtest" "^1.2.0"
-    "@webpack-cli/info" "^1.5.0"
-    "@webpack-cli/serve" "^1.7.0"
+    "@webpack-cli/configtest" "^2.0.1"
+    "@webpack-cli/info" "^2.0.1"
+    "@webpack-cli/serve" "^2.0.1"
     colorette "^2.0.14"
-    commander "^7.0.0"
+    commander "^9.4.1"
     cross-spawn "^7.0.3"
+    envinfo "^7.7.3"
     fastest-levenshtein "^1.0.12"
     import-local "^3.0.2"
-    interpret "^2.2.0"
-    rechoir "^0.7.0"
+    interpret "^3.1.1"
+    rechoir "^0.8.0"
     webpack-merge "^5.7.3"
 
 webpack-dev-middleware@^5.3.1: