Browse Source

feat: add i18n

Ahmad Kholid 3 years ago
parent
commit
0cf8903852
59 changed files with 1315 additions and 524 deletions
  1. 3 1
      package.json
  2. 14 13
      src/background/workflow-engine/blocks-handler.js
  3. 8 10
      src/background/workflow-engine/index.js
  4. 38 23
      src/components/newtab/app/AppSidebar.vue
  5. 10 3
      src/components/newtab/logs/LogsDataViewer.vue
  6. 27 16
      src/components/newtab/logs/LogsFilters.vue
  7. 2 2
      src/components/newtab/shared/SharedCard.vue
  8. 6 3
      src/components/newtab/shared/SharedLogsTable.vue
  9. 5 2
      src/components/newtab/shared/SharedWorkflowState.vue
  10. 10 8
      src/components/newtab/workflow/WorkflowActions.vue
  11. 8 5
      src/components/newtab/workflow/WorkflowBuilder.vue
  12. 9 4
      src/components/newtab/workflow/WorkflowDataColumns.vue
  13. 18 6
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  14. 4 0
      src/components/newtab/workflow/WorkflowEditBlock.vue
  15. 4 1
      src/components/newtab/workflow/WorkflowGlobalData.vue
  16. 5 2
      src/components/newtab/workflow/WorkflowRunning.vue
  17. 14 4
      src/components/newtab/workflow/WorkflowSettings.vue
  18. 0 84
      src/components/newtab/workflow/WorkflowTask.vue
  19. 6 8
      src/components/newtab/workflow/edit/EditAttributeValue.vue
  20. 6 2
      src/components/newtab/workflow/edit/EditCloseTab.vue
  21. 9 5
      src/components/newtab/workflow/edit/EditElementExists.vue
  22. 12 14
      src/components/newtab/workflow/edit/EditForms.vue
  23. 11 7
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  24. 8 27
      src/components/newtab/workflow/edit/EditJavascriptCode.vue
  25. 22 20
      src/components/newtab/workflow/edit/EditLoopData.vue
  26. 10 6
      src/components/newtab/workflow/edit/EditNewTab.vue
  27. 8 7
      src/components/newtab/workflow/edit/EditNewWindow.vue
  28. 9 6
      src/components/newtab/workflow/edit/EditScrollElement.vue
  29. 12 4
      src/components/newtab/workflow/edit/EditSwitchTo.vue
  30. 6 2
      src/components/newtab/workflow/edit/EditTakeScreenshot.vue
  31. 27 24
      src/components/newtab/workflow/edit/EditTrigger.vue
  32. 5 2
      src/components/newtab/workflow/edit/EditTriggerEvent.vue
  33. 15 10
      src/components/newtab/workflow/edit/EditWebhook.vue
  34. 16 14
      src/components/ui/UiDialog.vue
  35. 7 6
      src/components/ui/UiPagination.vue
  36. 52 0
      src/lib/vue-i18n.js
  37. 254 0
      src/locales/en/blocks.json
  38. 51 0
      src/locales/en/common.json
  39. 126 0
      src/locales/en/newtab.json
  40. 13 0
      src/locales/en/popup.json
  41. 24 16
      src/newtab/App.vue
  42. 2 0
      src/newtab/index.js
  43. 18 16
      src/newtab/pages/Collections.vue
  44. 7 5
      src/newtab/pages/Home.vue
  45. 50 0
      src/newtab/pages/Settings.vue
  46. 28 27
      src/newtab/pages/Workflows.vue
  47. 25 23
      src/newtab/pages/collections/[id].vue
  48. 18 10
      src/newtab/pages/logs.vue
  49. 42 22
      src/newtab/pages/logs/[id].vue
  50. 18 19
      src/newtab/pages/workflows/[id].vue
  51. 6 0
      src/newtab/router.js
  52. 16 9
      src/popup/App.vue
  53. 2 0
      src/popup/index.js
  54. 17 12
      src/popup/pages/Home.vue
  55. 25 2
      src/store/index.js
  56. 4 0
      src/utils/shared.js
  57. 8 1
      src/utils/workflow-data.js
  58. 7 3
      webpack.config.js
  59. 128 8
      yarn.lock

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.6.0",
+  "version": "0.6.1",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -35,6 +35,7 @@
     "tippy.js": "^6.3.1",
     "v-remixicon": "^0.1.1",
     "vue": "3.2.19",
+    "vue-i18n": "^9.2.0-beta.20",
     "vue-prism-editor": "^2.0.0-alpha.2",
     "vue-router": "^4.0.11",
     "vue-virtual-scroller": "^2.0.0-alpha.1",
@@ -47,6 +48,7 @@
     "@babel/eslint-parser": "7.15.7",
     "@babel/plugin-proposal-class-properties": "7.14.5",
     "@babel/preset-env": "7.15.6",
+    "@intlify/vue-i18n-loader": "^4.0.1",
     "@vue/compiler-sfc": "3.2.19",
     "archiver": "^5.3.0",
     "autoprefixer": "10.3.6",

+ 14 - 13
src/background/workflow-engine/blocks-handler.js

@@ -1,12 +1,10 @@
 /* eslint-disable no-underscore-dangle */
 import browser from 'webextension-polyfill';
 import { objectHasKey, fileSaver, isObject } from '@/utils/helper';
-import { tasks } from '@/utils/shared';
 import { executeWebhook } from '@/utils/webhookUtil';
 import executeContentScript from '@/utils/execute-content-script';
 import dataExporter, { generateJSON } from '@/utils/data-exporter';
 import compareBlockValue from '@/utils/compare-block-value';
