diff --git a/components/timetonic/actions/common/create-update-row.mjs b/components/timetonic/actions/common/create-update-row.mjs new file mode 100644 index 0000000000000..de684d75e77f0 --- /dev/null +++ b/components/timetonic/actions/common/create-update-row.mjs @@ -0,0 +1,178 @@ +import timetonic from "../../timetonic.app.mjs"; +import constants from "../../common/constants.mjs"; +import fs from "fs"; +import FormData from "form-data"; + +export default { + props: { + timetonic, + bookCode: { + propDefinition: [ + timetonic, + "bookCode", + ], + }, + tableId: { + propDefinition: [ + timetonic, + "tableId", + (c) => ({ + bookCode: c.bookCode, + }), + ], + reloadProps: true, + }, + }, + async additionalProps() { + const props = {}; + if (!this.tableId || !this.bookCode) { + return props; + } + const { bookTables: { categories } } = await this.timetonic.listTables({ + params: { + b_c: this.bookCode, + includeFields: true, + }, + }); + const { fields } = categories.find(({ id }) => id === this.tableId); + for (const field of fields) { + if (!field?.readOnly) { + const id = `${field.id}`; + props[id] = { + type: constants.FIELD_TYPES[field.type] || "string", + label: field.name, + optional: this.isUpdate() + ? true + : !field?.required, + }; + if (field.type === "link") { + const linkTableId = field.link.category.id; + const { tableRows } = await this.timetonic.listRows({ + params: { + catId: linkTableId, + }, + }); + const options = tableRows?.map(({ + id, name: label, + }) => ({ + value: `${id}`, + label, + })) || []; + props[id].options = options; + props[id].description = "The Row ID from the linked table to create a link to"; + tableRows.forEach(({ + id: rowId, name, + }) => { + props[`${id}_${rowId}_link_text`] = { + type: "string", + default: name, + hidden: true, + }; + }); + } + if (field.type === "file" || field.type === "files") { + props[id].description = "The path to the file saved to the `/tmp` directory (e.g. `/tmp/example.pdf`). [See the documentation](https://pipedream.com/docs/workflows/steps/code/nodejs/working-with-files/#the-tmp-directory)."; + props[`${id}_is_file`] = { + type: "boolean", + default: true, + hidden: true, + }; + } + } + } + return props; + }, + methods: { + isUpdate() { + return false; + }, + uploadFile($, fieldId, filePath, rowId) { + const fileStream = fs.createReadStream(filePath.includes("/tmp") + ? filePath + : `/tmp/${filePath}`); + const formData = new FormData(); + formData.append("qqfile", fileStream); + return this.timetonic.uploadFile({ + $, + params: { + b_c: this.bookCode, + fieldId, + rowId, + }, + data: formData, + headers: formData.getHeaders(), + }); + }, + }, + async run({ $ }) { + const { + timetonic, + // eslint-disable-next-line no-unused-vars + isUpdate, + uploadFile, + // eslint-disable-next-line no-unused-vars + bookCode, + tableId, + rowId = `tmp${Math.random().toString(36) + .substr(2, 9)}`, + ...fields + } = this; + + const fieldValues = {}; + const files = []; + for (const [ + key, + value, + ] of Object.entries(fields)) { + if (key.includes("link_text") || key.includes("is_file")) { + continue; + } + if (fields[`${key}_is_file`]) { + files.push({ + fieldId: key, + filePath: value, + }); + continue; + } + fieldValues[+key] = fields[`${key}_${value}_link_text`] + ? [ + { + row_id: +value, + value: fields[`${key}_${value}_link_text`], + }, + ] + : value; + } + // if fieldValues is empty, createOrUpdateRow will create a new row + // if updating, get and return the row instead + const response = isUpdate() && !Object.entries(fieldValues).length + ? await timetonic.getTableValues({ + $, + params: { + b_c: bookCode, + catId: tableId, + filterRowIds: { + row_ids: [ + rowId, + ], + }, + }, + }) + : await timetonic.createOrUpdateRow({ + $, + params: { + rowId, + catId: tableId, + fieldValues, + }, + }); + const newRowId = this.rowId || response.rows[0].id; + for (const file of files) { + await uploadFile($, file.fieldId, file.filePath, newRowId); + } + $.export("$summary", `Successfully ${isUpdate() + ? "updated" + : "created"} row in table ${tableId}`); + return response; + }, +}; diff --git a/components/timetonic/actions/create-row/create-row.mjs b/components/timetonic/actions/create-row/create-row.mjs new file mode 100644 index 0000000000000..ff71499913bcf --- /dev/null +++ b/components/timetonic/actions/create-row/create-row.mjs @@ -0,0 +1,10 @@ +import common from "../common/create-update-row.mjs"; + +export default { + ...common, + key: "timetonic-create-row", + name: "Create Row", + description: "Create a new row within an existing table in TimeTonic. [See the documentation](https://timetonic.com/live/apidoc/#api-Smart_table_operations-createOrUpdateTableRow)", + version: "0.0.1", + type: "action", +}; diff --git a/components/timetonic/actions/delete-row/delete-row.mjs b/components/timetonic/actions/delete-row/delete-row.mjs new file mode 100644 index 0000000000000..e8cf3c0b460e5 --- /dev/null +++ b/components/timetonic/actions/delete-row/delete-row.mjs @@ -0,0 +1,46 @@ +import timetonic from "../../timetonic.app.mjs"; + +export default { + key: "timetonic-delete-row", + name: "Delete Row", + description: "Deletes a row within an existing table in TimeTonic. [See the documentation](https://timetonic.com/live/apidoc/#api-Smart_table_operations-deleteTableRow)", + version: "0.0.1", + type: "action", + props: { + timetonic, + bookCode: { + propDefinition: [ + timetonic, + "bookCode", + ], + }, + tableId: { + propDefinition: [ + timetonic, + "tableId", + (c) => ({ + bookCode: c.bookCode, + }), + ], + }, + rowId: { + propDefinition: [ + timetonic, + "rowId", + (c) => ({ + tableId: c.tableId, + }), + ], + }, + }, + async run({ $ }) { + const response = await this.timetonic.deleteRow({ + $, + params: { + rowId: this.rowId, + }, + }); + $.export("$summary", `Successfully deleted row with ID ${this.rowId}`); + return response; + }, +}; diff --git a/components/timetonic/actions/search-rows/search-rows.mjs b/components/timetonic/actions/search-rows/search-rows.mjs new file mode 100644 index 0000000000000..ff5f336cb66d7 --- /dev/null +++ b/components/timetonic/actions/search-rows/search-rows.mjs @@ -0,0 +1,100 @@ +import timetonic from "../../timetonic.app.mjs"; +import constants from "../../common/constants.mjs"; + +export default { + key: "timetonic-search-rows", + name: "Search Rows", + description: "Perform a search across table rows based on given criteria. [See the documentation](https://timetonic.com/live/apidoc/#api-Smart_table_operations-listTableRowsById)", + version: "0.0.1", + type: "action", + props: { + timetonic, + bookCode: { + propDefinition: [ + timetonic, + "bookCode", + ], + }, + tableId: { + propDefinition: [ + timetonic, + "tableId", + (c) => ({ + bookCode: c.bookCode, + }), + ], + }, + searchField: { + propDefinition: [ + timetonic, + "fieldId", + (c) => ({ + bookCode: c.bookCode, + tableId: c.tableId, + }), + ], + reloadProps: true, + }, + }, + async additionalProps() { + const props = {}; + if (!this.searchField || !this.bookCode || !this.tableId) { + return props; + } + const { tableValues: { fields } } = await this.timetonic.getTableValues({ + params: { + catId: this.tableId, + b_c: this.bookCode, + }, + }); + const field = fields.find(({ id }) => id === this.searchField); + props.searchValue = { + type: constants.FIELD_TYPES[field.type] || "string", + label: "Search Value", + description: "The value to search for", + }; + if (field.type === "link") { + props.searchValue.description = `Please enter the Row ID from ${field.link.category.name} to link to`; + } + return props; + }, + methods: { + findMatches(fields) { + const field = fields.find(({ id }) => id === this.searchField); + let matches; + if (field.type === "link") { + matches = field.values.filter(({ value }) => + value?.length && value.find((link) => link.row_id == this.searchValue)); + } else { + matches = field.values.filter(({ value }) => value == this.searchValue); + } + return matches.map(({ id }) => id); + }, + buildRow(fields, matches) { + const rows = []; + matches.forEach((match) => { + const row = {}; + fields.forEach((field) => { + row[field.name] = field.values.find(({ id }) => id === match).value; + }); + rows.push(row); + }); + return rows; + }, + }, + async run({ $ }) { + const { tableValues: { fields } } = await this.timetonic.getTableValues({ + $, + params: { + catId: this.tableId, + b_c: this.bookCode, + }, + }); + const matches = this.findMatches(fields); + const rows = this.buildRow(fields, matches); + $.export("$summary", `Found ${rows.length} matching row${rows.length === 1 + ? "" + : "s"}`); + return rows; + }, +}; diff --git a/components/timetonic/actions/update-row/update-row.mjs b/components/timetonic/actions/update-row/update-row.mjs new file mode 100644 index 0000000000000..ec742ab095e29 --- /dev/null +++ b/components/timetonic/actions/update-row/update-row.mjs @@ -0,0 +1,28 @@ +import common from "../common/create-update-row.mjs"; + +export default { + ...common, + key: "timetonic-update-row", + name: "Update Row", + description: "Updates the values within a specified row in a table. [See the documentation](https://timetonic.com/live/apidoc/#api-Smart_table_operations-createOrUpdateTableRow)", + version: "0.0.1", + type: "action", + props: { + ...common.props, + rowId: { + propDefinition: [ + common.props.timetonic, + "rowId", + (c) => ({ + tableId: c.tableId, + }), + ], + }, + }, + methods: { + ...common.methods, + isUpdate() { + return true; + }, + }, +}; diff --git a/components/timetonic/common/constants.mjs b/components/timetonic/common/constants.mjs new file mode 100644 index 0000000000000..e100733dfe6be --- /dev/null +++ b/components/timetonic/common/constants.mjs @@ -0,0 +1,17 @@ +const DEFAULT_LIMIT = 100; + +const FIELD_TYPES = { + shorttext: "string", + mediumtext: "string", + email: "string", + phone: "string", + date: "string", + link: "string", + boolean: "boolean", + int: "integer", +}; + +export default { + DEFAULT_LIMIT, + FIELD_TYPES, +}; diff --git a/components/timetonic/package.json b/components/timetonic/package.json index 2ef86a0c1ca44..7ea1d96f6b3d8 100644 --- a/components/timetonic/package.json +++ b/components/timetonic/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/timetonic", - "version": "0.0.1", + "version": "0.1.0", "description": "Pipedream TimeTonic Components", "main": "timetonic.app.mjs", "keywords": [ @@ -11,5 +11,10 @@ "author": "Pipedream (https://pipedream.com/)", "publishConfig": { "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^1.6.5", + "form-data": "^4.0.0", + "md5": "^2.3.0" } -} \ No newline at end of file +} diff --git a/components/timetonic/sources/common/base.mjs b/components/timetonic/sources/common/base.mjs new file mode 100644 index 0000000000000..c784c3b689a19 --- /dev/null +++ b/components/timetonic/sources/common/base.mjs @@ -0,0 +1,67 @@ +import timetonic from "../../timetonic.app.mjs"; +import { DEFAULT_POLLING_SOURCE_TIMER_INTERVAL } from "@pipedream/platform"; + +export default { + props: { + timetonic, + db: "$.service.db", + timer: { + type: "$.interface.timer", + default: { + intervalSeconds: DEFAULT_POLLING_SOURCE_TIMER_INTERVAL, + }, + }, + bookCode: { + propDefinition: [ + timetonic, + "bookCode", + ], + }, + tableId: { + propDefinition: [ + timetonic, + "tableId", + (c) => ({ + bookCode: c.bookCode, + }), + ], + }, + }, + methods: { + formatFields(rows) { + rows.forEach((row) => { + const fields = {}; + for (const [ + key, + value, + ] of Object.entries(row.fields)) { + fields[key] = value.value; + } + row.fields = fields; + }); + return rows; + }, + }, + async run() { + const params = { + catId: this.tableId, + b_c: this.bookCode, + format: "rows", + }; + if (this.viewId) { + params.filterRowIds = { + applyViewFilters: this.viewId, + }; + } + const results = this.timetonic.paginate({ + resourceFn: this.timetonic.getTableValues, + params, + }); + const rows = []; + for await (const row of results) { + rows.push(row); + } + const formattedRows = this.formatFields(rows); + await this.processRows(formattedRows); + }, +}; diff --git a/components/timetonic/sources/new-table-row-in-view/new-table-row-in-view.mjs b/components/timetonic/sources/new-table-row-in-view/new-table-row-in-view.mjs new file mode 100644 index 0000000000000..c1404b26de242 --- /dev/null +++ b/components/timetonic/sources/new-table-row-in-view/new-table-row-in-view.mjs @@ -0,0 +1,40 @@ +import common from "../common/base.mjs"; + +export default { + ...common, + key: "timetonic-new-table-row-in-view", + name: "New Table Row in View", + description: "Emit new event when a new table row appears in a specific view.", + version: "0.0.1", + type: "source", + dedupe: "unique", + props: { + ...common.props, + viewId: { + propDefinition: [ + common.props.timetonic, + "viewId", + (c) => ({ + bookCode: c.bookCode, + tableId: c.tableId, + }), + ], + }, + }, + methods: { + ...common.methods, + generateMeta(row) { + return { + id: row.id, + summary: `New Row in View with ID: ${row.id}`, + ts: Date.now(), + }; + }, + processRows(rows) { + for (const row of rows) { + const meta = this.generateMeta(row); + this.$emit(row.fields, meta); + } + }, + }, +}; diff --git a/components/timetonic/sources/new-table-row/new-table-row.mjs b/components/timetonic/sources/new-table-row/new-table-row.mjs new file mode 100644 index 0000000000000..a48489d659514 --- /dev/null +++ b/components/timetonic/sources/new-table-row/new-table-row.mjs @@ -0,0 +1,27 @@ +import common from "../common/base.mjs"; + +export default { + ...common, + key: "timetonic-new-table-row", + name: "New Table Row", + description: "Emit new event when a new table row is added in TimeTonic", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + generateMeta(row) { + return { + id: row.id, + summary: `New Row with ID: ${row.id}`, + ts: Date.now(), + }; + }, + processRows(rows) { + for (const row of rows) { + const meta = this.generateMeta(row); + this.$emit(row.fields, meta); + } + }, + }, +}; diff --git a/components/timetonic/sources/row-deleted/row-deleted.mjs b/components/timetonic/sources/row-deleted/row-deleted.mjs new file mode 100644 index 0000000000000..17a5ebca7ac4e --- /dev/null +++ b/components/timetonic/sources/row-deleted/row-deleted.mjs @@ -0,0 +1,41 @@ +import common from "../common/base.mjs"; + +export default { + ...common, + key: "timetonic-row-deleted", + name: "Row Deleted", + description: "Emit new event when a row is deleted in a table.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + _getRowIds() { + return this.db.get("rowIds") || []; + }, + _setRowIds(rowIds) { + this.db.set("rowIds", rowIds); + }, + generateMeta(row) { + return { + id: row.id, + summary: `Row with ID: ${row.id} Deleted`, + ts: Date.now(), + }; + }, + processRows(rows) { + const previousRowIds = this._getRowIds(); + const currentRowIds = rows.map(({ id }) => id); + for (const id of previousRowIds) { + if (!currentRowIds.includes(id)) { + const row = { + id, + }; + const meta = this.generateMeta(row); + this.$emit(row, meta); + } + } + this._setRowIds(currentRowIds); + }, + }, +}; diff --git a/components/timetonic/sources/row-updated/row-updated.mjs b/components/timetonic/sources/row-updated/row-updated.mjs new file mode 100644 index 0000000000000..70e5d0c44c7c6 --- /dev/null +++ b/components/timetonic/sources/row-updated/row-updated.mjs @@ -0,0 +1,41 @@ +import common from "../common/base.mjs"; +import md5 from "md5"; + +export default { + ...common, + key: "timetonic-row-updated", + name: "Row Updated", + description: "Emit new event when a row is updated in a table.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + _getPreviousRows() { + return this.db.get("previousRows") || {}; + }, + _setPreviousRows(previousRows) { + this.db.set("previousRows", previousRows); + }, + generateMeta(row) { + const ts = Date.now(); + return { + id: `${row.id}${ts}`, + summary: `Row Updated with ID: ${row.id}`, + ts, + }; + }, + processRows(rows) { + const previousRows = this._getPreviousRows(); + for (const row of rows) { + const hash = md5(JSON.stringify(row.fields)); + if (previousRows[row.id] !== hash) { + const meta = this.generateMeta(row); + this.$emit(row.fields, meta); + previousRows[row.id] = hash; + } + } + this._setPreviousRows(previousRows); + }, + }, +}; diff --git a/components/timetonic/timetonic.app.mjs b/components/timetonic/timetonic.app.mjs index 1e8dd11f3a21a..520ad00efab77 100644 --- a/components/timetonic/timetonic.app.mjs +++ b/components/timetonic/timetonic.app.mjs @@ -1,11 +1,191 @@ +import { axios } from "@pipedream/platform"; +import constants from "./common/constants.mjs"; + export default { type: "app", app: "timetonic", - propDefinitions: {}, + propDefinitions: { + bookCode: { + type: "string", + label: "Book Code", + description: "The book code of the book", + async options() { + const { allBooks: { books } } = await this.listBooks(); + return books?.map(({ + b_c: value, ownerPrefs, + }) => ({ + value, + label: ownerPrefs?.title, + })) || []; + }, + }, + tableId: { + type: "string", + label: "Table ID", + description: "The ID of the table", + async options({ bookCode }) { + const { bookTables: { categories } } = await this.listTables({ + params: { + b_c: bookCode, + }, + }); + return categories?.map(({ + id: value, name: label, + }) => ({ + value, + label, + })) || []; + }, + }, + rowId: { + type: "string", + label: "Row ID", + description: "The ID of the row", + async options({ tableId }) { + const { tableRows } = await this.listRows({ + params: { + catId: tableId, + }, + }); + return tableRows?.map(({ + id: value, name: label, + }) => ({ + value, + label, + })) || []; + }, + }, + fieldId: { + type: "integer", + label: "Field ID", + description: "The ID of the field to search", + async options({ + bookCode, tableId, + }) { + const { bookTables: { categories } } = await this.listTables({ + params: { + b_c: bookCode, + includeFields: true, + }, + }); + const { fields } = categories.find(({ id }) => id === tableId); + return fields?.map(({ + id: value, name: label, + }) => ({ + value, + label, + })) || []; + }, + }, + viewId: { + type: "string", + label: "View ID", + description: "The ID of the view", + async options({ + bookCode, tableId, + }) { + const { tableValues: { views } } = await this.getTableValues({ + params: { + catId: tableId, + b_c: bookCode, + }, + }); + return views?.map(({ + id: value, name: label, + }) => ({ + value, + label, + })) || []; + }, + }, + }, methods: { - // this.$auth contains connected account data - authKeys() { - console.log(Object.keys(this.$auth)); + _baseUrl() { + return "https://timetonic.com/live/api.php"; + }, + _userId() { + return this.$auth.user_id; + }, + _makeRequest(opts = {}) { + const { + $ = this, req, params, ...otherOpts + } = opts; + return axios($, { + ...otherOpts, + method: "POST", + url: this._baseUrl(), + params: { + ...params, + req, + o_u: this._userId(), + u_c: this._userId(), + sesskey: this.$auth.api_key, + b_o: this._userId(), + }, + }); + }, + listBooks(opts = {}) { + return this._makeRequest({ + req: "getAllBooks", + ...opts, + }); + }, + listTables(opts = {}) { + return this._makeRequest({ + req: "getBookTables", + ...opts, + }); + }, + listRows(opts = {}) { + return this._makeRequest({ + req: "listTableRowsById", + ...opts, + }); + }, + getTableValues(opts = {}) { + return this._makeRequest({ + req: "getTableValues", + ...opts, + }); + }, + createOrUpdateRow(opts = {}) { + return this._makeRequest({ + req: "createOrUpdateTableRow", + ...opts, + }); + }, + deleteRow(opts = {}) { + return this._makeRequest({ + req: "deleteTableRow", + ...opts, + }); + }, + uploadFile(opts = {}) { + return this._makeRequest({ + req: "fileUpload", + ...opts, + }); + }, + async *paginate({ + resourceFn, + params, + }) { + params = { + ...params, + maxRows: constants.DEFAULT_LIMIT, + offset: 0, + }; + let total; + do { + const { tableValues: { rows } } = await resourceFn({ + params, + }); + for (const row of rows) { + yield row; + } + total = rows?.length; + params.offset += params.maxRows; + } while (total === params.maxRows); }, }, -}; \ No newline at end of file +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c8522b844106..2c8ca178b017f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8711,7 +8711,14 @@ importers: specifiers: {} components/timetonic: - specifiers: {} + specifiers: + '@pipedream/platform': ^1.6.5 + form-data: ^4.0.0 + md5: ^2.3.0 + dependencies: + '@pipedream/platform': 1.6.5 + form-data: 4.0.0 + md5: 2.3.0 components/timeular: specifiers: