From fb86c9c96d75406cfc68f69a31a0809ccede4a84 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Sat, 9 Nov 2024 02:40:13 -0700 Subject: [PATCH] able to pull Products using Shopify GraphQL API --- product_inventory.code-workspace | 4 +- src/config.ts | 39 +- src/initMenu.ts | 15 + src/shopifyApi.ts | 732 +++++++++++++++++++++++++++++++ 4 files changed, 786 insertions(+), 4 deletions(-) create mode 100644 src/shopifyApi.ts diff --git a/product_inventory.code-workspace b/product_inventory.code-workspace index 1362718..bf7695b 100644 --- a/product_inventory.code-workspace +++ b/product_inventory.code-workspace @@ -5,6 +5,8 @@ } ], "settings": { - "prettier.semi": false + "prettier.semi": false, + "cloudcode.duetAI.project": "beepmill-code", + "cloudcode.project": "beepmill-code" } } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index f9108ca..e48a03f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,43 @@ -class Config { +export class Config { productPhotosFolderId: string + shopifyApiKey: string + shopifyApiSecretKey: string + shopifyAdminApiAccessToken: string + shopifyApiURI: string constructor() { let ss = SpreadsheetApp.getActive() let s = ss.getSheetByName("vars") - this.productPhotosFolderId = vlookupByColumns("vars", "key", "productPhotosFolderId", "value") + this.productPhotosFolderId = vlookupByColumns( + "vars", + "key", + "productPhotosFolderId", + "value" + ) + this.shopifyApiKey = vlookupByColumns( + "vars", + "key", + "shopifyApiKey", + "value" + ) + this.shopifyApiSecretKey = vlookupByColumns( + "vars", + "key", + "shopifyApiSecretKey", + "value" + ) + this.shopifyAdminApiAccessToken = vlookupByColumns( + "vars", + "key", + "shopifyAdminApiAccessToken", + "value" + ) + this.shopifyApiURI = vlookupByColumns( + "vars", + "key", + "shopifyApiURI", + "value" + ) } -} \ No newline at end of file +} diff --git a/src/initMenu.ts b/src/initMenu.ts index f584f1a..ae8bdcb 100644 --- a/src/initMenu.ts +++ b/src/initMenu.ts @@ -1,7 +1,22 @@ +import { Shop } from "./shopifyApi" + function initMenu() { let ui = SpreadsheetApp.getUi() ui.createMenu("BLM") .addItem("Fill out product from template", "fillProductFromTemplate") .addItem("Create missing photo folders", "createMissingPhotoFolders") + .addSeparator() + .addItem("Run Shopify Orders", "runShopifyOrders") + .addItem("Get Shopify Products", "getShopifyProducts") .addToUi() } + +function runShopifyOrders() { + let shop = new Shop() + shop.RunOrders() +} + +function getShopifyProducts() { + let shop = new Shop() + shop.GetProducts() +} diff --git a/src/shopifyApi.ts b/src/shopifyApi.ts new file mode 100644 index 0000000..6d5532a --- /dev/null +++ b/src/shopifyApi.ts @@ -0,0 +1,732 @@ +// EXTREMELY inspired by https://github.com/webdjoe/shopify_apps_script/blob/master/shopify_api.gs + +// Create a private App in Shopify admin and insert app password below +// Sheets need to be created before running with columns of desired output set +// Use keys found in Shopify Rest API Order Object as row headers +// https://shopify.dev/api/admin-rest/2022-04/resources/order#resource-object + +// Supports the following sheets/order objects with default columns - +// orders - id, number, order_name, name, current_total_discounts, current_total_price, current_subtotal_price, current_total_tax, financial_status, landing_site, created_at, processed_at, processing_method, subtotal_price, source_url, tags, token, total_discounts, total_line_items_price, total_price, total_tax, updated_at +// shipping_lines - order_id, ordered_at, code, price, title, source, discounted_price, carrier_identifier +// discount_applications - order_id, created_at, type, title, value, value_type, code, description, target_type, target_selection, allocation_method, ordered_at +// refunds = id, order_id, ordered_at, created_at, note, transaction_id, kind, amount +// line_items = id, order_id, sku, name, price, title, vendor, quantity, product_id, variant_id, variant_title, total_discount, fulfillment_status, ordered_at +// customer - order_id, ordered_at, id, email, phone, last_name, created_at, first_name, admin_graphql_api_id, updated_at, orders_count, last_order_id + +/// + +import { Config } from "./config" + +const ss = SpreadsheetApp.getActive() + +const today = new Date() +const thirty_days = new Date( + today.getFullYear(), + today.getMonth() - 1, + today.getDate() +) +const start_str = Utilities.formatDate( + thirty_days, + SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone(), + "yyyy-MM-dd" +) +const end_str = Utilities.formatDate( + today, + SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone(), + "yyyy-MM-dd" +) + +const orders = "_orders" +const line_items = "_line_items" +const customer = "_customer" +const refunds = "_refunds" +const shipping_lines = "_shipping_lines" +const discount_applications = "_discount_applications" + +function makeArray(headers, obj_list) { + var rng_arr = [] + for (let obj of obj_list) { + var line_arr = [] + headers.forEach((item) => { + var line_item = item in obj ? obj[item] : "" + if (Array.isArray(line_item)) { + line_item = line_item.toString() + } + line_arr.push(line_item) + }) + rng_arr.push(line_arr) + } + return rng_arr +} + +function makeDiscountsArray(orders_arr) { + var disc_arr = [] + var headers = getDiscountHeaders() + for (let ord of orders_arr) { + var disc_list = ord.discount_applications + if (disc_list.length < 1) { + continue + } + disc_list.forEach((disc) => { + var disc_line = [] + disc["order_id"] = ord["id"] + disc["ordered_at"] = ord["created_at"] + headers.forEach((head) => { + var itm = head in disc ? disc[head] : "" + disc_line.push(itm) + }) + disc_arr.push(disc_line) + }) + } + return disc_arr +} + +function makeCustomerArray(orders_arr) { + var cust_arr = [] + var headers = getCustomerHeaders() + for (let ord of orders_arr) { + var cust_obj = ord.customer + if (typeof cust_obj == "undefined") { + continue + } + var customer_line = [] + cust_obj["order_id"] = ord["id"] + cust_obj["ordered_at"] = ord["created_at"] + headers.forEach((head) => { + var itm = head in cust_obj ? cust_obj[head] : "" + customer_line.push(itm) + }) + cust_arr.push(customer_line) + } + return cust_arr +} + +function makeLIArray(orders_arr) { + var line_arr = [] + var headers = getLineItemHeaders() + for (let ord of orders_arr) { + var line_items_list = ord.line_items + if (line_items_list.length < 1) { + continue + } + line_items_list.forEach((li_obj) => { + var li_line = [] + li_obj["ordered_at"] = ord["created_at"] + li_obj["order_id"] = ord["id"] + headers.forEach((head) => { + var itm = head in li_obj ? li_obj[head] : "" + li_line.push(itm) + }) + line_arr.push(li_line) + }) + } + return line_arr +} + +function makeShippingArray(orders_arr) { + var ship_arr = [] + var header = getShippingHeaders() + for (let ord of orders_arr) { + var ship_list = ord.shipping_lines + if (ship_list.length < 1) { + continue + } + + ship_list.forEach((ship_obj) => { + var line = [] + + ship_obj["ordered_at"] = ord["created_at"] + ship_obj["order_id"] = ord["id"] + + header.forEach((a) => { + var line_itm = a in ship_obj ? ship_obj[a] : "" + line.push(line_itm) + }) + ship_arr.push(line) + }) + } + return ship_arr +} + +function makeRefundsArray(orders_arr) { + var refunds_arr = [] + var header = getRefundHeaders() + for (let ord of orders_arr) { + var ref_list = ord.refunds + if (ref_list.length < 1) { + continue + } + + ref_list.forEach((ref_obj) => { + var line = [] + + ref_obj["ordered_at"] = ord["created_at"] + if (ref_obj["transactions"].length == 1) { + var trans = ref_obj["transactions"][0] + ref_obj["transaction_id"] = trans["id"] + ref_obj["amount"] = trans["amount"] + ref_obj["kind"] = trans["kind"] + } else { + return + } + header.forEach((a) => { + var line_itm = a in ref_obj ? ref_obj[a] : "" + line.push(line_itm) + }) + refunds_arr.push(line) + }) + } + return refunds_arr +} + +function appendRows(sht_name, arr) { + var sht = ss.getSheetByName(sht_name) + let last_row = sht.getLastRow() + sht.getRange(last_row + 1, 1, arr.length, arr[0].length).setValues(arr) +} + +function processDataRange(sht_name) { + var sh = ss.getSheetByName(sht_name) + var data_rng = sh.getDataRange().offset(1, 0, sh.getLastRow() - 1) + var data = data_rng.getValues() + + var targetData = new Array() + for (let n = 0; n < data.length; ++n) { + if (data[n].join().replace(/,/g, "") != "") { + targetData.push(data[n]) + } + } + data_rng.clear() + targetData.sort((a, b) => a[1] - b[1]) + sh.getRange(2, 1, targetData.length, targetData[0].length).setValues( + targetData + ) +} + +function removeDuplicates(obj_list, sht_name, id = "id") { + var sht = ss.getSheetByName(sht_name) + var sht_arr = sht.getDataRange().getValues() + for (let a = 0; a < sht_arr[0].length; a++) { + if (sht_arr[0][a] == id) { + var id_col = a + break + } + } + for (let x = 0; x < sht_arr.length; x++) { + var row_id = sht_arr[x][id_col] + for (let y = 0; y < obj_list.length; y++) { + if (obj_list[y][id_col] == row_id) { + sht.getRange(x + 1, 1, 1, sht_arr[0].length).clearContent() + break + } + } + } +} + +function removeAllDuplicates(obj_list, sht_name, id = "id") { + var sht = ss.getSheetByName(sht_name) + var sht_arr = sht.getDataRange().getValues() + for (let a = 0; a < sht_arr[0].length; a++) { + if (sht_arr[0][a] == id) { + var id_col = a + break + } + } + for (let x = 0; x < sht_arr.length; x++) { + var row_id = sht_arr[x][id_col] + for (let y = 0; y < obj_list.length; y++) { + if (obj_list[y][id_col] == row_id) { + sht.getRange(x + 1, 1, 1, sht_arr[0].length).clearContent() + } + } + } +} + +function getColumnList() { + var order_list = getOrderHeaders() + order_list.push( + line_items, + customer, + refunds, + discount_applications, + shipping_lines + ) + return order_list +} + +function getColumns(data: string) { + var sht = ss.getSheetByName(data) + if (sht == null) { + ss.insertSheet(data) + sht = ss.getSheetByName(data) + } + if (sht == null) { + throw new Error("unable to get/create sheet " + data) + } + var last_col = sht.getLastColumn() + var header_list = sht.getRange(1, 1, 1, last_col).getValues() + return header_list[0] +} + +function getShippingHeaders() { + return getColumns(shipping_lines) +} + +function getCustomerHeaders() { + return getColumns(customer) +} + +function getLineItemHeaders() { + return getColumns(line_items) +} + +function getOrderHeaders() { + return Order.columns +} + +function getDiscountHeaders() { + return getColumns(discount_applications) +} + +function getRefundHeaders() { + return getColumns(refunds) +} +function customerTable(orders_arr) { + var rng_arr = makeCustomerArray(orders_arr) + removeDuplicates(rng_arr, customer, "order_id") + SpreadsheetApp.flush() + appendRows(customer, rng_arr) + SpreadsheetApp.flush() + processDataRange(customer) +} + +function discountsTable(orders_arr) { + var rng_arr = makeDiscountsArray(orders_arr) + if (rng_arr.length > 0) { + removeAllDuplicates(rng_arr, discount_applications, "order_id") + SpreadsheetApp.flush() + appendRows(discount_applications, rng_arr) + SpreadsheetApp.flush() + processDataRange(discount_applications) + } +} + +function ordersTable(orders_arr) { + var rng_arr = makeArray(getOrderHeaders(), orders_arr) + removeDuplicates(rng_arr, orders, "id") + SpreadsheetApp.flush() + appendRows(orders, rng_arr) + SpreadsheetApp.flush() + processDataRange(orders) +} + +function refundsTable(orders_arr) { + var rng_arr = makeRefundsArray(orders_arr) + if (rng_arr.length > 0) { + removeDuplicates(rng_arr, refunds, "id") + SpreadsheetApp.flush() + appendRows(refunds, rng_arr) + SpreadsheetApp.flush() + processDataRange(refunds) + } +} + +function lineItemsTable(orders_arr) { + var rng_arr = makeLIArray(orders_arr) + removeDuplicates(rng_arr, line_items, "id") + SpreadsheetApp.flush() + appendRows(line_items, rng_arr) + SpreadsheetApp.flush() + processDataRange(line_items) +} + +function shippingTable(orders_arr) { + var rng_arr = makeShippingArray(orders_arr) + if (rng_arr.length > 0) { + removeDuplicates(rng_arr, shipping_lines, "order_id") + SpreadsheetApp.flush() + appendRows(shipping_lines, rng_arr) + SpreadsheetApp.flush() + processDataRange(shipping_lines) + } +} + +function unquote(value) { + if (value.charAt(0) == '"' && value.charAt(value.length - 1) == '"') + return value.substring(1, value.length - 1) + return value +} + +function parseLinkHeader(header) { + var linkexp = + /<[^>]*>\s*(\s*;\s*[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*")))*(,|$)/g + var paramexp = + /[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*"))/g + + var matches = header.match(linkexp) + var rels = {} + for (let i = 0; i < matches.length; i++) { + var split = matches[i].split(">") + var href = split[0].substring(1) + var ps = split[1] + var link = { + href: "", + rel: "", + } + link.href = href + var s = ps.match(paramexp) + for (let j = 0; j < s.length; j++) { + var p = s[j] + var paramsplit = p.split("=") + var name = paramsplit[0] + link[name] = unquote(paramsplit[1]) + } + + if (link.rel !== undefined) { + rels[link.rel] = link + } + } + + return rels +} + +export class Shop { + private shopifyApiKey: string + private shopifyApiSecretKey: string + private shopifyAdminApiAccessToken: string + private shopifyApiURI: string + static endpoints = { + orders: "/admin/api/2022-04/orders.json", + graphql: "/admin/api/2024-10/graphql.json", + } + + constructor() { + let config = new Config() + this.shopifyApiKey = config.shopifyApiKey + this.shopifyApiSecretKey = config.shopifyApiSecretKey + this.shopifyAdminApiAccessToken = config.shopifyAdminApiAccessToken + this.shopifyApiURI = config.shopifyApiURI + } + + RunOrders(start = "", end = "") { + if (start === "" || end === "") { + start = start_str + end = end_str + } + const date_exp = /^\d{4}[\-](0?[1-9]|1[012])[\-](0?[1-9]|[12][0-9]|3[01])$/ + if (!date_exp.test(start) || !date_exp.test(end)) { + Logger.log("Dates must be in yyyy-MM-dd format") + return + } + this.GetOrders(start, end) + } + + GetOrders(start: string, end: string) { + let endpoint = Shop.endpoints.orders + var params = { + created_at_min: start, + created_at_max: end, + fields: getColumnList(), + } + + var done = false + var next_link = "" + do { + if (next_link === "") { + var response = this.shopifyAPI(endpoint, params) + console.log(response) + } else { + var response = this.shopifyAPI(endpoint, params, next_link) + console.log(response) + } + let resp = response.content + let headers = response.headers + var orders_arr: Order[] = resp["orders"] + if (orders_arr.length < 1) { + console.log("No orders found") + done = true + continue + } + var cols = getColumnList() + var numColumns = cols.length + var respKeys = Object.keys(orders_arr[0]) + var respCols = respKeys.length + if (cols.filter((x) => respKeys.indexOf(x) === -1).length !== 0) { + Logger.log( + "Keys missing from return - " + + cols.filter((x) => respKeys.indexOf(x) === -1) + ) + return + } + ordersTable(orders_arr) + refundsTable(orders_arr) + lineItemsTable(orders_arr) + customerTable(orders_arr) + discountsTable(orders_arr) + shippingTable(orders_arr) + + if (headers["Link"] !== undefined) { + var links = parseLinkHeader(headers["Link"]) + Logger.log(headers["Link"]) + if (links["next"] === undefined) { + done = true + } else { + next_link = links["next"]["href"] + Logger.log(next_link) + } + } else { + done = true + } + } while (!done) + } + + GetProducts() { + let done = false + let cursor = "" + let fields = ["id", "title"] + var response = { + content: {}, + headers: {}, + } + let products: Product[] = [] + do { + let query = new ProductsQuery(fields, cursor) + response = this.shopifyGraphQLAPI(query.JSON) + console.log(response) + let productsResponse = new ProductsResponse(response.content) + if (productsResponse.products.edges.length <= 0) { + console.log("no products returned") + done = true + continue + } + for (let i = 0; i < productsResponse.products.edges.length; i++) { + let edge = productsResponse.products.edges[i] + console.log(JSON.stringify(edge)) + let p = new Product() + Object.assign(edge.node, p) + products.push(p) + } + if (productsResponse.products.pageInfo.hasNextPage) { + cursor = productsResponse.products.pageInfo.endCursor + } else { + done = true + } + } while (!done) + } + + shopifyAPI(endpoint: string, query: {}, next = "") { + var options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = { + method: "get", + headers: { + "X-Shopify-Access-Token": this.shopifyAdminApiAccessToken, + }, + } + if (next == "") { + var url = this.buildURL(endpoint, query) + var resp = UrlFetchApp.fetch(url, options) + console.log(resp.getContentText()) + } else { + var url = next + var resp = UrlFetchApp.fetch(url, options) + console.log(resp.getContentText()) + } + + return { + content: JSON.parse(resp.getContentText()), + headers: resp.getHeaders(), + } + } + + shopifyGraphQLAPI(query: {}, next = "") { + let endpoint = Shop.endpoints.graphql + let options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = { + method: "post", + payload: JSON.stringify(query), + headers: { + "Content-Type": "application/json", + "X-Shopify-Access-Token": this.shopifyAdminApiAccessToken, + }, + muteHttpExceptions: true, + } + var url = this.buildURL(endpoint) + console.log(UrlFetchApp.getRequest(url, options)) + var resp = UrlFetchApp.fetch(url, options) + console.log(resp.getContentText()) + + return { + content: JSON.parse(resp.getContentText()), + headers: resp.getHeaders(), + } + } + + buildURL(endpoint: string, params = {}) { + var url = this.shopifyApiURI + endpoint + if (params !== undefined && Object.keys(params).length > 0) { + let i = 0 + for (let p in params) { + if (i === 0) { + url += "?" + p + "=" + encodeURIComponent(params[p]) + } else { + url += "&" + p + "=" + encodeURIComponent(params[p]) + } + i++ + } + } + return url + } +} + +export class Order { + static columns = [ + "id", + "number", + "order_name", + "name", + "current_total_discounts", + "current_total_price", + "current_subtotal_price", + "current_total_tax", + "financial_status", + "landing_site", + "created_at", + "processed_at", + "processing_method", + "subtotal_price", + "source_url", + "tags", + "token", + "total_discounts", + "total_line_items_price", + "total_price", + "total_tax", + "updated_at", + ] + + constructor() { + for (let i = 0; i < Order.columns.length; i++) { + this[Order.columns[i]] = "" + } + } +} + +export class Product { + body_html: string + created_at: Date + handle: string + id: number + images: ProductImage[] + options: ProductOption[] + product_type: string + published_at: Date + published_scope: string + status: string + tags: string[] + template_suffix: string + title: string + updated_at: Date + variants: ProductVariant[] + vendor: string +} + +class ProductImage { + id: number + product_id: number + position: number + created_at: Date + updated_at: Date + width: number + height: number + src: string + variant_ids: number[] +} + +class ProductOption { + id: number + product_id: number + name: string + position: number + values: string[] +} + +class ProductVariant { + barcode: string + compare_at_price: number + created_at: Date + fulfillment_service: string + grams: number + weight: number + weight_unit: string + id: number + inventory_item_id: number + inventory_management: string + inventory_policy: string + inventory_quantity: number + option1: string + position: number + price: number + product_id: number + requires_shipping: boolean + sku: string + taxable: boolean + title: string + updated_at: Date +} + +class Products { + edges: ProductEdge[] + pageInfo: PageInfo +} + +class ProductEdge { + node: Product + cursor: string +} + +class PageInfo { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor: string + endCursor: string +} + +class ProductsQuery { + GQL: string + JSON: JSON + constructor( + fields: string[] = ["id", "title", "handle"], + cursor: string = "" + ) { + let cursorText: string + if (cursor == "") { + cursorText = "" + } else { + cursorText = `, after: "${cursor}"` + } + this.GQL = `{ + products(first: 10${cursorText}) { + edges { + node { ${fields.join(" ")} } + } + pageInfo { + hasNextPage + endCursor + } + } +}` + let j = `{"query": ${formatGqlForJSON(this.GQL)}}` + console.log(j) + this.JSON = JSON.parse(j) + } +} + +class ProductsResponse { + products: Products + constructor(response: {}) { + this.products = response["data"]["products"] + } +} + +function formatGqlForJSON(gql: string) { + let singleLine = gql.split("\n").join(" ").replace(/\s+/g, " ") + return JSON.stringify(singleLine) +} \ No newline at end of file