engine.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import browser from 'webextension-polyfill';
  2. import { nanoid } from 'nanoid';
  3. import { tasks } from '@/utils/shared';
  4. import { convertData, waitTabLoaded } from './helper';
  5. import { toCamelCase, parseJSON, isObject, objectHasKey } from '@/utils/helper';
  6. import referenceData from '@/utils/reference-data';
  7. import executeContentScript from './execute-content-script';
  8. class WorkflowEngine {
  9. constructor(
  10. workflow,
  11. { states, logger, blocksHandler, tabId, parentWorkflow, globalData }
  12. ) {
  13. this.id = nanoid();
  14. this.states = states;
  15. this.logger = logger;
  16. this.workflow = workflow;
  17. this.blocksHandler = blocksHandler;
  18. this.parentWorkflow = parentWorkflow;
  19. this.saveLog = workflow.settings?.saveLog ?? true;
  20. this.loopList = {};
  21. this.repeatedTasks = {};
  22. this.windowId = null;
  23. this.currentBlock = null;
  24. this.childWorkflowId = null;
  25. this.isDestroyed = false;
  26. this.isUsingProxy = false;
  27. this.blocks = {};
  28. this.history = [];
  29. this.eventListeners = {};
  30. this.columns = { column: { index: 0, type: 'any' } };
  31. const globalDataValue = globalData || workflow.globalData;
  32. this.activeTab = {
  33. url: '',
  34. id: tabId,
  35. frameId: 0,
  36. frames: {},
  37. groupId: null,
  38. };
  39. this.referenceData = {
  40. table: [],
  41. loopData: {},
  42. workflow: {},
  43. variables: {},
  44. googleSheets: {},
  45. globalData: parseJSON(globalDataValue, globalDataValue),
  46. };
  47. this.onWorkflowStopped = (id) => {
  48. if (this.id !== id || this.isDestroyed) return;
  49. this.stop();
  50. };
  51. }
  52. init(currentBlock) {
  53. if (this.workflow.isDisabled) return;
  54. if (!this.states) {
  55. console.error(`"${this.workflow.name}" workflow doesn't have states`);
  56. this.destroy('error');
  57. return;
  58. }
  59. const { drawflow } = this.workflow;
  60. const flow =
  61. typeof drawflow === 'string' ? parseJSON(drawflow, {}) : drawflow;
  62. const blocks = flow?.drawflow?.Home.data;
  63. if (!blocks) {
  64. console.error(`${this.workflow.name} doesn't have blocks`);
  65. return;
  66. }
  67. const triggerBlock = Object.values(blocks).find(
  68. ({ name }) => name === 'trigger'
  69. );
  70. if (!triggerBlock) {
  71. console.error(`${this.workflow.name} doesn't have a trigger block`);
  72. return;
  73. }
  74. const workflowTable = this.workflow.table || this.workflow.dataColumns;
  75. const dataColumns = Array.isArray(workflowTable)
  76. ? workflowTable
  77. : Object.values(workflowTable);
  78. dataColumns.forEach(({ name, type }) => {
  79. this.columns[name] = { index: 0, type };
  80. });
  81. this.blocks = blocks;
  82. this.startedTimestamp = Date.now();
  83. this.workflow.table = dataColumns;
  84. this.currentBlock = currentBlock || triggerBlock;
  85. this.states.on('stop', this.onWorkflowStopped);
  86. this.states
  87. .add(this.id, {
  88. state: this.state,
  89. workflowId: this.workflow.id,
  90. parentState: this.parentWorkflow,
  91. })
  92. .then(() => {
  93. this.executeBlock(this.currentBlock);
  94. });
  95. }
  96. resume({ id, state }) {
  97. this.id = id;
  98. Object.keys(state).forEach((key) => {
  99. this[key] = state[key];
  100. });
  101. this.init(state.currentBlock);
  102. }
  103. addLogHistory(detail) {
  104. if (
  105. !this.saveLog &&
  106. (this.history.length >= 1001 || detail.name === 'blocks-group') &&
  107. detail.type !== 'error'
  108. )
  109. return;
  110. this.history.push(detail);
  111. }
  112. addDataToColumn(key, value) {
  113. if (Array.isArray(key)) {
  114. key.forEach((item) => {
  115. if (!isObject(item)) return;
  116. Object.entries(item).forEach(([itemKey, itemValue]) => {
  117. this.addDataToColumn(itemKey, itemValue);
  118. });
  119. });
  120. return;
  121. }
  122. const columnName = objectHasKey(this.columns, key) ? key : 'column';
  123. const currentColumn = this.columns[columnName];
  124. const convertedValue = convertData(value, currentColumn.type);
  125. if (objectHasKey(this.referenceData.table, currentColumn.index)) {
  126. this.referenceData.table[currentColumn.index][columnName] =
  127. convertedValue;
  128. } else {
  129. this.referenceData.table.push({ [columnName]: convertedValue });
  130. }
  131. currentColumn.index += 1;
  132. }
  133. async stop() {
  134. try {
  135. if (this.childWorkflowId) {
  136. await this.states.stop(this.childWorkflowId);
  137. }
  138. await this.destroy('stopped');
  139. } catch (error) {
  140. console.error(error);
  141. }
  142. }
  143. async executeQueue() {
  144. const { workflowQueue } = await browser.storage.local.get('workflowQueue');
  145. const queueIndex = (workflowQueue || []).indexOf(this.workflow.id);
  146. if (!workflowQueue || queueIndex === -1) return;
  147. const engine = new WorkflowEngine(this.workflow, {
  148. logger: this.logger,
  149. states: this.states,
  150. blocksHandler: this.blocksHandler,
  151. });
  152. engine.init();
  153. workflowQueue.splice(queueIndex, 1);
  154. await browser.storage.local.set({ workflowQueue });
  155. }
  156. async destroy(status, message) {
  157. try {
  158. if (this.isDestroyed) return;
  159. if (this.isUsingProxy) chrome.proxy.settings.clear({});
  160. const endedTimestamp = Date.now();
  161. this.executeQueue();
  162. if (!this.workflow.isTesting && this.saveLog) {
  163. const { name, id } = this.workflow;
  164. await this.logger.add({
  165. name,
  166. status,
  167. message,
  168. id: this.id,
  169. workflowId: id,
  170. history: this.history,
  171. endedAt: endedTimestamp,
  172. parentLog: this.parentWorkflow,
  173. startedAt: this.startedTimestamp,
  174. data: {
  175. table: this.referenceData.table,
  176. variables: this.referenceData.variables,
  177. },
  178. });
  179. }
  180. this.states.off('stop', this.onWorkflowStopped);
  181. await this.states.delete(this.id);
  182. this.dispatchEvent('destroyed', {
  183. status,
  184. message,
  185. id: this.id,
  186. currentBlock: this.currentBlock,
  187. });
  188. this.isDestroyed = true;
  189. this.eventListeners = {};
  190. } catch (error) {
  191. console.error(error);
  192. }
  193. }
  194. async executeBlock(block, prevBlockData) {
  195. const currentState = await this.states.get(this.id);
  196. if (!currentState || currentState.isDestroyed) {
  197. if (this.isDestroyed) return;
  198. await this.destroy('stopped');
  199. return;
  200. }
  201. this.currentBlock = block;
  202. this.referenceData.prevBlockData = prevBlockData;
  203. this.referenceData.activeTabUrl = this.activeTab.url || '';
  204. await this.states.update(this.id, { state: this.state });
  205. this.dispatchEvent('update', { state: this.state });
  206. const startExecutedTime = Date.now();
  207. const blockHandler = this.blocksHandler[toCamelCase(block?.name)];
  208. const handler =
  209. !blockHandler && tasks[block.name].category === 'interaction'
  210. ? this.blocksHandler.interactionBlock
  211. : blockHandler;
  212. if (!handler) {
  213. console.error(`"${block.name}" block doesn't have a handler`);
  214. this.destroy('stopped');
  215. return;
  216. }
  217. const replacedBlock = referenceData({
  218. block,
  219. data: this.referenceData,
  220. refKeys: tasks[block.name].refDataKeys,
  221. });
  222. const blockDelay = this.workflow.settings?.blockDelay || 0;
  223. try {
  224. const result = await handler.call(this, replacedBlock, {
  225. prevBlockData,
  226. refData: this.referenceData,
  227. });
  228. this.addLogHistory({
  229. name: block.name,
  230. logId: result.logId,
  231. type: result.status || 'success',
  232. duration: Math.round(Date.now() - startExecutedTime),
  233. });
  234. if (result.nextBlockId) {
  235. setTimeout(() => {
  236. this.executeBlock(this.blocks[result.nextBlockId], result.data);
  237. }, blockDelay);
  238. } else {
  239. this.addLogHistory({
  240. type: 'finish',
  241. name: 'finish',
  242. });
  243. this.dispatchEvent('finish');
  244. this.destroy('success');
  245. }
  246. } catch (error) {
  247. this.addLogHistory({
  248. type: 'error',
  249. message: error.message,
  250. name: block.name,
  251. ...(error.data || {}),
  252. });
  253. if (
  254. this.workflow.settings.onError === 'keep-running' &&
  255. error.nextBlockId
  256. ) {
  257. setTimeout(() => {
  258. this.executeBlock(this.blocks[error.nextBlockId], error.data || '');
  259. }, blockDelay);
  260. } else {
  261. this.destroy('error', error.message);
  262. }
  263. console.error(`${block.name}:`, error);
  264. }
  265. }
  266. dispatchEvent(name, params) {
  267. const listeners = this.eventListeners[name];
  268. if (!listeners) return;
  269. listeners.forEach((callback) => {
  270. callback(params);
  271. });
  272. }
  273. on(name, listener) {
  274. (this.eventListeners[name] = this.eventListeners[name] || []).push(
  275. listener
  276. );
  277. }
  278. get state() {
  279. const keys = [
  280. 'history',
  281. 'columns',
  282. 'activeTab',
  283. 'isUsingProxy',
  284. 'currentBlock',
  285. 'referenceData',
  286. 'childWorkflowId',
  287. 'startedTimestamp',
  288. ];
  289. const state = {
  290. name: this.workflow.name,
  291. icon: this.workflow.icon,
  292. };
  293. keys.forEach((key) => {
  294. state[key] = this[key];
  295. });
  296. return state;
  297. }
  298. async _sendMessageToTab(payload, options = {}) {
  299. try {
  300. if (!this.activeTab.id) {
  301. const error = new Error('no-tab');
  302. error.workflowId = this.id;
  303. throw error;
  304. }
  305. await waitTabLoaded(this.activeTab.id);
  306. await executeContentScript(this.activeTab.id, options.frameId || 0);
  307. const data = await browser.tabs.sendMessage(
  308. this.activeTab.id,
  309. { isBlock: true, ...payload },
  310. options
  311. );
  312. return data;
  313. } catch (error) {
  314. if (error.message?.startsWith('Could not establish connection')) {
  315. error.message = 'Could not establish connection to the active tab';
  316. } else if (error.message?.startsWith('No tab')) {
  317. error.message = 'active-tab-removed';
  318. }
  319. throw error;
  320. }
  321. }
  322. }
  323. export default WorkflowEngine;