Explorar o código

feat: add google drive block

Ahmad Kholid %!s(int64=2) %!d(string=hai) anos
pai
achega
6a93137960

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

@@ -0,0 +1,119 @@
+<template>
+  <div>
+    <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>
+  </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 { 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 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>

+ 1 - 1
src/components/newtab/workflow/edit/EditGoogleSheetsDrive.vue

@@ -1,5 +1,5 @@
 <template>
-  <div v-if="!store.isGDriveConnected">
+  <div v-if="!store.integrations.googleDrive">
     <p>
       You haven't
       <a href="https://google.com" target="_blank" class="underline"

+ 2 - 0
src/lib/vRemixicon.js

@@ -94,6 +94,7 @@ import {
   riFileListLine,
   riDragDropLine,
   riDriveLine,
+  riDriveFill,
   riClipboardLine,
   riCheckDoubleLine,
   riDoubleQuotesL,
@@ -231,6 +232,7 @@ export const icons = {
   riFileListLine,
   riDragDropLine,
   riDriveLine,
+  riDriveFill,
   riClipboardLine,
   riCheckDoubleLine,
   riDoubleQuotesL,

+ 7 - 0
src/locales/en/blocks.json

@@ -402,6 +402,13 @@
         "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",

+ 30 - 5
src/stores/main.js

@@ -28,9 +28,14 @@ export const useStore = defineStore('main', {
         snapGrid: { 0: 15, 1: 15 },
       },
     },
+    integrations: {
+      googleDrive: false,
+    },
+    integrationsRetrieved: {
+      googleDrive: false,
+    },
     retrieved: true,
     connectedSheets: [],
-    isGDriveConnected: false,
     connectedSheetsRetrieved: false,
   }),
   actions: {
@@ -44,16 +49,36 @@ 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 { sessionToken } = await browser.storage.local.get(
+          'sessionToken'
+        );
+        if (!sessionToken) return;
+
+        const response = await fetch(
+          `https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${sessionToken.access}`
+        );
+        if (!response.ok) throw new Error(response.statusText);
+
+        const result = response.json();
+        this.integrations.googleDrive =
+          result.scope.includes('auth/drive.file');
+      } catch (error) {
+        console.error(error);
+      }
+    },
     async getConnectedSheets() {
       try {
         if (this.connectedSheetsRetrieved) return;
 
-        // const query = encodeURIComponent('mimeType="application/vnd.google-apps.spreadsheet"');
         const result = await fetchGapi(
-          `https://www.googleapis.com/drive/v3/files?q=${''}`
+          'https://www.googleapis.com/drive/v3/files'
         );
 
-        this.isGDriveConnected = true;
+        this.integrations.googleDrive = true;
         this.connectedSheets = result.files.filter(
           (file) => file.mimeType === 'application/vnd.google-apps.spreadsheet'
         );
@@ -62,7 +87,7 @@ export const useStore = defineStore('main', {
           error.message === 'no-scope' ||
           error.message.includes('insufficient authentication')
         ) {
-          this.isGDriveConnected = false;
+          this.integrations.googleDrive = false;
         }
 
         console.error(error);

+ 48 - 2
src/utils/api.js

@@ -146,6 +146,44 @@ export async function getUserWorkflows(useCache = true) {
   );
 }
 
+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');
@@ -162,10 +200,14 @@ export async function fetchGapi(url, resource = {}, options = {}) {
       `${origin}${pathname}?${searchParams.toString()}`,
       resource
     );
-    const result = await response.json();
+
+    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');
+      result?.error?.message.includes('insufficient authentication scopes');
     if (
       (response.status === 401 || insufficientScope) &&
       sessionToken.refresh
@@ -197,6 +239,10 @@ export async function fetchGapi(url, resource = {}, options = {}) {
       throw new Error(result?.error?.message, { cause: result });
     }
 
+    if (options?.response) {
+      return { response, result };
+    }
+
     return result;
   };
 

+ 21 - 1
src/utils/shared.js

@@ -609,7 +609,7 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
-    refDataKeys: ['customData', 'range', 'spreadsheetId'],
+    refDataKeys: ['customData', 'range', 'spreadsheetId', 'sheetName'],
     autocomplete: ['refKey'],
     data: {
       disableBlock: false,
@@ -632,6 +632,25 @@ export const tasks = {
       dataFrom: 'data-columns',
     },
   },
+  'google-drive': {
+    name: 'Google drive',
+    description: 'Upload files to Google Drive',
+    icon: 'riDriveFill',
+    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',
@@ -984,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;

+ 6 - 2
src/workflowEngine/blocksHandler/handlerGoogleSheets.js

@@ -123,8 +123,12 @@ async function updateSpreadsheetValues(
 }
 
 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';
+
+  if (isWhitespace(data.spreadsheetId) && isNotCreateAction)
+    throw new Error('empty-spreadsheet-id');
+  if (isWhitespace(data.range) && isNotCreateAction)
+    throw new Error('empty-spreadsheet-range');
 
   let result = [];
   const handleUpdate = async (append = false) => {