-import errorMessage from './error-message';
 
 function getBlockConnection(block, index = 1) {
   const blockId = block.outputs[`output_${index}`]?.connections[0]?.node;
@@ -31,13 +29,6 @@ function convertData(data, type) {
 
   return result;
 }
-function generateBlockError(block, code) {
-  const message = errorMessage(code || 'no-tab', tasks[block.name]);
-  const error = new Error(message);
-  error.nextBlockId = getBlockConnection(block);
-
-  return error;
-}
 
 export async function closeTab(block) {
   const nextBlockId = getBlockConnection(block);
@@ -142,7 +133,10 @@ export function goBack(block) {
     const nextBlockId = getBlockConnection(block);
 
     if (!this.tabId) {
-      reject(generateBlockError(block));
+      const error = new Error('no-tab');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
 
       return;
     }
@@ -167,7 +161,10 @@ export function forwardPage(block) {
     const nextBlockId = getBlockConnection(block);
 
     if (!this.tabId) {
-      reject(generateBlockError(block));
+      const error = new Error('no-tab');
+      error.nextBlockId = nextBlockId;
+
+      reject(nextBlockId);
 
       return;
     }
@@ -349,7 +346,7 @@ export async function takeScreenshot(block) {
 
     if (captureActiveTab) {
       if (!this.tabId) {
-        throw new Error(errorMessage('no-tab', block));
+        throw new Error('no-tab');
       }
 
       const [tab] = await browser.tabs.query({
@@ -407,7 +404,11 @@ export async function switchTo(block) {
         nextBlockId,
       };
     }
-    throw new Error(errorMessage('no-iframe-id', block.data));
+
+    const error = new Error('no-iframe-id');
+    error.data = { selector: block.selector };
+
+    throw error;
   } catch (error) {
     error.nextBlockId = nextBlockId;
 

+ 8 - 10
src/background/workflow-engine/index.js

@@ -160,7 +160,7 @@ class WorkflowEngine {
     this.logs.push({
       message,
       type: 'stop',
-      name: 'Workflow is stopped',
+      name: 'stop',
     });
     this.destroy('stopped');
   }
@@ -252,7 +252,7 @@ class WorkflowEngine {
 
     if (!disableTimeoutKeys.includes(block.name)) {
       this.workflowTimeout = setTimeout(() => {
-        if (!this.isDestroyed) this.stop('Workflow stopped because of timeout');
+        if (!this.isDestroyed) this.stop('stop-timeout');
       }, this.workflow.settings.timeout || 120000);
     }
 
@@ -283,8 +283,7 @@ class WorkflowEngine {
           this.workflowTimeout = null;
           this.logs.push({
             type: 'success',
-            name: tasks[block.name].name,
-            data: result.data,
+            name: block.name,
             duration: Math.round(Date.now() - started),
           });
 
@@ -293,8 +292,7 @@ class WorkflowEngine {
           } else {
             this.logs.push({
               type: 'finish',
-              message: 'Workflow finished running',
-              name: 'Finish',
+              name: 'finish',
             });
             this.dispatchEvent('finish');
             this.destroy('success');
@@ -304,7 +302,7 @@ class WorkflowEngine {
           this.logs.push({
             type: 'error',
             message: error.message,
-            name: tasks[block.name].name,
+            name: block.name,
           });
 
           if (
@@ -332,9 +330,9 @@ class WorkflowEngine {
   _sendMessageToTab(block, options = {}) {
     return new Promise((resolve, reject) => {
       if (!this.tabId) {
-        const message = errorMessage('no-tab', tasks[block.name]);
-
-        reject(new Error(message));
+        /* eslint-disable-next-line */
+        reject('no-tab');
+        return;
       }
 
       browser.tabs

+ 38 - 23
src/components/newtab/app/AppSidebar.vue

@@ -36,12 +36,12 @@
       <router-link
         v-for="tab in tabs"
         v-slot="{ href, navigate, isActive }"
-        :key="tab.name"
+        :key="tab.id"
         :to="tab.path"
         custom
       >
         <a
-          v-tooltip:right.group="tab.name"
+          v-tooltip:right.group="t(`common.${tab.id}`, 2)"
           :class="{ 'is-active': isActive }"
           :href="href"
           class="
@@ -64,52 +64,67 @@
       </router-link>
     </div>
     <div class="flex-grow"></div>
-    <a
-      v-tooltip:right="'Documentation'"
-      href="https://github.com/kholid060/automa/wiki"
-      rel="noopener"
-      class="mb-8"
-      target="_blank"
-    >
-      <v-remixicon name="riBookOpenLine" />
-    </a>
-    <a
-      v-tooltip:right="'Github'"
-      href="https://github.com/kholid060/automa"
-      rel="noopener"
-      target="_blank"
-    >
-      <v-remixicon name="riGithubFill" />
-    </a>
+    <ui-popover placement="right" trigger="mouseenter click">
+      <template #trigger>
+        <v-remixicon class="cursor-pointer" name="riInformationLine" />
+      </template>
+      <ui-list class="space-y-1">
+        <ui-list-item
+          tag="a"
+          href="https://github.com/kholid060/automa/wiki"
+          rel="noopener"
+          target="_blank"
+        >
+          <v-remixicon name="riBookOpenLine" class="-ml-1 mr-2" />
+          <span>{{ t('common.docs', 2) }}</span>
+        </ui-list-item>
+        <ui-list-item
+          tag="a"
+          href="https://github.com/kholid060/automa"
+          rel="noopener"
+          target="_blank"
+        >
+          <v-remixicon name="riGithubFill" class="-ml-1 mr-2" />
+          <span>GitHub</span>
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
   </aside>
 </template>
 <script setup>
 import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 
 useGroupTooltip();
+const { t } = useI18n();
 
 const tabs = [
   {
-    name: 'Dashboard',
+    id: 'dashboard',
     icon: 'riHome5Line',
     path: '/',
   },
   {
-    name: 'Workflows',
+    id: 'workflow',
     icon: 'riFlowChart',
     path: '/workflows',
   },
   {
-    name: 'Collections',
+    id: 'collection',
     icon: 'riFolderLine',
     path: '/collections',
   },
   {
-    name: 'Logs',
+    id: 'log',
     icon: 'riHistoryLine',
     path: '/logs',
   },
+  {
+    id: 'settings',
+    icon: 'riSettings3Line',
+    path: '/settings',
+  },
 ];
 const hoverIndicator = ref(null);
 const showHoverIndicator = ref(false);

+ 10 - 3
src/components/newtab/logs/LogsDataViewer.vue

@@ -1,11 +1,15 @@
 <template>
   <div class="flex items-center">
-    <ui-input v-model="fileName" placeholder="File name" title="File name" />
+    <ui-input
+      v-model="fileName"
+      :placeholder="t('common.fileName')"
+      :title="t('common.fileName')"
+    />
     <div class="flex-grow"></div>
     <ui-popover>
       <template #trigger>
         <ui-button variant="accent">
-          <span>Export data</span>
+          <span>{{ t('log.exportData.title') }}</span>
           <v-remixicon name="riArrowDropDownLine" class="ml-2 -mr-1" />
         </ui-button>
       </template>
@@ -17,7 +21,7 @@
           class="cursor-pointer"
           @click="exportData(type.id)"
         >
-          as {{ type.name }}
+          {{ t(`log.exportData.types.${type.id}`) }}
         </ui-list-item>
       </ui-list>
     </ui-popover>
@@ -32,6 +36,7 @@
 </template>
 <script setup>
 import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 import { dataExportTypes } from '@/utils/shared';
@@ -48,6 +53,8 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
+
 const data = Array.isArray(props.log.data)
   ? props.log.data
   : generateJSON(Object.keys(props.log.data), props.log.data);

+ 27 - 16
src/components/newtab/logs/LogsFilters.vue

@@ -2,8 +2,8 @@
   <div class="flex items-center mb-6 space-x-4">
     <ui-input
       :model-value="filters.query"
+      :placeholder="`${t('common.search')}...`"
       prepend-icon="riSearch2Line"
-      placeholder="Search..."
       class="flex-1"
       @change="updateFilters('query', $event)"
     />
@@ -19,7 +19,7 @@
       </ui-button>
       <ui-select
         :model-value="sorts.by"
-        placeholder="Sort by"
+        :placeholder="t('sort.sortBy')"
         @change="updateSorts('by', $event)"
       >
         <option v-for="sort in sortsList" :key="sort.id" :value="sort.id">
@@ -31,25 +31,27 @@
       <template #trigger>
         <ui-button>
           <v-remixicon name="riFilter2Line" class="mr-2 -ml-1" />
-          <span>Filters</span>
+          <span>{{ t('log.filter.title') }}</span>
         </ui-button>
       </template>
       <div class="w-48">
-        <p class="flex-1 mb-2 font-semibold">Filters</p>
-        <p class="mb-2 text-sm text-gray-600">By status</p>
+        <p class="flex-1 mb-2 font-semibold">{{ t('log.filter.title') }}</p>
+        <p class="mb-2 text-sm text-gray-600">{{ t('log.filter.byStatus') }}</p>
         <div class="grid grid-cols-2 gap-2">
           <ui-radio
             v-for="status in filterByStatus"
-            :key="status"
+            :key="status.id"
             :model-value="filters.byStatus"
-            :value="status"
+            :value="status.id"
             class="capitalize text-sm"
             @change="updateFilters('byStatus', $event)"
           >
-            {{ status }}
+            {{ status.name }}
           </ui-radio>
         </div>
-        <p class="mb-1 text-sm text-gray-600 mt-3">By date</p>
+        <p class="mb-1 text-sm text-gray-600 mt-3">
+          {{ t('log.filter.byDate.title') }}
+        </p>
         <ui-select
           :model-value="filters.byDate"
           class="w-full"
@@ -64,6 +66,8 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 defineProps({
   filters: {
     type: Object,
@@ -76,16 +80,23 @@ defineProps({
 });
 const emit = defineEmits(['updateSorts', 'updateFilters']);
 
-const filterByStatus = ['all', 'success', 'stopped', 'error'];
+const { t } = useI18n();
+
+const filterByStatus = [
+  { id: 'all', name: t('common.all') },
+  { id: 'success', name: t('logStatus.success') },
+  { id: 'stopped', name: t('logStatus.stopped') },
+  { id: 'error', name: t('logStatus.error') },
+];
 const filterByDate = [
-  { id: 0, name: 'All' },
-  { id: 1, name: 'Last day' },
-  { id: 7, name: 'Last 7 days' },
-  { id: 30, name: 'Last 30 days' },
+  { id: 0, name: t('common.all') },
+  { id: 1, name: t('log.filter.byDate.items.lastDay') },
+  { id: 7, name: t('log.filter.byDate.items.last7Days') },
+  { id: 30, name: t('log.filter.byDate.items.last30Days') },
 ];
 const sortsList = [
-  { id: 'name', name: 'Name' },
-  { id: 'startedAt', name: 'Created date' },
+  { id: 'name', name: t('sort.name') },
+  { id: 'startedAt', name: t('sort.createdAt') },
 ];
 
 function updateFilters(key, value) {

+ 2 - 2
src/components/newtab/shared/SharedCard.vue

@@ -20,10 +20,10 @@
         <ui-list class="w-36 space-y-1">
           <ui-list-item
             v-for="item in menu"
-            :key="item.name"
+            :key="item.id"
             v-close-popover
             class="cursor-pointer"
-            @click="$emit('menuSelected', { name: item.name, data })"
+            @click="$emit('menuSelected', { id: item.id, data })"
           >
             <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
             <span class="capitalize">{{ item.name }}</span>

+ 6 - 3
src/components/newtab/shared/SharedLogsTable.vue

@@ -13,7 +13,7 @@
         </td>
         <td class="log-time">
           <v-remixicon
-            title="Started date"
+            :title="t('log.startedDate')"
             name="riCalendarLine"
             class="mr-2 inline-block align-middle"
           />
@@ -21,7 +21,7 @@
             {{ formatDate(log.startedAt, 'relative') }}
           </span>
         </td>
-        <td class="log-time" title="Duration">
+        <td class="log-time" :title="t('log.duration')">
           <v-remixicon name="riTimerLine"></v-remixicon>
           <span>{{ countDuration(log.startedAt, log.endedAt) }}</span>
         </td>
@@ -31,7 +31,7 @@
             :title="log.status === 'error' ? getErrorMessage(log) : null"
             class="inline-block py-1 w-16 text-center text-sm rounded-lg"
           >
-            {{ log.status }}
+            {{ t(`logStatus.${log.status}`) }}
           </span>
         </td>
         <slot name="item-append" :log="log" />
@@ -40,6 +40,7 @@
   </table>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import { countDuration } from '@/utils/helper';
 import dayjs from '@/lib/dayjs';
 
@@ -50,6 +51,8 @@ defineProps({
   },
 });
 
+const { t } = useI18n();
+
 const statusColors = {
   error: 'bg-red-200',
   success: 'bg-green-200',

+ 5 - 2
src/components/newtab/shared/SharedWorkflowState.vue

@@ -24,7 +24,7 @@
       </ui-button>
       <ui-button variant="accent" @click="stopWorkflow">
         <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
-        <span>Stop</span>
+        <span>{{ t('common.stop') }}</span>
       </ui-button>
     </div>
     <div class="divide-y bg-box-transparent divide-y px-4 rounded-lg">
@@ -42,6 +42,7 @@
 </template>
 <script setup>
 import browser from 'webextension-polyfill';
+import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
 import { tasks } from '@/utils/shared';
 import dayjs from '@/lib/dayjs';
@@ -53,12 +54,14 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
+
 function getBlock() {
   if (!props.data.state.currentBlock) return [];
 
   if (Array.isArray(props.data.state.currentBlock)) {
     return props.data.state.currentBlock.map((item) => {
-      if (tasks[item.name]) return tasks[item.name];
+      if (tasks[item.name]) return t(`workflow.blocks.${item.name}.name`);
 
       return item;
     });

+ 10 - 8
src/components/newtab/workflow/WorkflowActions.vue

@@ -12,7 +12,7 @@
   </ui-card>
   <ui-card padding="p-1 ml-4">
     <button
-      v-tooltip.group="'Execute'"
+      v-tooltip.group="t('common.execute')"
       icon
       class="hoverable p-2 rounded-lg"
       @click="$emit('execute')"
@@ -62,11 +62,12 @@
         ></span>
       </span>
       <v-remixicon name="riSaveLine" class="mr-2 -ml-1 my-1" />
-      Save
+      {{ t('common.save') }}
     </ui-button>
   </ui-card>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 
 defineProps({
@@ -78,38 +79,39 @@ defineProps({
 defineEmits(['showModal', 'execute', 'rename', 'delete', 'save', 'export']);
 
 useGroupTooltip();
+const { t } = useI18n();
 
 const modalActions = [
   {
     id: 'data-columns',
-    name: 'Data columns',
+    name: t('workflow.dataColumns.title'),
     icon: 'riKey2Line',
   },
   {
     id: 'global-data',
-    name: 'Global data',
+    name: t('common.globalData'),
     icon: 'riDatabase2Line',
   },
   {
     id: 'settings',
-    name: 'Settings',
+    name: t('common.settings'),
     icon: 'riSettings3Line',
   },
 ];
 const moreActions = [
   {
     id: 'export',
-    name: 'Export',
+    name: t('common.export'),
     icon: 'riDownloadLine',
   },
   {
     id: 'rename',
-    name: 'Rename',
+    name: t('common.rename'),
     icon: 'riPencilLine',
   },
   {
     id: 'delete',
-    name: 'Delete',
+    name: t('common.delete'),
     icon: 'riDeleteBin7Line',
   },
 ];

+ 8 - 5
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -8,7 +8,7 @@
     <slot></slot>
     <div class="absolute z-10 p-4 bottom-0 left-0">
       <button
-        v-tooltip.group="'Reset zoom'"
+        v-tooltip.group="t('workflow.editor.resetZoom')"
         class="p-2 rounded-lg bg-white mr-2"
         @click="editor.zoom_reset()"
       >
@@ -16,7 +16,7 @@
       </button>
       <div class="rounded-lg bg-white inline-block">
         <button
-          v-tooltip.group="'Zoom out'"
+          v-tooltip.group="t('workflow.editor.zoomOut')"
           class="p-2 rounded-lg relative z-10"
           @click="editor.zoom_out()"
         >
@@ -24,7 +24,7 @@
         </button>
         <hr class="h-6 border-r inline-block" />
         <button
-          v-tooltip.group="'Zoom in'"
+          v-tooltip.group="t('workflow.editor.zoomIn')"
           class="p-2 rounded-lg"
           @click="editor.zoom_in()"
         >
@@ -56,6 +56,7 @@
 /* eslint-disable camelcase */
 import { onMounted, shallowRef, reactive, getCurrentInstance } from 'vue';
 import emitter from 'tiny-emitter/instance';
+import { useI18n } from 'vue-i18n';
 import { tasks } from '@/utils/shared';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import drawflow from '@/lib/drawflow';
@@ -70,18 +71,19 @@ export default {
   emits: ['load', 'deleteBlock'],
   setup(props, { emit }) {
     useGroupTooltip();
+    const { t } = useI18n();
 
     const contextMenuItems = {
       block: [
         {
           id: 'duplicate',
-          name: 'Duplicate',
+          name: t('workflow.editor.duplicate'),
           icon: 'riFileCopyLine',
           event: 'duplicateBlock',
         },
         {
           id: 'delete',
-          name: 'Delete',
+          name: t('common.delete'),
           icon: 'riDeleteBin7Line',
           event: 'deleteBlock',
         },
@@ -254,6 +256,7 @@ export default {
     });
 
     return {
+      t,
       editor,
       contextMenu,
       dropHandler,

+ 9 - 4
src/components/newtab/workflow/WorkflowDataColumns.vue

@@ -3,12 +3,14 @@
     <ui-input
       v-model.lowercase="state.query"
       autofocus
-      placeholder="Search or add column"
+      :placeholder="t('workflow.dataColumns.placeholder')"
       class="mr-2 flex-1"
       @keyup.enter="addColumn"
       @keyup.esc="$emit('close')"
     />
-    <ui-button variant="accent" @click="addColumn">Add</ui-button>
+    <ui-button variant="accent" @click="addColumn">
+      {{ t('common.add') }}
+    </ui-button>
   </div>
   <ul
     class="space-y-2 overflow-y-auto scroll py-1"
@@ -23,12 +25,12 @@
         :model-value="columns[index].name"
         disabled
         class="flex-1"
-        placeholder="Column name"
+        :placeholder="t('workflow.dataColumns.column.name')"
       />
       <ui-select
         v-model="columns[index].type"
         class="flex-1"
-        placeholder="Data type"
+        :placeholder="t('workflow.dataColumns.column.type')"
       >
         <option v-for="type in dataTypes" :key="type.id" :value="type.id">
           {{ type.name }}
@@ -42,6 +44,7 @@
 </template>
 <script setup>
 import { computed, onMounted, watch, reactive } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { debounce } from '@/utils/helper';
 
 const props = defineProps({
@@ -52,6 +55,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update', 'close']);
 
+const { t } = useI18n();
+
 const dataTypes = [
   { id: 'string', name: 'Text' },
   { id: 'integer', name: 'Number' },

+ 18 - 6
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -2,11 +2,14 @@
   <div class="px-4 flex items-center mb-2 mt-1">
     <ui-popover class="mr-2 h-6">
       <template #trigger>
-        <span title="Workflow icon" class="cursor-pointer">
+        <span
+          :title="t('workflow.sidebar.workflowIcon')"
+          class="cursor-pointer"
+        >
           <v-remixicon :name="workflow.icon" size="26" />
         </span>
       </template>
-      <p class="mb-2">Workflow icon</p>
+      <p class="mb-2">{{ t('workflow.sidebar.workflowIcon') }}</p>
       <div class="grid grid-cols-4 gap-1">
         <span
           v-for="icon in icons"
@@ -24,9 +27,9 @@
   </div>
   <ui-input
     v-model="query"
+    :placeholder="`${t('common.search')}...`"
     prepend-icon="riSearch2Line"
     class="px-4 mt-4 mb-2"
-    placeholder="Search blocks"
   />
   <div class="scroll bg-scroll overflow-auto px-4 flex-1 overflow-auto">
     <template v-for="(items, catId) in taskList" :key="catId">
@@ -41,7 +44,13 @@
         <div
           v-for="block in items"
           :key="block.id"
-          :title="block.description || block.name"
+          :title="
+            t(
+              `workflow.blocks.${block.id}.${
+                block.description ? 'description' : 'name'
+              }`
+            )
+          "
           draggable="true"
           class="
             transform
@@ -61,7 +70,7 @@
             v-if="block.docs"
             :href="`https://github.com/Kholid060/automa/wiki/Blocks#${block.id}`"
             target="_blank"
-            title="Documentation"
+            :title="t('common.docs')"
             rel="noopener"
             class="absolute top-px right-2"
           >
@@ -69,7 +78,7 @@
           </a>
           <v-remixicon :name="block.icon" size="24" class="mb-2" />
           <p class="leading-tight text-overflow">
-            {{ block.name }}
+            {{ t(`workflow.blocks.${block.id}.name`) }}
           </p>
         </div>
       </div>
@@ -78,6 +87,7 @@
 </template>
 <script setup>
 import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { tasks, categories } from '@/utils/shared';
 
 defineProps({
@@ -92,6 +102,8 @@ defineProps({
 });
 defineEmits(['update']);
 
+const { t } = useI18n();
+
 const icons = [
   'riGlobalLine',
   'riFileTextLine',

+ 4 - 0
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -19,6 +19,7 @@
 </template>
 <script>
 import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
 
 const editComponents = require.context(
   './edit',
@@ -46,6 +47,8 @@ export default {
   },
   emits: ['close', 'update'],
   setup(props, { emit }) {
+    const { t } = useI18n();
+
     const blockData = computed({
       get() {
         return props.data.data || {};
@@ -56,6 +59,7 @@ export default {
     });
 
     return {
+      t,
       blockData,
     };
   },

+ 4 - 1
src/components/newtab/workflow/WorkflowGlobalData.vue

@@ -6,7 +6,7 @@
       rel="noopener"
       class="inline-block text-primary"
     >
-      Learn how to access the global data in a block
+      {{ t('message.useDynamicData') }}
     </a>
     <p class="float-right clear-both" title="Characters limit">
       {{ globalData.length }}/{{ maxLength.toLocaleString() }}
@@ -21,6 +21,7 @@
 </template>
 <script setup>
 import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 import { debounce } from '@/utils/helper';
@@ -33,6 +34,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
+const { t } = useI18n();
+
 const maxLength = 1e4;
 const globalData = ref(`${props.workflow.globalData}`);
 

+ 5 - 2
src/components/newtab/workflow/WorkflowRunning.vue

@@ -25,7 +25,7 @@
         </ui-button>
         <ui-button variant="accent" @click="stopWorkflow(item)">
           <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
-          <span>Stop</span>
+          <span>{{ t('common.stop') }}</span>
         </ui-button>
       </div>
       <div class="flex items-center bg-box-transparent px-4 py-2 rounded-lg">
@@ -34,13 +34,14 @@
           <p class="flex-1 ml-2 mr-4">{{ getBlock(item).name }}</p>
           <ui-spinner color="text-accnet" size="20" />
         </template>
-        <p v-else>No block</p>
+        <p v-else>{{ t('message.noBlock') }}</p>
       </div>
     </ui-card>
   </div>
 </template>
 <script setup>
 import browser from 'webextension-polyfill';
+import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
 import { tasks } from '@/utils/shared';
 import dayjs from '@/lib/dayjs';
@@ -52,6 +53,8 @@ defineProps({
   },
 });
 
+const { t } = useI18n();
+
 function getBlock(item) {
   if (!item.state.currentBlock) return {};
 

+ 14 - 4
src/components/newtab/workflow/WorkflowSettings.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="workflow-settings">
     <div class="mb-4">
-      <p class="mb-1">On workflow error</p>
+      <p class="mb-1">{{ t('workflow.settings.onError.title') }}</p>
       <div class="space-x-4">
         <ui-radio
           v-for="item in onError"
@@ -16,7 +16,7 @@
       </div>
     </div>
     <div>
-      <p class="mb-1">Workflow timeout (milliseconds)</p>
+      <p class="mb-1">{{ t('workflow.settings.timeout.title') }}</p>
       <ui-input
         :model-value="workflow.settings.timeout"
         type="number"
@@ -26,6 +26,8 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   workflow: {
     type: Object,
@@ -34,9 +36,17 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
+const { t } = useI18n();
+
 const onError = [
-  { id: 'keep-running', name: 'Keep workflow running' },
-  { id: 'stop-workflow', name: 'Stop workflow' },
+  {
+    id: 'keep-running',
+    name: t('workflow.settings.onError.items.keepRunning'),
+  },
+  {
+    id: 'stop-workflow',
+    name: t('workflow.settings.onError.items.stopWorkflow'),
+  },
 ];
 
 function updateWorkflow(data) {

+ 0 - 84
src/components/newtab/workflow/WorkflowTask.vue

@@ -1,84 +0,0 @@
-<template>
-  <div
-    class="workflow-task rounded-lg group hoverable"
-    :class="{ 'bg-box-transparent': show }"
-  >
-    <div
-      class="
-        flex
-        items-center
-        w-full
-        text-left
-        py-2
-        px-3
-        cursor-pointer
-        rounded-lg
-      "
-      @click="show = !show"
-    >
-      <v-remixicon
-        :rotate="show ? 270 : 180"
-        name="riArrowLeftSLine"
-        class="-ml-1 mr-4 text-gray-600 dark:text-gray-200 transition-transform"
-      />
-      <v-remixicon :name="currentTask.icon" size="22" class="mr-3" />
-      <p class="flex-1 mr-2 text-overflow">
-        {{ task.name || currentTask.name }}
-      </p>
-      <v-remixicon
-        name="riDeleteBin7Line"
-        class="group-hover:visible mr-2 invisible cursor-pointer"
-        size="22"
-        @click.stop="$emit('delete', task)"
-      />
-      <v-remixicon
-        id="drag-handler"
-        name="mdiDrag"
-        style="cursor: grab"
-        @mousedown="show = false"
-      />
-    </div>
-    <transition-expand>
-      <div v-if="show" class="py-2 pr-4 pl-12 max-w-xl">
-        <div class="flex items-center mb-2">
-          <ui-input
-            :model-value="task.name"
-            placeholder="Task name"
-            class="flex-1"
-            @change="updateTask({ name: $event || currentTask.task })"
-          />
-          <ui-input
-            v-if="currentTask.needWebsite"
-            placeholder="Website"
-            class="flex-1 ml-2"
-          />
-        </div>
-        <div v-if="currentTask.needSelector" class="flex items-center">
-          <ui-button icon class="mr-2">
-            <v-remixicon name="riFocus3Line" />
-          </ui-button>
-          <ui-input placeholder="Element selector" class="mr-4 flex-1" />
-          <ui-checkbox>Multiple</ui-checkbox>
-        </div>
-      </div>
-    </transition-expand>
-  </div>
-</template>
-<script setup>
-import { tasks } from '@/utils/shared';
-
-const props = defineProps({
-  task: {
-    type: Object,
-    default: () => ({}),
-  },
-});
-const emit = defineEmits(['delete', 'update']);
-
-const show = ref(false);
-const currentTask = computed(() => tasks[props.task.type]);
-
-function updateTask(data) {
-  emit('update', props.task.id, data);
-}
-</script>

+ 6 - 8
src/components/newtab/workflow/edit/EditAttributeValue.vue

@@ -2,7 +2,7 @@
   <edit-interaction-base v-bind="{ data }" @change="updateData">
     <ui-input
       :model-value="data.attributeName"
-      placeholder="Attribute name"
+      :placeholder="t('workflow.blocks.attribute-value.forms.name')"
       class="mt-3 w-full"
       @change="updateData({ attributeName: $event })"
     />
@@ -11,12 +11,12 @@
       class="mt-3"
       @change="updateData({ saveData: $event })"
     >
-      Save data
+      {{ t('workflow.blocks.attribute-value.forms.checkbox') }}
     </ui-checkbox>
     <div v-if="data.saveData" class="flex items-center mt-1">
       <ui-select
         :model-value="data.dataColumn"
-        placeholder="Data column"
+        :placeholder="t('workflow.blocks.attribute-value.forms.column')"
         class="mr-2 flex-1"
         @change="updateData({ dataColumn: $event })"
       >
@@ -28,11 +28,7 @@
           {{ column.name }}
         </option>
       </ui-select>
-      <ui-button
-        icon
-        title="Data columns"
-        @click="workflow.showDataColumnsModal(true)"
-      >
+      <ui-button icon @click="workflow.showDataColumnsModal(true)">
         <v-remixicon name="riKey2Line" />
       </ui-button>
     </div>
@@ -40,6 +36,7 @@
 </template>
 <script setup>
 import { inject } from 'vue';
+import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -50,6 +47,7 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
 const workflow = inject('workflow');
 
 function updateData(value) {

+ 6 - 2
src/components/newtab/workflow/edit/EditCloseTab.vue

@@ -5,7 +5,7 @@
         :model-value="data.activeTab"
         @change="updateData({ activeTab: $event })"
       >
-        Close active tab
+        {{ t('workflow.blocks.close-tab.activeTab') }}
       </ui-checkbox>
     </div>
     <ui-input
@@ -15,7 +15,7 @@
       @change="updateData({ url: $event })"
     >
       <template #label>
-        URL or match pattern
+        {{ t('workflow.blocks.close-tab.url') }}
         <a
           href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns"
           target="_blank"
@@ -29,6 +29,8 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -37,6 +39,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 9 - 5
src/components/newtab/workflow/edit/EditElementExists.vue

@@ -1,24 +1,24 @@
 <template>
   <ui-input
     :model-value="data.selector"
-    label="Element selector"
+    :label="t('workflow.blocks.element-exists.selector')"
     class="mb-1 w-full"
     @change="updateData({ selector: $event })"
   />
   <div class="flex space-x-2">
     <ui-input
       :model-value="data.tryCount"
+      :title="t('workflow.blocks.element-exists.tryFor.title')"
+      :label="t('workflow.blocks.element-exists.tryFor.label')"
       class="flex-1"
       type="number"
-      title="Try check element exists"
-      label="Try for"
       min="1"
       @change="updateData({ tryCount: +$event })"
     />
     <ui-input
       :model-value="data.timeout"
-      label="Timeout(ms)"
-      title="Timeout for each try"
+      :label="t('workflow.blocks.element-exists.timeout.label')"
+      :title="t('workflow.blocks.element-exists.timeout.title')"
       class="flex-1"
       type="number"
       min="200"
@@ -27,6 +27,8 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -35,6 +37,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 12 - 14
src/components/newtab/workflow/edit/EditForms.vue

@@ -3,11 +3,11 @@
     <ui-select
       :model-value="data.type"
       class="block w-full mt-4 mb-3"
-      placeholder="Form type"
+      :placeholder="t('workflow.blocks.forms.type')"
       @change="updateData({ type: $event })"
     >
-      <option v-for="form in forms" :key="form.id" :value="form.id">
-        {{ form.name }}
+      <option v-for="form in forms" :key="form" :value="form">
+        {{ t(`workflow.blocks.forms.${form}.name`) }}
       </option>
     </ui-select>
     <ui-checkbox
@@ -15,12 +15,12 @@
       :model-value="data.selected"
       @change="updateData({ selected: $event })"
     >
-      Selected
+      {{ t('workflow.blocks.forms.selected') }}
     </ui-checkbox>
     <template v-if="data.type === 'text-field' || data.type === 'select'">
       <ui-textarea
         :model-value="data.value"
-        placeholder="Value"
+        :placeholder="t('workflow.blocks.forms.text-field.value')"
         class="w-full"
         @change="updateData({ value: $event })"
       />
@@ -29,14 +29,14 @@
         class="mb-1 ml-1"
         @change="updateData({ clearValue: $event })"
       >
-        Clear form value
+        {{ t('workflow.blocks.forms.text-field.clearValue') }}
       </ui-checkbox>
     </template>
     <ui-input
       v-if="data.type === 'text-field'"
       :model-value="data.delay"
-      label="Typing delay (millisecond)(0 to disable)"
-      placeholder="Delay"
+      :label="t('workflow.blocks.forms.text-field.delay.label')"
+      :placeholder="t('workflow.blocks.forms.text-field.delay.placeholder')"
       class="w-full"
       min="0"
       type="number"
@@ -45,6 +45,7 @@
   </edit-interaction-base>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -55,12 +56,9 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
-const forms = [
-  { id: 'text-field', name: 'Text field' },
-  { id: 'select', name: 'Select' },
-  { id: 'checkbox', name: 'Checkbox' },
-  { id: 'radio', name: 'Radio' },
-];
+const { t } = useI18n();
+
+const forms = ['text-field', 'select', 'checkbox', 'radio'];
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });

+ 11 - 7
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -3,40 +3,42 @@
     <slot name="prepend" />
     <ui-textarea
       :model-value="data.description"
+      :placeholder="t('common.description')"
       autoresize
-      placeholder="Description"
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
     <ui-input
       v-if="!hideSelector"
       :model-value="data.selector"
-      placeholder="Element selector"
+      :placeholder="t('workflow.blocks.base.selector')"
       class="mb-1 w-full"
       @change="updateData({ selector: $event })"
     />
     <template v-if="!hideSelector">
       <ui-checkbox
         v-if="!data.disableMultiple && !hideMultiple"
-        class="mr-6"
-        title="Select multiple elements"
+        :title="t('workflow.blocks.base.multiple.title')"
         :model-value="data.multiple"
+        class="mr-6"
         @change="updateData({ multiple: $event })"
       >
-        Multiple
+        {{ t('workflow.blocks.base.multiple.text') }}
       </ui-checkbox>
       <ui-checkbox
         :model-value="data.markEl"
-        title="An element will not be selected if have been selected before"
+        :title="t('workflow.blocks.base.markElement.title')"
         @change="updateData({ markEl: $event })"
       >
-        Mark element
+        {{ t('workflow.blocks.base.markElement.text') }}
       </ui-checkbox>
     </template>
     <slot></slot>
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -53,6 +55,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data', 'change']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   const payload = { ...props.data, ...value };
 

+ 8 - 27
src/components/newtab/workflow/edit/EditJavascriptCode.vue

@@ -3,7 +3,7 @@
     <ui-textarea
       :model-value="data.description"
       autoresize
-      placeholder="Description"
+      :placeholder="t('common.description')"
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
@@ -11,8 +11,8 @@
       type="number"
       :model-value="data.timeout"
       class="mb-2 w-full"
-      placeholder="Timeout"
-      title="Javascript code execution timeout"
+      :placeholder="t('workflow.blocks.javascript-code.timeout.placeholder')"
+      :title="t('workflow.blocks.javascript-code.timeout.title')"
       @change="updateData({ timeout: +$event })"
     />
     <prism-editor
@@ -25,7 +25,7 @@
     />
     <ui-modal
       v-model="showCodeModal"
-      title="Javascript code"
+      :title="t('workflow.blocks.javascript-code.modal')"
       content-class="max-w-3xl"
     >
       <prism-editor
@@ -33,36 +33,15 @@
         class="py-4"
         :highlight="highlighter('javascript')"
         line-numbers
-        style="height: calc(100vh - 18rem)"
+        style="height: calc(100vh - 12rem)"
       />
-      <div>
-        Note:
-        <ul class="list-disc pl-5">
-          <li>
-            To execute the next block, you can call the
-            <code>automaNextBlock</code> function. This function accepts one
-            parameter, which you can use to save data to the workflow. Data
-            format:
-            <ul class="list-disc space-y-2 mt-2 text-sm pl-5">
-              <li><code>{ key: value }</code></li>
-              <li>
-                <code>[{ key: value }, { key: value }]</code>
-              </li>
-            </ul>
-            You must use the column that you added as a key.
-          </li>
-          <li>
-            To reset the execution timeout of the code, you can call the
-            <code>automaResetTimeout</code> function.
-          </li>
-        </ul>
-      </div>
     </ui-modal>
   </div>
 </template>
 <script setup>
 import { ref, watch } from 'vue';
 import { PrismEditor } from 'vue-prism-editor';
+import { useI18n } from 'vue-i18n';
 import { highlighter } from '@/lib/prism';
 
 const props = defineProps({
@@ -73,6 +52,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const code = ref(props.data.code);
 const showCodeModal = ref(false);
 

+ 22 - 20
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -2,20 +2,20 @@
   <div>
     <ui-textarea
       :model-value="data.description"
-      placeholder="Description"
+      :placeholder="t('common.description')"
       class="w-full"
       @change="updateData({ description: $event })"
     />
     <ui-input
       :model-value="data.loopId"
       class="w-full mb-3"
-      label="Loop ID"
-      placeholder="Loop ID"
+      :label="t('workflow.blocks.loop-data.loopId')"
+      :placeholder="t('workflow.blocks.loop-data.loopId')"
       @change="updateLoopID"
     />
     <ui-select
       :model-value="data.loopThrough"
-      placeholder="Loop through"
+      :placeholder="t('workflow.blocks.loop-data.loopThrough.placeholder')"
       class="w-full mb-2"
       @change="
         updateData({
@@ -24,17 +24,17 @@
         })
       "
     >
-      <option v-for="type in loopTypes" :key="type.id" :value="type.id">
-        {{ type.name }}
+      <option v-for="type in loopTypes" :key="type" :value="type">
+        {{ t(`workflow.blocks.loop-data.loopThrough.options.${type}`) }}
       </option>
     </ui-select>
     <ui-input
       :model-value="data.maxLoop"
+      :label="t('workflow.blocks.loop-data.maxLoop.label')"
+      :title="t('workflow.blocks.loop-data.maxLoop.title')"
       class="w-full mb-4"
       min="0"
       type="number"
-      label="Max data to loop (0 to disable)"
-      title="Max numbers of data to loop"
       @change="updateData({ maxLoop: +$event || 0 })"
     />
     <ui-button
@@ -43,7 +43,7 @@
       variant="accent"
       @click="state.showDataModal = true"
     >
-      Insert data
+      {{ t('workflow.blocks.loop-data.buttons.insert') }}
     </ui-button>
     <ui-modal
       v-model="state.showDataModal"
@@ -52,10 +52,10 @@
     >
       <div class="flex mb-4 items-center">
         <ui-button variant="accent" @click="importFile">
-          Import file
+          {{ t('workflow.blocks.loop-data.buttons.import') }}
         </ui-button>
         <ui-button
-          v-tooltip="'Options'"
+          v-tooltip="t('commons.options')"
           :class="{ 'text-primary': state.showOptions }"
           icon
           class="ml-2"
@@ -65,12 +65,14 @@
         </ui-button>
         <p class="flex-1 text-overflow mx-4">{{ file.name }}</p>
         <template v-if="data.loopData.length > maxStrLength">
-          <p class="mr-2">File too large to edit</p>
+          <p class="mr-2">
+            {{ t('workflow.blocks.loop-data.modal.fileTooLarge') }}
+          </p>
           <ui-button @click="updateData({ loopData: '[]' })">
-            Clear data
+            {{ t('workflow.blocks.loop-data.buttons.clear') }}
           </ui-button>
         </template>
-        <p v-else>Max file size is 1MB</p>
+        <p v-else>{{ t('workflow.blocks.loop-data.modal.maxFile') }}</p>
       </div>
       <div style="height: calc(100vh - 11rem)">
         <prism-editor
@@ -84,7 +86,7 @@
         <div v-show="state.showOptions">
           <p class="font-semibold mb-2">CSV</p>
           <ui-checkbox v-model="options.header">
-            Use the first row as keys
+            {{ t('workflow.blocks.loop-data.modal.options.firstRow') }}
           </ui-checkbox>
         </div>
       </div>
@@ -95,6 +97,7 @@
 import { onMounted, shallowReactive } from 'vue';
 import { nanoid } from 'nanoid';
 import { PrismEditor } from 'vue-prism-editor';
+import { useI18n } from 'vue-i18n';
 import Papa from 'papaparse';
 import { highlighter } from '@/lib/prism';
 import { openFilePicker } from '@/utils/helper';
@@ -111,12 +114,11 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const maxStrLength = 5e4;
 const maxFileSize = 1024 * 1024;
-const loopTypes = [
-  { id: 'data-columns', name: 'Data columns' },
-  { id: 'custom-data', name: 'Custom data' },
-];
+const loopTypes = ['data-columns', 'custom-data'];
 const tempLoopData =
   props.data.loopData.length > maxStrLength
     ? props.data.loopData.slice(0, maxStrLength)
@@ -153,7 +155,7 @@ function importFile() {
   openFilePicker(['application/json', 'text/csv', 'application/vnd.ms-excel'])
     .then(async (fileObj) => {
       if (fileObj.size > maxFileSize) {
-        alert('The file size is the exceeded maximum allowed');
+        alert(t('message.maxSizeExceeded'));
         return;
       }
 

+ 10 - 6
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -2,8 +2,8 @@
   <div class="mb-2 mt-4 space-y-2">
     <ui-textarea
       :model-value="data.description"
+      :placeholder="t('common.description')"
       class="w-full"
-      placeholder="Description"
       @change="updateData({ description: $event })"
     />
     <ui-input
@@ -21,31 +21,33 @@
       target="_blank"
       style="margin-top: 0"
     >
-      Learn how to add dynamic data
+      {{ t('message.useDynamicData') }}
     </a>
     <ui-checkbox
       :model-value="data.updatePrevTab"
       class="leading-tight"
-      title="Use the previously opened new tab instead of creating a new one"
+      :title="t('workflow.blocks.new-tab.updatePrevTab.title')"
       @change="updateData({ updatePrevTab: $event })"
     >
-      Update previously opened tab
+      {{ t('workflow.blocks.new-tab.updatePrevTab.text') }}
     </ui-checkbox>
     <ui-checkbox
       :model-value="data.active"
       @change="updateData({ active: $event })"
     >
-      Set as active tab
+      {{ t('workflow.blocks.new-tab.activeTab') }}
     </ui-checkbox>
     <ui-checkbox
       :model-value="data.inGroup"
       @change="updateData({ inGroup: $event })"
     >
-      Add tab to group
+      {{ t('workflow.blocks.new-tab.tabToGroup') }}
     </ui-checkbox>
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -54,6 +56,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 8 - 7
src/components/newtab/workflow/edit/EditNewWindow.vue

@@ -3,17 +3,17 @@
     <ui-textarea
       :model-value="data.description"
       class="w-full"
-      placeholder="Description"
+      :placeholder="t('common.description')"
       @change="updateData({ description: $event })"
     />
     <ui-select
       :model-value="data.windowState"
       class="w-full"
-      placeholder="Window state"
+      :placeholder="t('workflow.blocks.new-window.windowState.placeholder')"
       @change="updateData({ windowState: $event })"
     >
       <option v-for="state in windowStates" :key="state" :value="state">
-        {{ state }}
+        {{ t(`workflow.blocks.new-window.windowState.options.${state}`) }}
       </option>
     </ui-select>
     <ui-checkbox
@@ -21,10 +21,8 @@
       :disabled="!allowInIncognito"
       @change="updateData({ incognito: $event })"
     >
-      Set as incognito window
-      <span
-        title="You must enable 'Allow in incognito' for this extension to use the option"
-      >
+      {{ t('workflow.blocks.new-window.incognito.text') }}
+      <span :title="t('workflow.blocks.new-window.incognito.note')">
         &#128712;
       </span>
     </ui-checkbox>
@@ -32,6 +30,7 @@
 </template>
 <script setup>
 import { ref, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
 import browser from 'webextension-polyfill';
 
 const props = defineProps({
@@ -42,6 +41,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const windowStates = ['normal', 'minimized', 'maximized', 'fullscreen'];
 const allowInIncognito = ref(false);
 

+ 9 - 6
src/components/newtab/workflow/edit/EditScrollElement.vue

@@ -3,14 +3,14 @@
     <div v-if="!data.scrollIntoView" class="flex items-center mt-3 space-x-2">
       <ui-input
         :model-value="data.scrollX || 0"
+        :label="t('workflow.blocks.element-scroll.scrollX')"
         type="number"
-        label="Scroll horizontal"
         @change="updateData({ scrollX: +$event })"
       />
       <ui-input
         :model-value="data.scrollY || 0"
+        :label="t('workflow.blocks.element-scroll.scrollY')"
         type="number"
-        label="Scroll vertical"
         @change="updateData({ scrollY: +$event })"
       />
     </div>
@@ -20,32 +20,33 @@
         :model-value="data.scrollIntoView"
         @change="updateData({ scrollIntoView: $event })"
       >
-        Scroll into view
+        {{ t('workflow.blocks.element-scroll.intoView') }}
       </ui-checkbox>
       <ui-checkbox
         :model-value="data.smooth"
         @change="updateData({ smooth: $event })"
       >
-        Smooth scroll
+        {{ t('workflow.blocks.element-scroll.smooth') }}
       </ui-checkbox>
       <template v-if="!data.scrollIntoView">
         <ui-checkbox
           :model-value="data.incX"
           @change="updateData({ incX: $event })"
         >
-          Increment horizontal scroll
+          {{ t('workflow.blocks.element-scroll.incScrollX') }}
         </ui-checkbox>
         <ui-checkbox
           :model-value="data.incY"
           @change="updateData({ incY: $event })"
         >
-          Increment vertical scroll
+          {{ t('workflow.blocks.element-scroll.incScrollY') }}
         </ui-checkbox>
       </template>
     </div>
   </edit-interaction-base>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -56,6 +57,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 12 - 4
src/components/newtab/workflow/edit/EditSwitchTo.vue

@@ -2,8 +2,8 @@
   <div class="space-y-2">
     <ui-textarea
       :model-value="data.description"
+      :placeholder="t('common.description')"
       autoresize
-      placeholder="Description"
       class="w-full"
       @change="updateData({ description: $event })"
     />
@@ -12,19 +12,25 @@
       class="w-full"
       @change="updateData({ windowType: $event })"
     >
-      <option value="main-window">Main window</option>
-      <option value="iframe">Iframe</option>
+      <option value="main-window">
+        {{ t('workflow.blocks.switch-to.windowTypes.main') }}
+      </option>
+      <option value="iframe">
+        {{ t('workflow.blocks.switch-to.windowTypes.iframe') }}
+      </option>
     </ui-select>
     <ui-input
       v-if="data.windowType === 'iframe'"
       :model-value="data.selector"
-      placeholder="Iframe element selector"
+      :placeholder="t('workflow.blocks.switch-to.iframeSelector')"
       class="mb-1 w-full"
       @change="updateData({ selector: $event })"
     />
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -33,6 +39,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 6 - 2
src/components/newtab/workflow/edit/EditTakeScreenshot.vue

@@ -2,7 +2,7 @@
   <div class="flex items-center mb-2 mt-8">
     <ui-input
       :model-value="data.fileName"
-      placeholder="File name"
+      :placeholder="t('common.fileName')"
       class="flex-1 mr-2"
       title="File name"
       @change="updateData({ fileName: $event })"
@@ -20,7 +20,7 @@
   <div class="bg-box-transparent px-4 mb-4 py-2 rounded-lg flex items-center">
     <input
       :value="data.quality"
-      title="Image quality"
+      :title="t('workflow.blocks.loop.take-screenshot.imageQuality')"
       class="focus:outline-none flex-1"
       type="range"
       min="0"
@@ -38,6 +38,8 @@
   </ui-checkbox>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -46,6 +48,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 27 - 24
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -3,27 +3,27 @@
     <ui-textarea
       :model-value="data.description"
       autoresize
-      placeholder="Description"
+      :placeholder="t('common.description')"
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
     <ui-select
       :model-value="data.type || 'manual'"
-      placeholder="Trigger workflow"
+      :placeholder="t('workflow.blocks.trigger.forms.triggerWorkflow')"
       class="w-full"
       @change="handleSelectChange"
     >
-      <option v-for="trigger in triggers" :key="trigger.id" :value="trigger.id">
-        {{ trigger.name }}
+      <option v-for="trigger in triggers" :key="trigger" :value="trigger">
+        {{ t(`workflow.blocks.trigger.items.${trigger}`) }}
       </option>
     </ui-select>
     <transition-expand mode="out-in">
       <div v-if="data.type === 'interval'" class="flex items-center mt-1">
         <ui-input
           :model-value="data.interval"
+          :label="t('workflow.blocks.trigger.forms.interval')"
           type="number"
           class="w-full mr-2"
-          label="Interval (minutes)"
           placeholder="1-120"
           min="1"
           max="120"
@@ -35,7 +35,7 @@
           :model-value="data.delay"
           type="number"
           class="w-full"
-          label="Delay (minutes)"
+          :label="t('workflow.blocks.trigger.forms.delay')"
           min="0"
           max="20"
           placeholder="0-20"
@@ -49,16 +49,16 @@
           :model-value="data.date"
           :max="maxDate"
           :min="minDate"
+          :placeholder="t('workflow.blocks.trigger.forms.date')"
           class="w-full"
           type="date"
-          placeholder="Date"
           @change="updateDate({ date: $event })"
         />
         <ui-input
           :model-value="data.time"
+          :placeholder="t('workflow.blocks.trigger.forms.time')"
           type="time"
           class="w-full mt-2"
-          placeholder="Time"
           @change="updateData({ time: $event || '00:00' })"
         />
       </div>
@@ -74,17 +74,17 @@
           <ui-checkbox
             v-for="day in days"
             :key="day.id"
-            :model-value="data.days.includes(day.id)"
+            :model-value="data.days?.includes(day.id)"
             @change="onDayChange($event, day.id)"
           >
-            {{ day.name }}
+            {{ t(`workflow.blocks.trigger.days.${day.id}`) }}
           </ui-checkbox>
         </div>
       </div>
       <div v-else-if="data.type === 'visit-web'" class="mt-2">
         <ui-input
           :model-value="data.url"
-          placeholder="URL or Regex"
+          :placeholder="t('workflow.blocks.trigger.forms.url')"
           class="w-full"
           @change="updateData({ url: $event })"
         />
@@ -93,7 +93,7 @@
           class="mt-1"
           @change="updateData({ isUrlRegex: $event })"
         >
-          Use regex
+          {{ t('workflow.blocks.trigger.useRegex') }}
         </ui-checkbox>
       </div>
       <div v-else-if="data.type === 'keyboard-shortcut'" class="mt-2">
@@ -102,10 +102,10 @@
             :model-value="recordKeys.keys"
             readonly
             class="flex-1 mr-2"
-            placeholder="Shortcut"
+            :placeholder="t('workflow.blocks.trigger.forms.shortcut')"
           />
           <ui-button
-            v-tooltip="'Record shortcut'"
+            v-tooltip="t('workflow.blocks.trigger.shortcut.tooltip')"
             icon
             @click="toggleRecordKeys"
           >
@@ -119,13 +119,13 @@
         <ui-checkbox
           :model-value="data.activeInInput"
           class="mb-1"
-          title="Execute shortcut even when you're in an input element"
+          :title="t('workflow.blocks.trigger.shortcut.checkboxTitle')"
           @change="updateData({ activeInInput: $event })"
         >
-          Active while in input
+          {{ t('workflow.blocks.trigger.shortcut.checkbox') }}
         </ui-checkbox>
         <p class="mt-4 leading-tight text-gray-600 dark:text-gray-200">
-          Note: keyboard shortcut only working when you're on a webpage
+          {{ t('workflow.blocks.trigger.shortcut.note') }}
         </p>
       </div>
     </transition-expand>
@@ -133,6 +133,7 @@
 </template>
 <script setup>
 import { shallowReactive, onUnmounted } from 'vue';
+import { useI18n } from 'vue-i18n';
 import dayjs from 'dayjs';
 
 const props = defineProps({
@@ -143,13 +144,15 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const triggers = [
-  { id: 'manual', name: 'Manually' },
-  { id: 'interval', name: 'Interval' },
-  { id: 'date', name: 'On specific date' },
-  { id: 'specific-day', name: 'On specific day' },
-  { id: 'visit-web', name: 'When visit a website' },
-  { id: 'keyboard-shortcut', name: 'Keyboard shortcut' },
+  'manual',
+  'interval',
+  'date',
+  'specific-day',
+  'visit-web',
+  'keyboard-shortcut',
 ];
 const days = [
   { id: 0, name: 'Sunday' },
@@ -183,7 +186,7 @@ function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
 function onDayChange(value, id) {
-  const dataDays = [...props.data.days];
+  const dataDays = [...(props.data?.days || [])];
 
   if (value) dataDays.push(id);
   else dataDays.splice(dataDays.indexOf(id), 1);

+ 5 - 2
src/components/newtab/workflow/edit/EditTriggerEvent.vue

@@ -2,8 +2,8 @@
   <edit-interaction-base v-bind="{ data }" @change="updateData">
     <ui-select
       :model-value="data.eventName"
+      :placeholder="t('workflow.blocks.trigger-event.selectEvent')"
       class="w-full mt-2"
-      placeholder="Select an event"
       @change="handleSelectChange"
     >
       <option v-for="event in eventList" :key="event.id" :value="event.id">
@@ -19,7 +19,7 @@
         class="mr-1 transition-transform -ml-1"
         :rotate="showOptions ? 270 : 180"
       />
-      <span class="flex-1">Options</span>
+      <span class="flex-1">{{ t('common.options') }}</span>
       <a
         :href="getEventDetailsUrl()"
         rel="noopener"
@@ -75,6 +75,7 @@ export default {
 <script setup>
 /* eslint-disable */
 import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { eventList } from '@/utils/shared';
 import { toCamelCase } from '@/utils/helper';
 import EditInteractionBase from './EditInteractionBase.vue';
@@ -87,6 +88,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const eventComponents = {
   'mouse-event': 'TriggerEventMouse',
   'focus-event': '',

+ 15 - 10
src/components/newtab/workflow/edit/EditWebhook.vue

@@ -2,7 +2,7 @@
   <div class="mb-2 mt-4">
     <ui-textarea
       :model-value="data.description"
-      placeholder="Description"
+      :placeholder="t('common.description')"
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
@@ -11,13 +11,13 @@
       class="mb-2 w-full"
       placeholder="https://example.com/postreceive"
       required
-      title="The Post receive URL"
+      :title="t('workflow.blocks.webhook.url')"
       type="url"
       @change="updateData({ url: $event })"
     />
     <ui-select
       :model-value="data.contentType"
-      placeholder="Select a content type"
+      :placeholder="t('workflow.blocks.webhook.contentType')"
       class="mb-2 w-full"
       @change="updateData({ contentType: $event })"
     >
@@ -31,15 +31,17 @@
     </ui-select>
     <ui-input
       :model-value="data.timeout"
+      :placeholder="t('workflow.blocks.webhook.timeout.placeholder')"
+      :title="t('workflow.blocks.webhook.timeout.title')"
       class="mb-2 w-full"
-      placeholder="Timeout"
-      title="Http request execution timeout(ms)"
       type="number"
       @change="updateData({ timeout: +$event })"
     />
     <ui-tabs v-model="activeTab" fill class="mb-4">
-      <ui-tab value="headers">Headers</ui-tab>
-      <ui-tab value="body">Content body</ui-tab>
+      <ui-tab value="headers">{{
+        t('workflow.blocks.webhook.tabs.headers')
+      }}</ui-tab>
+      <ui-tab value="body">{{ t('workflow.blocks.webhook.tabs.body') }}</ui-tab>
     </ui-tabs>
     <ui-tab-panels :model-value="activeTab">
       <ui-tab-panel
@@ -68,7 +70,7 @@
           variant="accent"
           @click="addHeader"
         >
-          <span> Add Header </span>
+          <span> {{ t('workflow.blocks.webhook.buttons.header') }} </span>
         </ui-button>
       </ui-tab-panel>
       <ui-tab-panel value="body">
@@ -85,7 +87,7 @@
     <ui-modal
       v-model="showContentModalRef"
       content-class="max-w-3xl"
-      title="Content Body"
+      :title="t('workflow.blocks.webhook.tabs.body')"
     >
       <prism-editor
         v-model="contentRef"
@@ -101,7 +103,7 @@
           class="border-b text-primary"
           target="_blank"
         >
-          Click here to learn how to add dynamic data
+          {{ t('message.useDynamicData') }}
         </a>
       </div>
     </ui-modal>
@@ -109,6 +111,7 @@
 </template>
 <script setup>
 import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 import { contentTypes } from '@/utils/shared';
@@ -121,6 +124,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const activeTab = ref('headers');
 const contentRef = ref(props.data.body);
 const headerRef = ref(props.data.headers);

+ 16 - 14
src/components/ui/UiDialog.vue

@@ -5,7 +5,7 @@
     @close="state.show = false"
   >
     <template #header>
-      <h3 class="font-semibold text-lg">{{ state.options.title }}</h3>
+      <h3 class="font-semibold">{{ state.options.title }}</h3>
     </template>
     <p class="text-gray-600 dark:text-gray-200 leading-tight">
       {{ state.options.body }}
@@ -34,23 +34,25 @@
 </template>
 <script>
 import { reactive, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
 import emitter from 'tiny-emitter/instance';
 
-const defaultOptions = {
-  html: false,
-  body: '',
-  title: '',
-  placeholder: '',
-  label: '',
-  okText: 'Confirm',
-  okVariant: 'accent',
-  cancelText: 'Cancel',
-  onConfirm: null,
-  onCancel: null,
-};
-
 export default {
   setup() {
+    const { t } = useI18n();
+
+    const defaultOptions = {
+      html: false,
+      body: '',
+      title: '',
+      placeholder: '',
+      label: '',
+      okText: t('common.confirm'),
+      okVariant: 'accent',
+      cancelText: t('common.cancel'),
+      onConfirm: null,
+      onCancel: null,
+    };
     const state = reactive({
       show: false,
       type: '',

+ 7 - 6
src/components/ui/UiPagination.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="flex items-center">
     <ui-button
-      v-tooltip="'Previous page'"
+      v-tooltip="t('components.pagination.prevPage')"
       :disabled="modelValue <= 1"
       icon
       @click="updatePage(modelValue - 1)"
@@ -11,7 +11,7 @@
     <div class="mx-4">
       <input
         ref="inputEl"
-        v-tooltip="'Current page'"
+        v-tooltip="t('components.pagination.currentPage')"
         :value="modelValue"
         :max="maxPage"
         min="0"
@@ -28,11 +28,10 @@
         @click="$event.target.select()"
         @input="updatePage(+$event.target.value, $event.target)"
       />
-      of
-      {{ maxPage }}
+      {{ t('components.pagination.of', { page: maxPage }) }}
     </div>
     <ui-button
-      v-tooltip="'Next page'"
+      v-tooltip="t('components.pagination.nextPage')"
       :disabled="modelValue >= maxPage"
       icon
       @click="updatePage(modelValue + 1)"
@@ -43,6 +42,7 @@
 </template>
 <script setup>
 import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
 
 const props = defineProps({
   modelValue: {
@@ -60,8 +60,9 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:modelValue', 'paginate']);
 
-const inputEl = ref(null);
+const { t } = useI18n();
 
+const inputEl = ref(null);
 const maxPage = computed(() => Math.round(props.records / props.perPage));
 
 function emitEvent(page) {

+ 52 - 0
src/lib/vue-i18n.js

@@ -0,0 +1,52 @@
+import { nextTick } from 'vue';
+import { createI18n } from 'vue-i18n/dist/vue-i18n.esm-bundler';
+import { supportLocales } from '@/utils/shared';
+import dayjs from './dayjs';
+
+const i18n = createI18n({
+  locale: 'en',
+  legacy: false,
+  fallbackLocale: 'en',
+});
+
+export function setI18nLanguage(locale) {
+  i18n.global.locale.value = locale;
+
+  document.querySelector('html').setAttribute('lang', locale);
+}
+
+export async function loadLocaleMessages(locale, location) {
+  const isLocaleSupported = supportLocales.some(({ id }) => id === locale);
+
+  if (!isLocaleSupported) {
+    console.error(`${locale} locale is not supported`);
+
+    return null;
+  }
+
+  const importLocale = async (path, merge = false) => {
+    try {
+      const messages = await import(
+        /* webpackChunkName: "locale-[request]" */ `../locales/${locale}/${path}`
+      );
+
+      if (merge) {
+        i18n.global.mergeLocaleMessage(locale, messages.default);
+      } else {
+        i18n.global.setLocaleMessage(locale, messages.default);
+      }
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  dayjs.locale(locale);
+
+  await importLocale('common.json');
+  await importLocale(`${location}.json`, true);
+  await importLocale('blocks.json', true);
+
+  return nextTick();
+}
+
+export default i18n;

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

@@ -0,0 +1,254 @@
+{
+  "collection": {
+    "blocks": {
+      "export-result": {
+        "name": "Export result",
+        "description": "Export the collection result as JSON"
+      }
+    }
+  },
+  "workflow": {
+    "blocks": {
+      "base": {
+        "selector": "Element selector",
+        "markElement": {
+          "title": "An element will not be selected if have been selected before",
+          "text": "Mark element"
+        },
+        "multiple": {
+          "title": "Select multiple element",
+          "text": "Multiple"
+        }
+      },
+      "trigger": {
+        "name": "Trigger",
+        "description": "Block where the workflow will start executing",
+        "days": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
+        "useRegex": "Use regex",
+        "shortcut": {
+          "tootlip": "Record shortcut",
+          "checkboxTitle": "Execute shortcut even when you're in an input element",
+          "checkbox": "Active while in input",
+          "note": "Note: keyboard shortcut only working when you're on a webpage",
+        },
+        "forms": {
+          "triggerWorkflow": "Trigger workflow",
+          "interval": "Interval (minutes)",
+          "delay": "Delay (minutes)",
+          "date": "Date",
+          "time": "Time",
+          "url": "URL or Regex",
+          "shortcut": "Shortcut"
+        },
+        "items": {
+          "manual": "Manually",
+          "interval": "Interval",
+          "date": "On a specific date",
+          "specific-day": "On a specific day",
+          "visit-web": "When visit a website",
+          "keyboard-shortcut": "Keyboard shortcut"
+        },
+      },
+      "active-tab": {
+        "name": "Active tab",
+        "description": "Set current tab that you're in as an active tab"
+      },
+      "new-window": {
+        "name": "New window",
+        "description": "Create a new window",
+        "windowState": {
+          "placeholder": "Window state",
+          "options": {
+            "normal": "Normal",
+            "minimized": "Minimized",
+            "maximized": "Maximized",
+            "fullscreen": "Fullscreen"
+          }
+        },
+        "incognito": {
+          "text": "Set as incognito window",
+          "note": "You must enable 'Allow in incognito' for this extension to use the option"
+        },
+      },
+      "go-back": {
+        "name": "Go back",
+        "description": "Go back to the previous page"
+      },
+      "forward-page": {
+        "name": "Go forward",
+        "description": "Go forward to the next page"
+      },
+      "close-tab": {
+        "name": "Close tab",
+        "description": "",
+        "activeTab": "Close activeTab",
+        "url": "URL or match pattern"
+      },
+      "event-click": {
+        "name": "Click element",
+        "description": ""
+      },
+      "delay": {
+        "name": "Delay",
+        "description": "Add delay before executing the next block"
+      },
+      "get-text": {
+        "name": "Get text",
+        "description": "Get text from an element"
+      },
+      "export-data": {
+        "name": "Export data",
+        "description": "Export workflow data columns"
+      },
+      "element-scroll": {
+        "name": "Scroll element",
+        "description": "",
+        "scrollY": "Scroll vertical",
+        "scrollX": "Scroll horizontal",
+        "intoView": "Scroll into view",
+        "smooth": "Smooth scroll",
+        "incScrollX": "Increment horizontal scroll",
+        "incScrollY": "Increment vertical scroll",
+      },
+      "new-tab": {
+        "name": "New tab",
+        "description": "",
+        "activeTab": "Set as active tab",
+        "tabToGroup": "Add tab to group",
+        "updatePrevTab": {
+          "title": "Use the previously opened new tab instead of creating a new one",
+          "text": "Update previously opened tab"
+        }
+      },
+      "link": {
+        "name": "Link",
+        "description": "Open link element"
+      },
+      "attribute-value": {
+        "name": "Attribute value",
+        "description": "Get value of an element attribute",
+        "forms": {
+          "name": "Attribute name",
+          "checkbox": "Save data",
+          "column": "Select column"
+        }
+      },
+      "forms": {
+        "name": "Forms",
+        "description": "",
+        "selected": "Selected",
+        "type": "Form type",
+        "text-field": {
+          "name": "Text field",
+          "value": "Value",
+          "clearValue": "Clear form value",
+          "delay": {
+            "placeholder": "Delay",
+            "label": "Typing delay (millisecond)(0 to disable)"
+          }
+        },
+        "select": { "name": "Select" },
+        "radio": { "name": "Radio" },
+        "checkbox": { "name": "Checkbox" }
+      },
+      "repeat-task": {
+        "name": "Repeat task",
+        "description": "",
+      },
+      "javascript-code": {
+        "name": "JavaScript code",
+        "description": "Execute your javascript code in the web page",
+        "timeout": {
+          "placeholder": "Timeout",
+          "title": "Javascript code execution timeout"
+        },
+        "modal": "JavaScript code"
+      },
+      "trigger-event": {
+        "name": "Trigger event",
+        "description": "",
+        "selectEvent": "Select event"
+      },
+      "conditions": {
+        "name": "Conditions",
+        "description": "Conditional block"
+      },
+      "element-exists": {
+        "name": "Element exists",
+        "description": "Check if an element is exists",
+        "selector": "Element selector",
+        "tryFor": {
+          "title": "Try to check if element exist",
+          "label": "Try for"
+        },
+        "timeout": {
+          "label": "Timeout (milliseconds)",
+          "title": "Timeout for each try"
+        }
+      },
+      "webhook": {
+        "name": "Webhook",
+        "description": "Webhook allow external service to be notified",
+        "url": "The Post receive URL",
+        "contentType": "Select a content type",
+        "buttons": {
+          "header": "Add header"
+        },
+        "timeout": {
+          "placeholder": "Timeout",
+          "title": "Http request execution timeout(ms)"
+        },
+        "tabs": {
+          "headers": "Headers",
+          "body": "Content body"
+        }
+      },
+      "loop-data": {
+        "name": "Loop data",
+        "description": "Iterate through data columns or your custom data",
+        "loopId": "Loop ID",
+        "modal": {
+          "fileTooLarge": "File too large to edit",
+          "maxFile": "Max file size is 1MB",
+          "options": {
+            "firstRow": "Use the first row as keys"
+          },
+        },
+        "buttons": {
+          "clear": "Clear data",
+          "insert": "Insert data",
+          "import": "Import file",
+        },
+        "maxLoop": {
+          "title": "Max numbers of data to loop",
+          "label": "Max data to loop (0 to disable)"
+        },
+        "loopThrough": {
+          "placeholder": "Loop through",
+          "options": {
+            "data-columns": "Data columns",
+            "custom-data": "Custom data"
+          },
+        }
+      },
+      "loop-breakpoint": {
+        "name": "Loop breakpoint",
+        "description": "To tell where loop data block must stop"
+      },
+      "take-screenshot": {
+        "name": "Take screenshot",
+        "description": "Take a screenshot of current active tab",
+        "imageQuality": "Image quality"
+      },
+      "switch-to": {
+        "name": "Switch frame",
+        "description": "Switch between main window and iframe",
+        "iframeSelector": "Iframe element selector",
+        "windowTypes": {
+          "main": "Main window",
+          "iframe": "Iframe"
+        },
+      }
+    }
+  }
+}

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

@@ -0,0 +1,51 @@
+{
+  "common": {
+    "dashboard": "Dashboard",
+    "workflow": "Workflow | Workflows",
+    "collection": "Collection | Collections",
+    "log": "Log | Logs",
+    "block": "Block | Blocks",
+    "docs": "Documentation",
+    "search": "Search",
+    "import": "Import",
+    "export": "Export",
+    "rename": "Rename",
+    "execute": "Execute",
+    "delete": "Delete",
+    "cancel": "Cancel",
+    "settings": "Settings",
+    "options": "Options",
+    "confirm": "Confirm",
+    "name": "Name",
+    "all": "All",
+    "add": "Add",
+    "save": "Save",
+    "data": "data",
+    "stop": "Stop",
+    "editor": "Editor",
+    "running": "Running",
+    "globalData": "Global data",
+    "fileName": "File name",
+    "description": "Description",
+  },
+  "message": {
+    "noBlock": "No block",
+    "noData": "No data to show",
+    "noTriggerBlock": "Can't find a trigger block",
+    "useDynamicData": "Learn how to add dynamic data",
+    "delete": "Are you sure want to delete \"{name}\"?",
+    "empty": "Oppss... It's looks like you don't have any items",
+    "notSaved": "Do you really want to leave? you have unsaved changes!",
+    "maxSizeExceeded": "The file size is the exceeded maximum allowed",
+  },
+  "sort": {
+    "sortBy": "Sort by",
+    "name": "Name",
+    "createdAt": "Created date"
+  },
+  "logStatus": {
+    "stopped": "stopped",
+    "error": "error",
+    "success": "success"
+  },
+}

+ 126 - 0
src/locales/en/newtab.json

@@ -0,0 +1,126 @@
+{
+  "home": {
+    "viewAll": "View all"
+  },
+  "settings": {
+    "language": {
+      "label": "Language",
+      "helpTranslate": "Can't find your language? Help translate.",
+      "reloadPage": "Reload the page to take effect"
+    },
+  },
+  "workflow": {
+    "import": "Import workflow",
+    "new": "New workflow",
+    "delete": "Delete workflow",
+    "name": "Workflow name",
+    "rename": "Rename workflow",
+    "add": "Add workflow",
+    "dataColumns": {
+      "title": "Data columns",
+      "placeholder": "Search or add column",
+      "column": {
+        "name": "Column name",
+        "type": "Data type"
+      }
+    },
+    "sidebar": {
+      "workflowIcon": "Workflow icon"
+    },
+    "editor": {
+      "zoomIn": "Zoom in",
+      "zoomOut": "Zoom out",
+      "resetZoom": "Reset zoom",
+      "duplicate": "Duplicate"
+    },
+    "settings": {
+      "onError": {
+        "title": "On workflow error",
+        "items": {
+          "keepRunning": "Keep running",
+          "stopWorkflow": "Stop workflow"
+        }
+      },
+      "timeout": {
+        "title": "Workflow timeout (milliseconds)"
+      }
+    }
+  },
+  "collection": {
+    "description": "Execute your workflows in sequence",
+    "new": "New collection",
+    "delete": "Delete collection",
+    "add": "Add collection",
+    "rename": "Rename collection",
+    "flow": "Flow",
+    "dragDropText": "Drop a workflow or block in here",
+    "options": {
+      "atOnce": {
+        "title": "Execute all workflows in the collection at once",
+        "description": "Block not gonna executed when using this option"
+      }
+    },
+    "globalData": {
+      "note": "This will overwrite the global data of the workflow"
+    }
+  },
+  "log": {
+    "goBack": "Go back to \"{name}\" log",
+    "startedDate": "Started date",
+    "duration": "Duration",
+    "selectAll": "Select all",
+    "deselectAll": "Deselect all",
+    "deleteSelected": "Delete selected logs",
+    "types": {
+      "stop": "Workflow is stopped",
+      "finish": "Finish"
+    },
+    "messages": {
+      "stop-timeout": "Workflow is stopped because of timeout",
+      "no-iframe-id": "Can't find Frame ID for the frame element with \"{selector}\" selector",
+      "no-tab": "Can't connect to a tab, use \"New tab\" or \"Active tab\" block before using the \"{name}\" block."
+    },
+    "description": {
+      "text": "{status} on {date} in {duration}",
+      "status": {
+        "success": "Succeeded",
+        "error": "Failed",
+        "stopped": "Stopped"
+      }
+    },
+    "delete": {
+      "title": "Delete log",
+      "description": "Are you sure want to delete all the selected logs?"
+    },
+    "exportData": {
+      "title": "Export data",
+      "types": {
+        "json": "JSON",
+        "csv": "CSV",
+        "plain-text": "Plain text"
+      },
+    },
+    "filter": {
+      "title": "Filter",
+      "byStatus": "By status",
+      "byDate": {
+        "title": "By date",
+        "items": {
+          "lastDay": "Last day",
+          "last7Days": "Last seven days",
+          "last30Days": "Last thirty days",
+        }
+      },
+    },
+  },
+  "components": {
+    "pagination": {
+      "text1": "Showing",
+      "text2": "items out of {count}",
+      "nextPage": "Next page",
+      "currentPage": "Current page",
+      "prevPage": "Previous page",
+      "of": "of {page}"
+    }
+  },
+}

+ 13 - 0
src/locales/en/popup.json

@@ -0,0 +1,13 @@
+{
+  "home": {
+    "elementSelector": {
+      "name": "Element selector",
+      "noAccess": "Don't have access to this site"
+    },
+    "workflow": {
+      "new": "New workflow",
+      "rename": "Rename workflow",
+      "delete": "Delete workflow"
+    },
+  }
+}

+ 24 - 16
src/newtab/App.vue

@@ -1,29 +1,22 @@
 <template>
-  <app-sidebar />
-  <main class="pl-16">
-    <router-view v-if="retrieved" />
-  </main>
-  <ui-dialog />
+  <template v-if="retrieved">
+    <app-sidebar />
+    <main class="pl-16">
+      <router-view />
+    </main>
+    <ui-dialog />
+  </template>
 </template>
 <script setup>
-import { ref } from 'vue';
+import { ref, onMounted } from 'vue';
 import { useStore } from 'vuex';
 import browser from 'webextension-polyfill';
+import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 
 const store = useStore();
 const retrieved = ref(false);
 
-store.dispatch('retrieveWorkflowState');
-store
-  .dispatch('retrieve', ['workflows', 'logs', 'collections'])
-  .then(() => {
-    retrieved.value = true;
-  })
-  .catch(() => {
-    retrieved.value = true;
-  });
-
 function handleStorageChanged(change) {
   if (change.logs) {
     store.dispatch('entities/create', {
@@ -45,4 +38,19 @@ browser.storage.local.onChanged.addListener(handleStorageChanged);
 window.addEventListener('beforeunload', () => {
   browser.storage.local.onChanged.removeListener(handleStorageChanged);
 });
+
+onMounted(async () => {
+  try {
+    await store.dispatch('retrieve', ['workflows', 'logs', 'collections']);
+    await store.dispatch('retrieveWorkflowState');
+
+    await loadLocaleMessages(store.state.settings.locale, 'newtab');
+    await setI18nLanguage(store.state.settings.locale);
+
+    retrieved.value = true;
+  } catch (error) {
+    retrieved.value = true;
+    console.error(error);
+  }
+});
 </script>

+ 2 - 0
src/newtab/index.js

@@ -3,6 +3,7 @@ import App from './App.vue';
 import router from './router';
 import store from '../store';
 import compsUi from '../lib/comps-ui';
+import vueI18n from '../lib/vue-i18n';
 import vRemixicon, { icons } from '../lib/v-remixicon';
 import '../assets/css/tailwind.css';
 import '../assets/css/fonts.css';
@@ -12,6 +13,7 @@ createApp(App)
   .use(router)
   .use(store)
   .use(compsUi)
+  .use(vueI18n)
   .use(vRemixicon, icons)
   .mount('#app');
 

+ 18 - 16
src/newtab/pages/Collections.vue

@@ -1,18 +1,18 @@
 <template>
   <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold">Collections</h1>
+    <h1 class="text-2xl font-semibold">{{ t('common.collection', 2) }}</h1>
     <p class="text-gray-600 dark:text-gray-200">
-      Execute your workflows in sequence
+      {{ t('collection.description') }}
     </p>
     <div class="flex items-center my-6 space-x-4">
       <ui-input
         v-model="query"
+        :placeholder="`${t('common.search')}...`"
         prepend-icon="riSearch2Line"
-        placeholder="Search..."
         class="flex-1"
       />
       <ui-button variant="accent" @click="newCollection">
-        New collection
+        {{ t('collection.new') }}
       </ui-button>
     </div>
     <div
@@ -22,10 +22,10 @@
       <img src="@/assets/svg/alien.svg" class="w-96" />
       <div class="ml-4">
         <h1 class="text-2xl font-semibold max-w-md mb-6">
-          Oppss... It's looks like you don't have any collections.
+          {{ t('message.empty') }}
         </h1>
         <ui-button variant="accent" @click="newCollection">
-          New collection
+          {{ t('collection.new') }}
         </ui-button>
       </div>
     </div>
@@ -45,16 +45,18 @@
 </template>
 <script setup>
 import { ref, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
 import { useDialog } from '@/composable/dialog';
 import Collection from '@/models/collection';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 
 const dialog = useDialog();
+const { t } = useI18n();
 
 const collectionCardMenu = [
-  { name: 'rename', icon: 'riPencilLine' },
-  { name: 'delete', icon: 'riDeleteBin7Line' },
+  { id: 'rename', name: t('collection.rename'), icon: 'riPencilLine' },
+  { id: 'delete', name: t('collection.delete'), icon: 'riDeleteBin7Line' },
 ];
 
 const query = ref('');
@@ -73,9 +75,9 @@ function executeCollection(collection) {
 }
 function newCollection() {
   dialog.prompt({
-    title: 'New collection',
-    placeholder: 'Collection name',
-    okText: 'Add collection',
+    title: t('collection.new'),
+    placeholder: t('common.name'),
+    okText: t('collection.add'),
     onConfirm: (name) => {
       Collection.insert({
         data: {
@@ -88,9 +90,9 @@ function newCollection() {
 }
 function renameCollection({ id, name }) {
   dialog.prompt({
-    title: 'Rename collection',
-    placeholder: 'Collection name',
-    okText: 'Rename',
+    title: t('collection.rename'),
+    placeholder: t('common.name'),
+    okText: t('common.rename'),
     inputValue: name,
     onConfirm: (newName) => {
       Collection.update({
@@ -104,9 +106,9 @@ function renameCollection({ id, name }) {
 }
 function deleteCollection({ name, id }) {
   dialog.confirm({
-    title: 'Delete collection',
+    title: t('collection.delete'),
     okVariant: 'danger',
-    body: `Are you sure you want to delete "${name}" collection?`,
+    body: t('message.delete', { name }),
     onConfirm: () => {
       Collection.delete(id);
     },

+ 7 - 5
src/newtab/pages/Home.vue

@@ -1,11 +1,11 @@
 <template>
   <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold mb-8">Dashboard</h1>
+    <h1 class="text-2xl font-semibold mb-8">{{ t('common.dashboard') }}</h1>
     <div class="flex items-start">
       <div class="w-8/12 mr-8">
         <div class="grid gap-4 mb-8 2xl:grid-cols-4 grid-cols-3">
           <p v-if="workflows.length === 0" class="text-center text-gray-600">
-            No data
+            {{ t('message.noData') }}
           </p>
           <shared-card
             v-for="workflow in workflows"
@@ -24,18 +24,18 @@
               to="/logs"
               class="text-gray-600 text-sm dark:text-gray-200"
             >
-              View all
+              {{ t('home.viewAll') }}
             </router-link>
           </div>
           <p v-if="logs.length === 0" class="text-center text-gray-600">
-            No data
+            {{ t('message.noData') }}
           </p>
           <shared-logs-table :logs="logs" class="w-full" />
         </div>
       </div>
       <div class="w-4/12 space-y-4">
         <p v-if="workflowState.length === 0" class="text-center text-gray-600">
-          No data
+          {{ t('message.noData') }}
         </p>
         <shared-workflow-state
           v-for="item in workflowState"
@@ -50,6 +50,7 @@
 <script setup>
 import { computed } from 'vue';
 import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
 import Log from '@/models/log';
 import Workflow from '@/models/workflow';
@@ -57,6 +58,7 @@ import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
 import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
 
+const { t } = useI18n();
 const store = useStore();
 
 const workflows = computed(() =>

+ 50 - 0
src/newtab/pages/Settings.vue

@@ -0,0 +1,50 @@
+<template>
+  <div class="container pt-8 pb-4">
+    <h1 class="text-2xl font-semibold mb-10">{{ t('common.settings') }}</h1>
+    <div class="flex items-center">
+      <div id="languages">
+        <ui-select
+          :model-value="settings.locale"
+          :label="t('settings.language.label')"
+          class="w-80"
+          @change="updateLanguage"
+        >
+          <option
+            v-for="locale in supportLocales"
+            :key="locale.id"
+            :value="locale.id"
+          >
+            {{ locale.name }}
+          </option>
+        </ui-select>
+        <a
+          class="block text-sm text-gray-600 dark:text-gray-200 ml-1"
+          target="_blank"
+        >
+          {{ t('settings.language.helpTranslate') }}
+        </a>
+      </div>
+      <p v-if="isLangChange" class="inline-block ml-4">
+        {{ t('settings.language.reloadPage') }}
+      </p>
+    </div>
+  </div>
+</template>
+<script setup>
+import { computed, ref } from 'vue';
+import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import { supportLocales } from '@/utils/shared';
+
+const { t } = useI18n();
+const store = useStore();
+
+const isLangChange = ref(false);
+const settings = computed(() => store.state.settings);
+
+async function updateLanguage(value) {
+  isLangChange.value = true;
+
+  store.dispatch('updateSettings', { locale: value });
+}
+</script>

+ 28 - 27
src/newtab/pages/Workflows.vue

@@ -1,11 +1,13 @@
 <template>
   <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold mb-6">Workflows</h1>
+    <h1 class="text-2xl font-semibold mb-6 capitalize">
+      {{ t('common.workflow', 2) }}
+    </h1>
     <div class="flex items-center mb-6 space-x-4">
       <ui-input
         v-model="state.query"
         prepend-icon="riSearch2Line"
-        placeholder="Search..."
+        :placeholder="`${t(`common.search`)}...`"
         class="flex-1"
       />
       <div class="flex items-center workflow-sort">
@@ -18,29 +20,29 @@
             :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
           />
         </ui-button>
-        <ui-select v-model="state.sortBy" placeholder="Sort by">
-          <option v-for="sort in sorts" :key="sort.id" :value="sort.id">
-            {{ sort.name }}
+        <ui-select v-model="state.sortBy" :placeholder="t('sort.sortBy')">
+          <option v-for="sort in sorts" :key="sort" :value="sort">
+            {{ t(`sort.${sort}`) }}
           </option>
         </ui-select>
       </div>
       <ui-button @click="importWorkflow">
         <v-remixicon name="riUploadLine" class="mr-2 -ml-1" />
-        Import workflow
+        {{ t('workflow.import') }}
       </ui-button>
       <ui-button variant="accent" @click="newWorkflow">
-        New workflow
+        {{ t('workflow.new') }}
       </ui-button>
     </div>
     <div v-if="Workflow.all().length === 0" class="py-12 flex items-center">
       <img src="@/assets/svg/alien.svg" class="w-96" />
       <div class="ml-4">
         <h1 class="text-2xl font-semibold max-w-md mb-6">
-          Oppss... It's looks like you don't have any workflows.
+          {{ t('message.empty') }}
         </h1>
-        <ui-button variant="accent" @click="newWorkflow"
-          >New workflow</ui-button
-        >
+        <ui-button variant="accent" @click="newWorkflow">
+          {{ t('workflow.new') }}
+        </ui-button>
       </div>
     </div>
     <div v-else class="grid gap-4 grid-cols-5 2xl:grid-cols-6">
@@ -50,13 +52,14 @@
         :key="workflow.id"
         @click="$router.push(`/workflows/${$event.id}`)"
         @execute="executeWorkflow"
-        @menuSelected="menuHandlers[$event.name]($event.data)"
+        @menuSelected="menuHandlers[$event.id]($event.data)"
       />
     </div>
   </div>
 </template>
 <script setup>
 import { computed, shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
 import { sendMessage } from '@/utils/message';
 import { exportWorkflow, importWorkflow } from '@/utils/workflow-data';
@@ -64,15 +67,13 @@ import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
 
 const dialog = useDialog();
+const { t } = useI18n();
 
-const sorts = [
-  { name: 'Name', id: 'name' },
-  { name: 'Created date', id: 'createdAt' },
-];
+const sorts = ['name', 'createdAt'];
 const menu = [
-  { name: 'export', icon: 'riDownloadLine' },
-  { name: 'rename', icon: 'riPencilLine' },
-  { name: 'delete', icon: 'riDeleteBin7Line' },
+  { id: 'export', name: t('common.export'), icon: 'riDownloadLine' },
+  { id: 'rename', name: t('common.rename'), icon: 'riPencilLine' },
+  { id: 'delete', name: t('common.delete'), icon: 'riDeleteBin7Line' },
 ];
 
 const state = shallowReactive({
@@ -95,9 +96,9 @@ function executeWorkflow(workflow) {
 }
 function newWorkflow() {
   dialog.prompt({
-    title: 'New workflow',
-    placeholder: 'Workflow name',
-    okText: 'Add workflow',
+    title: t('workflow.new'),
+    placeholder: t('common.name'),
+    okText: t('workflow.add'),
     onConfirm: (name) => {
       Workflow.insert({
         data: {
@@ -110,9 +111,9 @@ function newWorkflow() {
 }
 function deleteWorkflow({ name, id }) {
   dialog.confirm({
-    title: 'Delete workflow',
+    title: t('workflow.delete'),
     okVariant: 'danger',
-    body: `Are you sure you want to delete "${name}" workflow?`,
+    body: t('message.delete', { name }),
     onConfirm: () => {
       Workflow.delete(id);
     },
@@ -120,9 +121,9 @@ function deleteWorkflow({ name, id }) {
 }
 function renameWorkflow({ id, name }) {
   dialog.prompt({
-    title: 'Rename workflow',
-    placeholder: 'Workflow name',
-    okText: 'Rename',
+    title: t('workflow.rename'),
+    placeholder: t('common.name'),
+    okText: t('common.rename'),
     inputValue: name,
     onConfirm: (newName) => {
       Workflow.update({

+ 25 - 23
src/newtab/pages/collections/[id].vue

@@ -14,10 +14,10 @@
       />
       <div class="flex-grow"></div>
       <ui-button variant="accent" class="mr-4" @click="executeCollection">
-        Execute
+        {{ t('common.execute') }}
       </ui-button>
       <ui-button class="text-red-500" @click="deleteCollection">
-        Delete
+        {{ t('common.delete') }}
       </ui-button>
     </div>
     <div class="flex items-start">
@@ -32,8 +32,8 @@
           prepend-icon="riSearch2Line"
         />
         <ui-tabs v-model="state.sidebarTab" fill class="w-full mb-4">
-          <ui-tab value="workflows">Workflows</ui-tab>
-          <ui-tab value="blocks">Blocks</ui-tab>
+          <ui-tab value="workflows">{{ t('common.workflow', 2) }}</ui-tab>
+          <ui-tab value="blocks">{{ t('common.block', 2) }}</ui-tab>
         </ui-tabs>
         <draggable
           :list="state.sidebarTab === 'workflows' ? workflows : blocksArr"
@@ -61,10 +61,10 @@
               v-model="state.activeTab"
               class="border-none h-full space-x-1"
             >
-              <ui-tab value="flow">Flow</ui-tab>
-              <ui-tab value="logs">Logs</ui-tab>
+              <ui-tab value="flow">{{ t('collection.flow') }}</ui-tab>
+              <ui-tab value="logs">{{ t('common.log', 2) }}</ui-tab>
               <ui-tab value="running">
-                Running
+                {{ t('common.running') }}
                 <span
                   v-if="runningCollection.length > 0"
                   class="
@@ -82,12 +82,12 @@
                   {{ runningCollection.length }}
                 </span>
               </ui-tab>
-              <ui-tab value="options">Options</ui-tab>
+              <ui-tab value="options">{{ t('common.options') }}</ui-tab>
             </ui-tabs>
           </div>
           <div class="flex-grow"></div>
           <ui-button
-            v-tooltip="'Global data'"
+            v-tooltip="t('common.globalData')"
             icon
             @click="state.showGlobalData = !state.showGlobalData"
           >
@@ -112,7 +112,7 @@
                 p-4
               "
             >
-              Drop a workflow or block in here
+              {{ t('collection.dragDropText') }}
             </div>
             <draggable
               :model-value="collectionFlow"
@@ -166,7 +166,7 @@
                 src="@/assets/svg/files-and-folder.svg"
                 class="mx-auto max-w-sm"
               />
-              <p class="text-xl font-semibold">No data to show</p>
+              <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
             </div>
             <shared-logs-table :logs="logs" class="w-full">
               <template #item-append="{ log }">
@@ -186,7 +186,7 @@
                 src="@/assets/svg/files-and-folder.svg"
                 class="mx-auto max-w-sm"
               />
-              <p class="text-xl font-semibold">No data to show</p>
+              <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
             </div>
             <div class="grid grid-cols-2 gap-4">
               <shared-workflow-state
@@ -199,10 +199,10 @@
           <ui-tab-panel value="options">
             <ui-checkbox v-model="collectionOptions.atOnce">
               <p class="leading-tight">
-                Execute all workflows in the collection at once
+                {{ t('collection.options.atOnce.title') }}
               </p>
               <p class="text-sm text-gray-600 leading-tight">
-                Block not gonna executed when using this option
+                {{ t('collection.options.atOnce.description') }}
               </p>
             </ui-checkbox>
           </ui-tab-panel>
@@ -211,9 +211,9 @@
     </div>
   </div>
   <ui-modal v-model="state.showGlobalData" content-class="max-w-xl">
-    <template #header>Global data</template>
+    <template #header>{{ t('common.globalData') }}</template>
     <p class="inline-block">
-      This will overwrite the global data of the workflow
+      {{ t('collection.globalData.note') }}
     </p>
     <p class="float-right clear-both" title="Characters limit">
       {{ collection.globalData.length }}/{{ (1e4).toLocaleString() }}
@@ -233,6 +233,7 @@ import { nanoid } from 'nanoid';
 import { useStore } from 'vuex';
 import { useRoute, useRouter } from 'vue-router';
 import { PrismEditor } from 'vue-prism-editor';
+import { useI18n } from 'vue-i18n';
 import Draggable from 'vuedraggable';
 import { highlighter } from '@/lib/prism';
 import { useDialog } from '@/composable/dialog';
@@ -243,13 +244,19 @@ import Collection from '@/models/collection';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
 import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
 
+const { t } = useI18n();
+const store = useStore();
+const route = useRoute();
+const router = useRouter();
+const dialog = useDialog();
+
 const blocks = {
   'export-result': {
     type: 'block',
     id: 'export-result',
     icon: 'riDownloadLine',
-    name: 'Export result',
-    description: 'Export the collection result as JSON',
+    name: t('collection.blocks.export-result.name'),
+    description: t('collection.blocks.export-result.description'),
     data: {
       type: 'json',
     },
@@ -260,11 +267,6 @@ const blocksArr = Object.entries(blocks).map(([id, value]) => ({
   id,
 }));
 
-const store = useStore();
-const route = useRoute();
-const router = useRouter();
-const dialog = useDialog();
-
 const state = shallowReactive({
   query: '',
   activeTab: 'flow',

+ 18 - 10
src/newtab/pages/logs.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="container pt-8 pb-4 logs-list">
-    <h1 class="text-2xl font-semibold mb-6">Logs</h1>
+    <h1 class="text-2xl font-semibold mb-6">{{ t('common.log', 2) }}</h1>
     <logs-filters
       :sorts="sortsBuilder"
       :filters="filtersBuilder"
@@ -32,7 +32,6 @@
             <v-remixicon
               name="riDeleteBin7Line"
               class="text-red-500 cursor-pointer"
-              title="Delete log"
               @click="deleteLog(log.id)"
             />
           </div>
@@ -41,13 +40,13 @@
     </shared-logs-table>
     <div class="flex items-center justify-between mt-4">
       <div>
-        Showing
+        {{ t('components.pagination.text1') }}
         <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
           <option v-for="num in [10, 15, 25, 50, 100]" :key="num" :value="num">
             {{ num }}
           </option>
         </select>
-        items out of {{ filteredLogs.length }}
+        {{ t('components.pagination.text2', { count: filteredLogs.length }) }}
       </div>
       <ui-pagination
         v-model="pagination.currentPage"
@@ -60,15 +59,22 @@
       class="fixed right-0 bottom-0 m-5 shadow-xl space-x-2"
     >
       <ui-button @click="selectAllLogs">
-        {{ selectedLogs.length >= logs.length ? 'Deselect' : 'Select' }}
-        all
+        {{
+          t(
+            `log.${
+              selectedLogs.length >= logs.length ? 'deselectAll' : 'selectAll'
+            }`
+          )
+        }}
       </ui-button>
       <ui-button variant="danger" @click="deleteSelectedLogs">
-        Delete selected logs ({{ selectedLogs.length }})
+        {{ t('log.deleteSelected') }} ({{ selectedLogs.length }})
       </ui-button>
     </ui-card>
     <ui-modal v-model="exportDataModal.show">
-      <template #header> Data </template>
+      <template #header>
+        <span class="capitalize">{{ t('common.data') }}</span>
+      </template>
       <logs-data-viewer
         :log="exportDataModal.log"
         editor-class="logs-list-data"
@@ -79,12 +85,14 @@
 <script setup>
 import { shallowReactive, ref, computed } from 'vue';
 import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
 import Log from '@/models/log';
 import LogsFilters from '@/components/newtab/logs/LogsFilters.vue';
 import LogsDataViewer from '@/components/newtab/logs/LogsDataViewer.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
 
+const { t } = useI18n();
 const store = useStore();
 const dialog = useDialog();
 
@@ -155,9 +163,9 @@ function toggleSelectedLog(selected, logId) {
 }
 function deleteSelectedLogs() {
   dialog.confirm({
-    title: 'Delete logs',
+    title: t('log.delete.title'),
     okVariant: 'danger',
-    body: `Are you sure want to delete all the selected logs?`,
+    body: t('log.delete.description'),
     onConfirm: () => {
       const promises = selectedLogs.value.map((logId) => Log.delete(logId));
 

+ 42 - 22
src/newtab/pages/logs/[id].vue

@@ -2,25 +2,23 @@
   <div class="container pt-8 pb-4">
     <div class="flex items-center mb-8">
       <div>
-        <h1 class="text-2xl max-w-sm text-overflow font-semibold">
+        <h1 class="text-2xl max-w-md text-overflow font-semibold">
           {{ activeLog.name }}
         </h1>
         <p class="text-gray-600">
-          <span class="capitalize">
-            {{
-              activeLog.status === 'success' ? 'succeeded' : activeLog.status
-            }}
-          </span>
-          <span
-            :title="dayjs(activeLog.startedAt).format('DD MMM YYYY, hh:mm A')"
-          >
-            on {{ dayjs(activeLog.startedAt).format('DD MMM') }}
-          </span>
-          in {{ countDuration(activeLog.startedAt, activeLog.endedAt) }}
+          {{
+            t(`log.description.text`, {
+              status: t(`log.description.status.${activeLog.status}`),
+              date: dayjs(activeLog.startedAt).format('DD MMM'),
+              duration: countDuration(activeLog.startedAt, activeLog.endedAt),
+            })
+          }}
         </p>
       </div>
       <div class="flex-grow"></div>
-      <ui-button class="text-red-500" @click="deleteLog"> Delete </ui-button>
+      <ui-button class="text-red-500" @click="deleteLog">
+        {{ t('common.delete') }}
+      </ui-button>
     </div>
     <div class="flex items-start">
       <div class="w-7/12 mr-6">
@@ -31,8 +29,7 @@
             class="mb-4 flex"
           >
             <v-remixicon name="riArrowLeftLine" class="mr-2" />
-            Go back
-            <span class="font-semibold mx-1">{{ collectionLog.name }}</span> log
+            {{ t('log.goBack', { name: collectionLog.name }) }}
           </router-link>
           <ui-list-item v-for="(item, index) in history" :key="index">
             <span
@@ -46,7 +43,7 @@
                 {{ item.name }}
               </p>
               <p
-                v-if="item.type === 'error'"
+                v-if="item.message"
                 :title="item.message"
                 class="
                   text-sm
@@ -76,7 +73,7 @@
           class="flex items-center justify-between mt-4"
         >
           <div>
-            Showing
+            {{ t('components.pagination.text1') }}
             <select
               v-model="pagination.perPage"
               class="p-1 rounded-md bg-input"
@@ -89,7 +86,11 @@
                 {{ num }}
               </option>
             </select>
-            items out of {{ activeLog.history.length }}
+            {{
+              t('components.pagination.text2', {
+                count: activeLog.history.length,
+              })
+            }}
           </div>
           <ui-pagination
             v-model="pagination.currentPage"
@@ -107,6 +108,7 @@
 <script setup>
 import { computed, onMounted, shallowReactive } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
+import { useI18n } from 'vue-i18n';
 import Log from '@/models/log';
 import dayjs from '@/lib/dayjs';
 import { countDuration } from '@/utils/helper';
@@ -135,6 +137,7 @@ const logsType = {
   },
 };
 
+const { t, te } = useI18n();
 const route = useRoute();
 const router = useRouter();
 
@@ -143,12 +146,29 @@ const pagination = shallowReactive({
   currentPage: 1,
 });
 
+function translateLog(log) {
+  const { name, message, type } = log;
+  const getTranslatation = (path, def) => (te(path) ? t(path) : def);
+
+  if (['finish', 'stop'].includes(type)) {
+    log.name = t(`log.types.${type}`);
+  } else {
+    log.name = getTranslatation(`workflow.blocks.${name}.name`, name);
+  }
+
+  log.message = getTranslatation(`log.messages.${message}`, message);
+
+  return log;
+}
+
 const activeLog = computed(() => Log.find(route.params.id));
 const history = computed(() =>
-  activeLog.value.history.slice(
-    (pagination.currentPage - 1) * pagination.perPage,
-    pagination.currentPage * pagination.perPage
-  )
+  activeLog.value.history
+    .slice(
+      (pagination.currentPage - 1) * pagination.perPage,
+      pagination.currentPage * pagination.perPage
+    )
+    .map(translateLog)
 );
 const collectionLog = computed(() => Log.find(activeLog.value.collectionLogId));
 

+ 18 - 19
src/newtab/pages/workflows/[id].vue

@@ -21,10 +21,10 @@
           v-model="activeTab"
           class="border-none px-2 rounded-lg h-full space-x-1 bg-white"
         >
-          <ui-tab value="editor">Editor</ui-tab>
-          <ui-tab value="logs">Logs</ui-tab>
+          <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
+          <ui-tab value="logs">{{ t('common.log', 2) }}</ui-tab>
           <ui-tab value="running" class="flex items-center">
-            Running
+            {{ t('common.running') }}
             <span
               v-if="workflowState.length > 0"
               class="
@@ -69,7 +69,7 @@
                 src="@/assets/svg/files-and-folder.svg"
                 class="mx-auto max-w-sm"
               />
-              <p class="text-xl font-semibold">No data to show</p>
+              <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
             </div>
             <shared-logs-table :logs="logs" class="w-full">
               <template #item-append="{ log: itemLog }">
@@ -89,7 +89,7 @@
                 src="@/assets/svg/files-and-folder.svg"
                 class="mx-auto max-w-sm"
               />
-              <p class="text-xl font-semibold">No data to show</p>
+              <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
             </div>
             <div class="grid grid-cols-2 gap-4">
               <shared-workflow-state
@@ -125,6 +125,7 @@ import {
 } from 'vue';
 import { useStore } from 'vuex';
 import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
+import { useI18n } from 'vue-i18n';
 import emitter from 'tiny-emitter/instance';
 import { sendMessage } from '@/utils/message';
 import { debounce } from '@/utils/helper';
@@ -143,6 +144,7 @@ import WorkflowDataColumns from '@/components/newtab/workflow/WorkflowDataColumn
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
 import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
 
+const { t } = useI18n();
 const store = useStore();
 const route = useRoute();
 const router = useRouter();
@@ -152,18 +154,18 @@ const workflowId = route.params.id;
 const workflowModals = {
   'data-columns': {
     icon: 'riKey2Line',
-    title: 'Data columns',
     component: WorkflowDataColumns,
+    title: t('workflow.dataColumns.title'),
   },
   'global-data': {
-    title: 'Global data',
     icon: 'riDatabase2Line',
     component: WorkflowGlobalData,
+    title: t('common.globalData'),
   },
   settings: {
     icon: 'riSettings3Line',
-    title: 'Settings',
     component: WorkflowSettings,
+    title: t('common.settings'),
   },
 };
 
@@ -241,7 +243,7 @@ function editBlock(data) {
 function executeWorkflow() {
   if (editor.value.getNodesFromName('trigger').length === 0) {
     /* eslint-disable-next-line */
-    alert("Can't find a trigger block");
+    alert(t('message.noTriggerBlock'));
     return;
   }
 
@@ -258,9 +260,9 @@ function handleEditorDataChanged() {
 }
 function deleteWorkflow() {
   dialog.confirm({
-    title: 'Delete workflow',
+    title: t('workflow.delete'),
     okVariant: 'danger',
-    body: `Are you sure you want to delete "${workflow.value.name}" workflow?`,
+    body: t('message.delete', { name: workflow.value.name }),
     onConfirm: () => {
       Workflow.delete(route.params.id).then(() => {
         router.replace('/workflows');
@@ -270,9 +272,9 @@ function deleteWorkflow() {
 }
 function renameWorkflow() {
   dialog.prompt({
-    title: 'Rename workflow',
-    placeholder: 'Workflow name',
-    okText: 'Rename',
+    title: t('workflow.rename'),
+    placeholder: t('common.name'),
+    okText: t('common.rename'),
     inputValue: workflow.value.name,
     onConfirm: (newName) => {
       Workflow.update({
@@ -295,10 +297,7 @@ provide('workflow', {
 onBeforeRouteLeave(() => {
   if (!state.isDataChanged) return;
 
-  // eslint-disable-next-line no-alert
-  const answer = window.confirm(
-    'Do you really want to leave? you have unsaved changes!'
-  );
+  const answer = window.confirm(t('common.notSaved'));
 
   if (!answer) return false;
 });
@@ -311,7 +310,7 @@ onMounted(() => {
 
   window.onbeforeunload = () => {
     if (state.isDataChanged) {
-      return 'Changes you made may not be saved.';
+      return t('common.notSaved');
     }
   };
 

+ 6 - 0
src/newtab/router.js

@@ -6,6 +6,7 @@ import Collections from './pages/Collections.vue';
 import CollectionsDetails from './pages/collections/[id].vue';
 import Logs from './pages/Logs.vue';
 import LogsDetails from './pages/logs/[id].vue';
+import Settings from './pages/Settings.vue';
 
 const routes = [
   {
@@ -43,6 +44,11 @@ const routes = [
     path: '/logs/:id',
     component: LogsDetails,
   },
+  {
+    name: 'settings',
+    path: '/settings',
+    component: Settings,
+  },
 ];
 
 export default createRouter({

+ 16 - 9
src/popup/App.vue

@@ -1,22 +1,29 @@
 <template>
-  <router-view v-if="retrieved" />
-  <ui-dialog />
+  <template v-if="retrieved">
+    <router-view />
+    <ui-dialog />
+  </template>
 </template>
 <script setup>
-import { ref } from 'vue';
+import { ref, onMounted } from 'vue';
 import { useStore } from 'vuex';
+import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
 
 const store = useStore();
 const retrieved = ref(false);
 
-store
-  .dispatch('retrieve')
-  .then(() => {
+onMounted(async () => {
+  try {
+    await store.dispatch('retrieve');
+    await loadLocaleMessages(store.state.locale, 'popup');
+    await setI18nLanguage(store.state.locale);
+
     retrieved.value = true;
-  })
-  .catch(() => {
+  } catch (error) {
+    console.error(error);
     retrieved.value = true;
-  });
+  }
+});
 </script>
 <style>
 body {

+ 2 - 0
src/popup/index.js

@@ -3,6 +3,7 @@ import App from './App.vue';
 import router from './router';
 import store from '../store';
 import compsUi from '../lib/comps-ui';
+import vueI18n from '../lib/vue-i18n';
 import vRemixicon, { icons } from '../lib/v-remixicon';
 import '../assets/css/tailwind.css';
 import '../assets/css/fonts.css';
@@ -11,6 +12,7 @@ createApp(App)
   .use(router)
   .use(store)
   .use(compsUi)
+  .use(vueI18n)
   .use(vRemixicon, icons)
   .mount('#app');
 

+ 17 - 12
src/popup/pages/Home.vue

@@ -5,35 +5,38 @@
   >
     <ui-input
       v-model="query"
+      :placeholder="`${t('common.search')}...`"
       autofocus
       prepend-icon="riSearch2Line"
       class="flex-1 search-input"
-      placeholder="Search..."
     ></ui-input>
     <ui-button
-      v-tooltip="
-        haveAccess ? 'Element selector' : 'Don\'t have access to this site'
-      "
+      v-tooltip="t(`home.elementSelector.${haveAccess ? 'name' : 'noAccess'}`)"
       icon
       class="ml-3"
       @click="selectElement"
     >
       <v-remixicon name="riFocus3Line" />
     </ui-button>
-    <ui-button icon title="Dashboard" class="ml-3" @click="openDashboard">
+    <ui-button
+      icon
+      :title="t('common.dashboard')"
+      class="ml-3"
+      @click="openDashboard"
+    >
       <v-remixicon name="riHome5Line" />
     </ui-button>
   </div>
   <div class="px-5 pb-5 space-y-2">
     <ui-card v-if="Workflow.all().length === 0" class="text-center">
       <img src="@/assets/svg/alien.svg" />
-      <p class="font-semibold">It looks like you don't have any workflows</p>
+      <p class="font-semibold">{{ t('message.empty') }}</p>
       <ui-button
         variant="accent"
         class="mt-6"
         @click="openDashboard('/workflows')"
       >
-        New workflow
+        {{ t('home.workflow.new') }}
       </ui-button>
     </ui-card>
     <home-workflow-card
@@ -49,12 +52,14 @@
 </template>
 <script setup>
 import { ref, computed, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
 import browser from 'webextension-polyfill';
 import { useDialog } from '@/composable/dialog';
 import { sendMessage } from '@/utils/message';
 import Workflow from '@/models/workflow';
 import HomeWorkflowCard from '@/components/popup/home/HomeWorkflowCard.vue';
 
+const { t } = useI18n();
 const dialog = useDialog();
 
 const query = ref('');
@@ -74,9 +79,9 @@ function executeWorkflow(workflow) {
 }
 function renameWorkflow({ id, name }) {
   dialog.prompt({
-    title: 'Rename workflow',
-    placeholder: 'Workflow name',
-    okText: 'Rename',
+    title: t('home.workflow.rename'),
+    placeholder: t('common.name'),
+    okText: t('common.rename'),
     inputValue: name,
     onConfirm: (newName) => {
       Workflow.update({
@@ -90,9 +95,9 @@ function renameWorkflow({ id, name }) {
 }
 function deleteWorkflow({ id, name }) {
   dialog.confirm({
-    title: 'Delete workflow',
+    title: t('home.workflow.delete'),
     okVariant: 'danger',
-    body: `Are you sure you want to delete "${name}" workflow?`,
+    body: t('message.delete', { name }),
     onConfirm: () => {
       Workflow.delete(id);
     },

+ 25 - 2
src/store/index.js

@@ -8,6 +8,9 @@ const store = createStore({
   plugins: [vuexORM(models)],
   state: () => ({
     workflowState: [],
+    settings: {
+      locale: 'en',
+    },
   }),
   mutations: {
     updateState(state, { key, value }) {
@@ -21,7 +24,18 @@ const store = createStore({
       ),
   },
   actions: {
-    async retrieve({ dispatch, getters }, keys = 'workflows') {
+    updateSettings({ state, commit }, data) {
+      commit('updateState', {
+        key: 'settings',
+        value: {
+          ...state.settings,
+          ...data,
+        },
+      });
+
+      browser.storage.local.set({ settings: state.settings });
+    },
+    async retrieve({ dispatch, getters, commit, state }, keys = 'workflows') {
       try {
         const data = await browser.storage.local.get(keys);
         const promises = Object.keys(data).map((entity) => {
@@ -34,7 +48,16 @@ const store = createStore({
             data: data[entity],
           });
         });
-        const { isFirstTime } = await browser.storage.local.get('isFirstTime');
+
+        const { isFirstTime, settings } = await browser.storage.local.get([
+          'isFirstTime',
+          'settings',
+        ]);
+
+        commit('updateState', {
+          key: 'settings',
+          value: { ...state.settings, ...(settings || {}) },
+        });
 
         if (isFirstTime) {
           await dispatch('entities/insert', {

+ 4 - 0
src/utils/shared.js

@@ -9,6 +9,7 @@ export const tasks = {
     editComponent: 'EditTrigger',
     category: 'general',
     inputs: 0,
+    docs: true,
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
@@ -324,6 +325,7 @@ export const tasks = {
     category: 'interaction',
     inputs: 1,
     outputs: 1,
+    docs: true,
     allowedInputs: true,
     maxConnection: 1,
     data: {
@@ -522,3 +524,5 @@ export const contentTypes = [
   { name: 'application/json', value: 'json' },
   { name: 'application/x-www-form-urlencoded', value: 'form' },
 ];
+
+export const supportLocales = [{ id: 'en', name: 'English' }];

+ 8 - 1
src/utils/workflow-data.js

@@ -20,7 +20,14 @@ export function importWorkflow() {
 }
 
 export function exportWorkflow(workflow) {
-  const keys = ['dataColumns', 'drawflow', 'icon', 'name', 'settings'];
+  const keys = [
+    'dataColumns',
+    'drawflow',
+    'icon',
+    'name',
+    'settings',
+    'globalData',
+  ];
   const content = {};
 
   keys.forEach((key) => {

+ 7 - 3
webpack.config.js

@@ -62,9 +62,6 @@ const options = {
         test: /\.css$/,
         use: [
           MiniCssExtractPlugin.loader,
-          // {
-          //   loader: 'style-loader',
-          // },
           {
             loader: 'css-loader',
           },
@@ -73,6 +70,13 @@ const options = {
           },
         ],
       },
+      {
+        test: /\.(json5?|ya?ml)$/, // target json, json5, yaml and yml files
+        type: 'javascript/auto',
+        // Use `Rule.include` to specify the files of locale messages to be pre-compiled
+        include: [path.resolve(__dirname, './src/locales')],
+        loader: '@intlify/vue-i18n-loader',
+      },
       {
         test: new RegExp(`.(${fileExtensions.join('|')})$`),
         loader: 'file-loader',

+ 128 - 8
yarn.lock

@@ -918,6 +918,79 @@
   resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
   integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
 
+"@intlify/bundle-utils@next":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@intlify/bundle-utils/-/bundle-utils-2.1.0.tgz#2d5ca80335dcccb8f1d72423e7261b2d23a0cda2"
+  integrity sha512-ogvmAjbSq0su4ijQbwqRfb0yimizZSK+HHV0SF3XCHSCtL+PDdRIDkkeNpNdPxNmdahORHXknC06Umehz4nTnQ==
+  dependencies:
+    "@intlify/message-compiler" beta
+    "@intlify/shared" beta
+    jsonc-eslint-parser "^1.0.1"
+    source-map "^0.6.1"
+    yaml-eslint-parser "^0.3.2"
+
+"@intlify/core-base@9.2.0-beta.20":
+  version "9.2.0-beta.20"
+  resolved "https://registry.yarnpkg.com/@intlify/core-base/-/core-base-9.2.0-beta.20.tgz#2c4ea4d244d6835d2bc2aa6a60bf9905d993125c"
+  integrity sha512-2pWte5Qzabs6tU51B6MmNSwzmCo4SoX6M9ZmnesyyDDYJaMpu7/MJ0R5yOZ2G/kbtzn6gYqI2TRPJp7aEhnWeg==
+  dependencies:
+    "@intlify/devtools-if" "9.2.0-beta.20"
+    "@intlify/message-compiler" "9.2.0-beta.20"
+    "@intlify/shared" "9.2.0-beta.20"
+    "@intlify/vue-devtools" "9.2.0-beta.20"
+
+"@intlify/devtools-if@9.2.0-beta.20":
+  version "9.2.0-beta.20"
+  resolved "https://registry.yarnpkg.com/@intlify/devtools-if/-/devtools-if-9.2.0-beta.20.tgz#3ffef80404a6b170802dcf685fb3771fc8f82554"
+  integrity sha512-Dfx+IzJMB02EW4khgIH9icvxKymRHoSXB1EweE95rs+A6cJtS8LyJMualwoy3T/C6f0eFALg2YjKHLBCXGw1zg==
+  dependencies:
+    "@intlify/shared" "9.2.0-beta.20"
+
+"@intlify/message-compiler@9.2.0-beta.20":
+  version "9.2.0-beta.20"
+  resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-9.2.0-beta.20.tgz#ea63c34fc5d0827f86eff9344464b887121f52ac"
+  integrity sha512-nbJFuzwm1XZuKzhXqcURYabZ7JqyhyibIs3i661NXrblutd8ZiuOWUB20zIYjKFRLL38QWIijUclOyp+CSW5YQ==
+  dependencies:
+    "@intlify/shared" "9.2.0-beta.20"
+    source-map "0.6.1"
+
+"@intlify/message-compiler@beta":
+  version "9.2.0-beta.21"
+  resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-9.2.0-beta.21.tgz#fb72c5f67ab31240b1416ef322c27d4d5a878bb6"
+  integrity sha512-Dk84bcd/REMxHCcS8PaWnoimGYZFVoxA5DE46Y4PaF7DSh6o9V2ckjHPE+O6/G9JPHlmH1Bmtl3G28xzP3tAXw==
+  dependencies:
+    "@intlify/shared" "9.2.0-beta.21"
+    source-map "0.6.1"
+
+"@intlify/shared@9.2.0-beta.20":
+  version "9.2.0-beta.20"
+  resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-9.2.0-beta.20.tgz#a434928e5526580312658fdc3da86e5c398a840d"
+  integrity sha512-mwdvobi1gBNWt+dgd68IuRHG3xh6BXs0472ZO8vClr49HOhoVg9M+myQQntBQBHblcXreM8NX/pLkBzXSyUfmQ==
+
+"@intlify/shared@9.2.0-beta.21", "@intlify/shared@beta":
+  version "9.2.0-beta.21"
+  resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-9.2.0-beta.21.tgz#5561ee06fe6c0f21ff0538fd154b7dd836f64161"
+  integrity sha512-A4MgJqraJ30q6nb4FhkbEhSaRzPhBaXFyw31aBQEA2b3/pYKpuRopYB+ouPGKk6A1iL0WE1uZU4Emf+DtsU44w==
+
+"@intlify/vue-devtools@9.2.0-beta.20":
+  version "9.2.0-beta.20"
+  resolved "https://registry.yarnpkg.com/@intlify/vue-devtools/-/vue-devtools-9.2.0-beta.20.tgz#811d2fc8ee3dc6df246d5f536572bcc8f541de50"
+  integrity sha512-vqX/JTZt5um78ypTySdW1skJp6TRDgr0TrU4L/j22d5Oth8E3nh9FgtoYvHEFm40dT284sco8dbJiud58d20Ew==
+  dependencies:
+    "@intlify/core-base" "9.2.0-beta.20"
+    "@intlify/shared" "9.2.0-beta.20"
+
+"@intlify/vue-i18n-loader@^4.0.1":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@intlify/vue-i18n-loader/-/vue-i18n-loader-4.0.1.tgz#2bac7b20293bcee9c11ddc34cb4982ed0d64aec9"
+  integrity sha512-xI859YIFDBztdqSQqlfD44ijH0Z4y+DJGDIWbClc4RXSfOgS/Zm2v1Bd3WN9CFYSQOeU6ARTcx4K3izxgtWMVA==
+  dependencies:
+    "@intlify/bundle-utils" next
+    "@intlify/shared" beta
+    js-yaml "^4.1.0"
+    json5 "^2.2.0"
+    loader-utils "^2.0.0"
+
 "@medv/finder@^2.1.0":
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/@medv/finder/-/finder-2.1.0.tgz#5c53cdaac3b87057b9e5579ca1282b2397624016"
@@ -1055,6 +1128,11 @@
   resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.0.0-beta.15.tgz#ad7cb384e062f165bcf9c83732125bffbc2ad83d"
   integrity sha512-quBx4Jjpexo6KDiNUGFr/zF/2A4srKM9S9v2uHgMXSU//hjgq1eGzqkIFql8T9gfX5ZaVOUzYBP3jIdIR3PKIA==
 
+"@vue/devtools-api@^6.0.0-beta.13":
+  version "6.0.0-beta.20.1"
+  resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.0.0-beta.20.1.tgz#5b499647e929c35baf2a66a399578f9aa4601142"
+  integrity sha512-R2rfiRY+kZugzWh9ZyITaovx+jpU4vgivAEAiz80kvh3yviiTU3CBuGuyWpSwGz9/C7TkSWVM/FtQRGlZ16n8Q==
+
 "@vue/reactivity@3.2.19":
   version "3.2.19"
   resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.19.tgz#fc6e0f0106f295226835cfed5ff5f84d927bea65"
@@ -1300,7 +1378,7 @@ acorn-walk@^7.0.0:
   resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
   integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
 
-acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.0:
+acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.0, acorn@^7.4.1:
   version "7.4.1"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
   integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
@@ -1461,6 +1539,11 @@ argparse@^1.0.7:
   dependencies:
     sprintf-js "~1.0.2"
 
+argparse@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+  integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
 arr-diff@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@@ -2841,7 +2924,7 @@ eslint@7.32.0:
     text-table "^0.2.0"
     v8-compile-cache "^2.0.3"
 
-espree@^6.2.1:
+espree@^6.0.0, espree@^6.2.1:
   version "6.2.1"
   resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a"
   integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==
@@ -4121,6 +4204,13 @@ js-yaml@^3.13.1:
     argparse "^1.0.7"
     esprima "^4.0.0"
 
+js-yaml@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
+  dependencies:
+    argparse "^2.0.1"
+
 jsesc@^2.5.1:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
@@ -4168,13 +4258,24 @@ json5@^1.0.1:
   dependencies:
     minimist "^1.2.0"
 
-json5@^2.1.2:
+json5@^2.1.2, json5@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
   integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
   dependencies:
     minimist "^1.2.5"
 
+jsonc-eslint-parser@^1.0.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/jsonc-eslint-parser/-/jsonc-eslint-parser-1.4.1.tgz#8cbe99f6f5199acbc5a823c4c0b6135411027fa6"
+  integrity sha512-hXBrvsR1rdjmB2kQmUjf1rEIa+TqHBGMge8pwi++C+Si1ad7EjZrJcpgwym+QGK/pqTx+K7keFAtLlVNdLRJOg==
+  dependencies:
+    acorn "^7.4.1"
+    eslint-utils "^2.1.0"
+    eslint-visitor-keys "^1.3.0"
+    espree "^6.0.0"
+    semver "^6.3.0"
+
 jsonfile@^6.0.1:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
@@ -6079,16 +6180,16 @@ source-map-url@^0.4.0:
   resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56"
   integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==
 
+source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
 source-map@^0.5.0, source-map@^0.5.6:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
 
-source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
-  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
-
 source-map@~0.7.2:
   version "0.7.3"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
@@ -6698,6 +6799,16 @@ vue-eslint-parser@^7.10.0:
     lodash "^4.17.21"
     semver "^6.3.0"
 
+vue-i18n@^9.2.0-beta.20:
+  version "9.2.0-beta.20"
+  resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-9.2.0-beta.20.tgz#145561dbb7035c945ac2b30cd791994553f6809e"
+  integrity sha512-0MLOKveMjMu5Qq8UM+fBWn/ReP3hYxs9eSaqHiKLnYbqLDi/PjHZ+cLcdzBKcyru/xYqlbXoxlwUvVVCe6WeGg==
+  dependencies:
+    "@intlify/core-base" "9.2.0-beta.20"
+    "@intlify/shared" "9.2.0-beta.20"
+    "@intlify/vue-devtools" "9.2.0-beta.20"
+    "@vue/devtools-api" "^6.0.0-beta.13"
+
 vue-loader@16.8.1:
   version "16.8.1"
   resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-16.8.1.tgz#354f12bc0897954158b71590f800295713a7792d"
@@ -7011,6 +7122,15 @@ yallist@^4.0.0:
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
+yaml-eslint-parser@^0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/yaml-eslint-parser/-/yaml-eslint-parser-0.3.2.tgz#c7f5f3904f1c06ad55dc7131a731b018426b4898"
+  integrity sha512-32kYO6kJUuZzqte82t4M/gB6/+11WAuHiEnK7FreMo20xsCKPeFH5tDBU7iWxR7zeJpNnMXfJyXwne48D0hGrg==
+  dependencies:
+    eslint-visitor-keys "^1.3.0"
+    lodash "^4.17.20"
+    yaml "^1.10.0"
+
 yaml@^1.10.0, yaml@^1.10.2:
   version "1.10.2"
   resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"