diff --git a/docs/SKU logic migration plan.md b/docs/SKU logic migration plan.md
new file mode 100644
index 0000000..965f8b0
--- /dev/null
+++ b/docs/SKU logic migration plan.md
@@ -0,0 +1,67 @@
+# SKU logic migration plan
+
+2026-01-03
+
+## Summary
+
+The goal of this migration is to reduce the number of touchpoints required to create a new SKU. User should only have to define `product_type` and `product_style` once, and then a new SKU should be created automatically when needed.
+
+## High Level Migration Steps
+
+1. FREEZE CHANGES to the spreadsheet while this migration is in progress
+2. Remove `sku_prefix` column from `product_inventory` sheet. This will disable the existing automation by removing one of the needed inputs that is controlled by an instant ARRAYFORMULA.
+3. Update column names in `product_inventory` and `values` sheets to match new SKU logic
+4. Update `newSku.ts` to use new SKU logic
+5. Update `MediaManager.ts` to use new SKU logic
+
+## Detailed Migration Steps
+
+## `product_inventory` sheet
+
+* [x] Remove `sku_prefix` column
+* [x] Change `type` to `product_style`
+* [x] Move `product_style` column to the right of `product_type`
+* [x] Remove `function` column
+* [x] Remove `#` column
+* [x] Remove `style` column
+ * This column is not currently used in any active way, and is confusingly named. It should be removed.
+
+## `values` sheet
+
+* [x] Add `sku_prefix` column
+* [x] `type_sku_code` -> `sku_suffix` column
+* [x] Remove `function` and `function_sku_code` columns
+* [x] `type` -> `product_style`
+
+## `product_types` sheet
+
+* [x] Remove `function` column
+* [x] Change `type` to `product_style`
+
+## `Product` class
+
+* [x] Rename `type` -> `product_style` (to match the plan).
+* [x] Remove `function` property.
+* [x] Remove the existing `style: string[]` property (Line 24).
+
+## newSku.ts
+
+* [x] Move manual trigger to `sku` column
+* [ ] Add safety check to ensure that existing `sku` values are not overwritten. If the product already has a `sku` in Shopify, use it. Only check if `sku` is empty and `shopify_id` is defined.
+* [x] Start using `product_type` -> `sku_prefix` lookup + `product_style` -> `sku_suffix` lookup for SKU code
+
+## Media Manager
+
+* [ ] If `product_type` and `product_style` are defined, but `sku` is not, request a new SKU after confirming values are correct
+* [ ] If either `product_type` or `product_style` are undefined, prompt the user to define them, then request a new SKU
+
+## Cleanup
+
+* Scrub code for columns that have been removed
+ * [x] `function` column
+ * [x] `function_sku_code` column
+ * [x] `type_sku_code` column
+ * [x] `#` column
+ * [x] `style` column
+* [x] Scrub code for logic that has been removed
+* [x] Backfill
diff --git a/src/MediaManager.html b/src/MediaManager.html
index 8e920a2..cc056b4 100644
--- a/src/MediaManager.html
+++ b/src/MediaManager.html
@@ -723,6 +723,78 @@
+
+
+
🏷️
+
Generate SKU?
+
+ This product needs a SKU to proceed.
+ Type:
+ Style:
+
+
+
+
+
+
+
📝
+
missing Information
+
+ Please define Product Type and Style to generate a SKU.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -811,14 +883,18 @@
alert("Script Error: " + msg + "\nLine: " + line);
};
+ // Template Variables (Injected by Server)
+ var initialSku = "= initialSku ?>";
// Template Variables (Injected by Server)
var initialSku = "= initialSku ?>";
var initialTitle = "= initialTitle ?>";
+ var initialDescription = `= initialDescription ?>`;
// --- ES5 Refactor: MediaState ---
function MediaState() {
this.sku = (initialSku && initialSku !== "undefined") ? initialSku : null;
this.title = (initialTitle && initialTitle !== "undefined") ? initialTitle : "";
+ this.description = (initialDescription && initialDescription !== "undefined") ? initialDescription : "";
this.token = null;
this.items = [];
this.initialState = [];
@@ -829,6 +905,7 @@
MediaState.prototype.setSku = function (info) {
this.sku = info ? info.sku : null;
this.title = info ? info.title : "";
+ this.description = info && info.description ? info.description : "";
this.items = [];
this.initialState = [];
ui.updateSku(this.sku, this.title);
@@ -1023,9 +1100,13 @@
};
var state = new MediaState();
- state.sku = "!= initialSku ?>";
- state.title = "!= initialTitle ?>";
- window.state = state;
+ state.productType = "= initialProductType ?>";
+ state.productStyle = "= initialProductStyle ?>";
+ var state = new MediaState();
+ state.productType = "= initialProductType ?>";
+ state.productStyle = "= initialProductStyle ?>";
+ state.productOptions = null; // { types: [], styles: [] }
+ // description is handled in constructor
// --- ES5 Refactor: UI ---
function UI() {
@@ -1703,13 +1784,13 @@
return html;
}
- return '';
- };
+ return '';
+ };
- UI.prototype.showDetails = function () {
- var plan = state.calculateDiff();
- var container = document.getElementById('details-content');
- container.innerHTML = ui.renderPlanHtml(plan);
+ UI.prototype.showDetails = function () {
+ var plan = state.calculateDiff();
+ var container = document.getElementById('details-content');
+ container.innerHTML = ui.renderPlanHtml(plan);
document.getElementById('details-modal').style.display = 'flex';
};
@@ -1745,18 +1826,64 @@
google.script.run
.withSuccessHandler(response => {
- const { sku: serverSku, title: serverTitle, diagnostics, media, token } = response;
+
+ const { sku: serverSku, title: serverTitle, description: initialServerDescription, diagnostics, media, token, productOptions } = response;
+
+ if (productOptions) {
+ state.productOptions = productOptions;
+ }
if (!serverSku) {
- console.warn("No SKU found. Showing error.");
+ console.warn("No SKU found. Checking for prompt conditions.");
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'none';
- document.getElementById('error-ui').style.display = 'flex';
+
+ if (state.productType && state.productStyle) {
+ // Show Generate UI
+ document.getElementById('prompt-type').innerText = state.productType;
+ document.getElementById('prompt-style').innerText = state.productStyle;
+ document.getElementById('generate-sku-ui').style.display = 'flex';
+ } else {
+ // Show Missing Info UI with Form
+ document.getElementById('define-product-ui').style.display = 'flex';
+
+ // Populate Inputs
+ document.getElementById('input-product-title').value = state.title || "";
+ document.getElementById('input-product-desc').value = state.description || "";
+
+ // Populate Dropdowns
+ const typeSelect = document.getElementById('input-product-type');
+ const styleSelect = document.getElementById('input-product-style');
+
+ // Clear
+ typeSelect.innerHTML = '';
+ styleSelect.innerHTML = '';
+
+ if (state.productOptions) {
+ state.productOptions.types.forEach(t => {
+ const opt = document.createElement('option');
+ opt.value = t;
+ opt.innerText = t;
+ typeSelect.appendChild(opt);
+ });
+ state.productOptions.styles.forEach(s => {
+ const opt = document.createElement('option');
+ opt.value = s;
+ opt.innerText = s;
+ styleSelect.appendChild(opt);
+ });
+ }
+ }
+ document.getElementById('error-ui').style.display = 'none'; // Ensure error is hidden
return;
}
+ // Sanitize Description (remove literal quote artifacts if present)
+ let serverDescription = initialServerDescription;
+ if (serverDescription === "''" || serverDescription === "'") serverDescription = "";
+
// Update State
- state.setSku({ sku: serverSku, title: serverTitle });
+ state.setSku({ sku: serverSku, title: serverTitle, description: serverDescription });
state.token = token;
// Update UI
@@ -1854,6 +1981,84 @@
.getMediaManagerInitialState(sku, title);
},
+ generateSku() {
+ ui.setLoadingState(true);
+ // Hide prompts
+ document.getElementById('generate-sku-ui').style.display = 'none';
+ document.getElementById('loading-ui').style.display = 'block';
+
+ google.script.run
+ .withSuccessHandler(newSku => {
+ if (newSku) {
+ console.log("Generated SKU: " + newSku);
+ state.sku = newSku;
+ // Reload to get full state
+ this.loadMedia();
+ } else {
+ alert("Failed to generate SKU. Please check logs or try again.");
+ ui.setLoadingState(false);
+ document.getElementById('loading-ui').style.display = 'none';
+ // Show prompt again?
+ document.getElementById('generate-sku-ui').style.display = 'flex';
+ }
+ })
+ .withFailureHandler(e => {
+ alert("Error: " + e.message);
+ ui.setLoadingState(false);
+ document.getElementById('loading-ui').style.display = 'none';
+ document.getElementById('generate-sku-ui').style.display = 'flex';
+ })
+ .generateSkuForActiveRow();
+ },
+
+
+
+ saveProductDefinition() {
+
+ const titleVal = document.getElementById('input-product-title').value;
+ const descVal = document.getElementById('input-product-desc').value;
+ const typeSelect = document.getElementById('input-product-type');
+ const styleSelect = document.getElementById('input-product-style');
+ const typeVal = typeSelect.value;
+ const styleVal = styleSelect.value;
+
+ if (!typeVal || !styleVal) {
+ alert("Please select both a Product Type and a Style.");
+ return;
+ }
+
+ ui.setLoadingState(true);
+ document.getElementById('define-product-ui').style.display = 'none';
+ document.getElementById('loading-ui').style.display = 'block';
+
+ google.script.run
+ .withSuccessHandler(newSku => {
+ if (newSku) {
+ console.log("Product Defined & SKU Generated: " + newSku);
+ state.sku = newSku;
+ state.sku = newSku;
+ state.productType = typeVal;
+ state.productStyle = styleVal;
+ state.title = titleVal;
+ state.description = descVal;
+ // Reload
+ this.loadMedia();
+ } else {
+ // Failed to generate SKU (e.g. unsafe?)
+ alert("Product info saved, but SKU generation failed (maybe unsafe to overwrite?). Please check sheet.");
+ // Retry load to see state
+ this.loadMedia();
+ }
+ })
+ .withFailureHandler(e => {
+ alert("Error saving product info: " + e.message);
+ ui.setLoadingState(false);
+ document.getElementById('loading-ui').style.display = 'none';
+ document.getElementById('define-product-ui').style.display = 'flex';
+ })
+ .saveProductDefinition(typeVal, styleVal, titleVal, descVal);
+ },
+
linkSelectedMedia() {
var selectedItems = state.items.filter(function (i) { return state.selectedIds.has(i.id); });
var driveItem = selectedItems.find(function (i) { return i.source === 'drive_only'; });
diff --git a/src/Product.ts b/src/Product.ts
index 4928053..1f9d782 100644
--- a/src/Product.ts
+++ b/src/Product.ts
@@ -21,7 +21,6 @@ import { GASDriveService } from "./services/GASDriveService"
export class Product {
shopify_id: string = ""
title: string = ""
- style: string[] = []
tags: string = ""
category: string = ""
ebay_category_id: string = ""
@@ -31,8 +30,7 @@ export class Product {
price: number = 0
compare_at_price: number = 0
shipping: number = 0
- function: string = ""
- type: string = ""
+ product_style: string = ""
weight_grams: number = 0
product_width_cm: number = 0
product_depth_cm: number = 0
@@ -78,13 +76,14 @@ export class Product {
}
if (productValues[i] === "") {
console.log(
- "keeping '" + headers[i] + "' default: '" + this[headers[i]] + "'"
+ "keeping '" + headers[i] + "' default: '" + this[headers[i] as keyof Product] + "'"
)
continue
}
console.log(
"setting value for '" + headers[i] + "' to '" + productValues[i] + "'"
)
+ // @ts-ignore
this[headers[i]] = productValues[i]
} else {
console.log("skipping '" + headers[i] + "'")
diff --git a/src/backfill_sku.ts b/src/backfill_sku.ts
new file mode 100644
index 0000000..aa8f407
--- /dev/null
+++ b/src/backfill_sku.ts
@@ -0,0 +1,156 @@
+import {
+ getCellRangeByColumnName,
+ getCellValueByColumnName,
+ getColumnValuesByName,
+ getColumnByName,
+ vlookupByColumns,
+} from "./sheetUtils"
+import { Shop } from "./shopifyApi"
+import { Config } from "./config"
+
+export function backfillSkus() {
+ const sheet = SpreadsheetApp.getActive().getSheetByName("product_inventory")
+ if (!sheet) {
+ console.log("product_inventory sheet not found")
+ return
+ }
+
+ const shop = new Shop()
+
+ // Read all data
+ const productTypes = getColumnValuesByName(sheet, "product_type")
+ const productStyles = getColumnValuesByName(sheet, "product_style")
+ const ids = getColumnValuesByName(sheet, "#")
+ const skus = getColumnValuesByName(sheet, "sku")
+ const shopifyIds = getColumnValuesByName(sheet, "shopify_id")
+ const photoUrls = getColumnValuesByName(sheet, "photos") // Folder URLs
+
+ const missingCols = []
+ if (!productTypes) missingCols.push("product_type")
+ if (!productStyles) missingCols.push("product_style")
+ if (!skus) missingCols.push("sku")
+ if (!shopifyIds) missingCols.push("shopify_id")
+ if (!photoUrls) missingCols.push("photos")
+
+ if (missingCols.length > 0) {
+ console.log("Could not read necessary columns for backfill. Missing: " + missingCols.join(", "))
+ return
+ }
+
+ // 0. Pre-fetch all Shopify Products
+ console.log("Fetching all Shopify products...")
+ const allShopifyProducts = shop.GetProducts()
+
+ if (allShopifyProducts) {
+ console.log(`Fetched ${allShopifyProducts.length} raw products from Shopify.`)
+ if (allShopifyProducts.length > 0) {
+ console.log("Sample Product structure:", JSON.stringify(allShopifyProducts[0]))
+ }
+ } else {
+ console.log("GetProducts returned undefined/null")
+ }
+
+ const shopifySkuMap = new Map() // ID -> SKU
+
+ if (allShopifyProducts) {
+ for (const p of allShopifyProducts) {
+ let variants = p.variants
+ // @ts-ignore
+ if (!variants && p['variants']) variants = p['variants']
+
+ if (variants && variants.nodes && variants.nodes.length > 0) {
+ const v = variants.nodes[0]
+ const sku = v.sku || ""
+ const rawId = p.id
+
+ if (rawId) {
+ // Store raw ID
+ shopifySkuMap.set(rawId, sku)
+ // Store numeric ID (if it's a GID)
+ const numericId = rawId.split("/").pop()
+ if (numericId && numericId !== rawId) {
+ shopifySkuMap.set(numericId, sku)
+ }
+ }
+ }
+ }
+ }
+ console.log(`Mapped ${shopifySkuMap.size} IDs to SKUs.`)
+
+ // Get SKU Column Index ONCE
+ const skuColIndex = getColumnByName(sheet, "sku")
+ if (skuColIndex === -1) {
+ console.log("Column 'sku' not found in product_inventory")
+ return
+ }
+
+ for (let i = 0; i < productTypes.length; i++) {
+ const row = i + 2
+ const currentSku = String(skus[i])
+
+ // 1. Calculate Expected SKU
+ const pType = String(productTypes[i])
+ const pStyle = String(productStyles[i])
+ const id = ids ? String(ids[i]) : ""
+
+ let calculatedSku = ""
+ if (pType && pStyle && id && id !== '?' && id !== 'n') {
+ const prefix = vlookupByColumns("values", "product_type", pType, "sku_prefix")
+ const suffix = vlookupByColumns("values", "product_style", pStyle, "sku_suffix")
+ if (prefix && suffix) {
+ calculatedSku = `${prefix}${suffix}-${id.padStart(4, "0")}`
+ }
+ }
+
+ // 2. Get External SKUs
+ const shopifyId = String(shopifyIds[i])
+ let shopifySku = ""
+ if (shopifyId) {
+ shopifySku = shopifySkuMap.get(shopifyId) || ""
+ }
+
+ let driveSku = ""
+ const photoUrl = String(photoUrls[i])
+ if (photoUrl && photoUrl.includes("drive.google.com")) {
+ try {
+ let folderId = ""
+ const match = photoUrl.match(/[-\w]{25,}/)
+ if (match) {
+ folderId = match[0]
+ const folder = DriveApp.getFolderById(folderId)
+ driveSku = folder.getName()
+ }
+ } catch (e) {
+ console.log(`Row ${row}: Error fetching Drive Folder: ${e.message}`)
+ }
+ }
+
+ // 3. Determine Winner
+ let targetSku = calculatedSku // Default to calculated
+ let source = "Calculated"
+
+ if (shopifySku && driveSku && shopifySku === driveSku) {
+ targetSku = shopifySku
+ source = "External Match (Shopify + Drive)"
+ } else if (shopifySku) {
+ if (targetSku && targetSku !== shopifySku) {
+ console.log(`Row ${row}: CONFLICT. Calculated=${targetSku}, Shopify=${shopifySku}, Drive=${driveSku}`)
+ }
+ if (!targetSku) {
+ targetSku = shopifySku
+ source = "Shopify (Calculation Failed)"
+ }
+ }
+
+ if (targetSku && currentSku !== targetSku) {
+ console.log(`Row ${row}: Updating SKU '${currentSku}' -> '${targetSku}' [Source: ${source}]`)
+ // Optimization: Use pre-calculated index
+ const cell = sheet.getRange(row, skuColIndex)
+ cell.setValue(targetSku)
+ } else if (targetSku) {
+ // Valid SKU already there
+ } else {
+ console.log(`Row ${row}: Could not determine SKU.`)
+ }
+ }
+}
diff --git a/src/global.ts b/src/global.ts
index 993f5ff..c6b55c8 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -23,8 +23,9 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
import { installSalesSyncTrigger } from "./triggers"
-import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia, pollJobLogs, getMediaManagerInitialState, getMediaSavePlan, executeSavePhase, updateSpreadsheetThumbnail, executeFullSavePlan } from "./mediaHandlers"
+import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia, pollJobLogs, getMediaManagerInitialState, getMediaSavePlan, executeSavePhase, updateSpreadsheetThumbnail, executeFullSavePlan, generateSkuForActiveRow, saveProductDefinition } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite"
+import { backfillSkus } from "./backfill_sku"
// prettier-ignore
;(global as any).onOpen = onOpen
@@ -71,3 +72,6 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).executeSavePhase = executeSavePhase
;(global as any).updateSpreadsheetThumbnail = updateSpreadsheetThumbnail
;(global as any).executeFullSavePlan = executeFullSavePlan
+;(global as any).backfillSkus = backfillSkus
+;(global as any).generateSkuForActiveRow = generateSkuForActiveRow
+;(global as any).saveProductDefinition = saveProductDefinition
diff --git a/src/mediaHandlers.test.ts b/src/mediaHandlers.test.ts
index 6169c4c..e8bf26c 100644
--- a/src/mediaHandlers.test.ts
+++ b/src/mediaHandlers.test.ts
@@ -1,15 +1,31 @@
-import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges, getMediaManagerInitialState } from "./mediaHandlers"
+import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, generateSkuForActiveRow, saveProductDefinition, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges, getMediaManagerInitialState } from "./mediaHandlers"
import { Config } from "./config"
import { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
import { MediaService } from "./services/MediaService"
import { Product } from "./Product"
+import { newSku } from "./newSku"
// --- Mocks ---
+jest.mock("./newSku", () => ({
+ newSku: jest.fn()
+}))
+jest.mock("./sheetUtils", () => ({
+ getColumnValuesByName: jest.fn().mockReturnValue([["TypeA"], ["TypeB"]]),
+ // Add other used functions if needed, likely safe to partial mock if needed
+}))
+import { getColumnValuesByName } from "./sheetUtils"
// Mock Config
jest.mock("./config", () => {
+ // Inject global Drive for testing fallback logic
+ (global as any).Drive = {
+ Files: {
+ create: jest.fn().mockReturnValue({ id: "adv_file_id" }),
+ insert: jest.fn()
+ }
+ };
return {
Config: jest.fn().mockImplementation(() => {
return {
@@ -65,7 +81,7 @@ jest.mock("./services/GASSpreadsheetService", () => {
}),
getRowNumberByColumnValue: jest.fn().mockReturnValue(5),
setCellValueByColumnName: jest.fn(),
- getHeaders: jest.fn().mockReturnValue(["sku", "title", "thumbnail"]),
+ getHeaders: jest.fn().mockReturnValue(["sku", "title", "product_type", "product_style", "thumbnail"]),
getRowData: jest.fn()
}
})
@@ -123,7 +139,8 @@ global.SpreadsheetApp = {
setAltTextTitle: jest.fn().mockReturnThis(),
setAltTextDescription: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue("CELL_IMAGE_OBJECT")
- })
+ }),
+ getActiveSpreadsheet: jest.fn(),
} as any
// UrlFetchApp
@@ -270,18 +287,30 @@ describe("mediaHandlers", () => {
})
test("should fallback to Advanced Drive API if DriveApp.createFile fails", () => {
- ;(DriveApp.createFile as jest.Mock).mockImplementationOnce(() => {
+ // Explicitly ensure global Drive is set for this test
+ (global as any).Drive = {
+ Files: {
+ create: jest.fn().mockReturnValue({ id: "adv_file_id" })
+ }
+ };
+
+ (DriveApp.createFile as jest.Mock).mockImplementationOnce(() => {
throw new Error("Server Error")
})
- ;(Drive.Files.create as jest.Mock).mockReturnValue({ id: "adv_file_id" })
;(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile)
importFromPicker("SKU123", null, "image/jpeg", "fallback.jpg", "https://url")
expect(DriveApp.createFile).toHaveBeenCalled()
- expect(Drive.Files.create).toHaveBeenCalled()
+ expect((global as any).Drive.Files.create).toHaveBeenCalled()
})
+ // ... (other tests)
+
+
+
+
+
test("should throw if folder access fails (Step 2)", () => {
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Folder Access Error") })
expect(() => {
@@ -315,6 +344,14 @@ describe("mediaHandlers", () => {
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
+ // Mock getActiveSpreadsheet for getProductOptionsFromValuesSheet
+ const mockSpreadsheet = {
+ getSheetByName: jest.fn().mockImplementation((name) => {
+ return name === "values" ? {} : null;
+ })
+ };
+ (global.SpreadsheetApp.getActiveSpreadsheet as jest.Mock).mockReturnValue(mockSpreadsheet);
+
const response = getMediaManagerInitialState()
expect(response.sku).toBe("TEST-SKU")
@@ -475,13 +512,13 @@ describe("mediaHandlers", () => {
const mockRange = { getValues: jest.fn() };
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
- getLastColumn: jest.fn().mockReturnValue(2),
+ getLastColumn: jest.fn().mockReturnValue(4),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 }),
getRange: jest.fn().mockReturnValue(mockRange)
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
- mockRange.getValues.mockReturnValueOnce([["sku", "title"]]);
- mockRange.getValues.mockReturnValueOnce([["SKU-1", "Product-1"]]);
+ mockRange.getValues.mockReturnValueOnce([["sku", "title", "product_type", "product_style"]]);
+ mockRange.getValues.mockReturnValueOnce([["SKU-1", "Product-1", "T-Shirt", "Regular"]]);
// Mock Template chain
const mockHtml = {
@@ -492,13 +529,20 @@ describe("mediaHandlers", () => {
const mockTemplate = {
evaluate: jest.fn().mockReturnValue(mockHtml),
initialSku: "",
- initialTitle: ""
+ initialTitle: "",
+ initialProductType: "",
+ initialProductStyle: ""
}
;(global.HtmlService.createTemplateFromFile as jest.Mock).mockReturnValue(mockTemplate)
showMediaManager()
expect(global.HtmlService.createTemplateFromFile).toHaveBeenCalledWith("MediaManager")
+ expect(mockTemplate.initialSku).toBe("SKU-1")
+ expect(mockTemplate.initialTitle).toBe("Product-1")
+ expect(mockTemplate.initialProductType).toBe("T-Shirt")
+ expect(mockTemplate.initialProductStyle).toBe("Regular")
+
expect(mockTemplate.evaluate).toHaveBeenCalled()
expect(mockHtml.setTitle).toHaveBeenCalledWith("Media Manager")
expect(mockHtml.setWidth).toHaveBeenCalledWith(1100)
@@ -506,7 +550,7 @@ describe("mediaHandlers", () => {
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
})
- test("getSelectedProductInfo should return sku and title from sheet", () => {
+ test("getSelectedProductInfo should return sku, title, description, type, style from sheet", () => {
// Mock SpreadsheetApp behavior specifically for the optimized implementation
// The implementation calls:
// 1. sheet.getRange(1, 1, 1, lastCol).getValues()[0] (headers)
@@ -518,7 +562,7 @@ describe("mediaHandlers", () => {
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
- getLastColumn: jest.fn().mockReturnValue(3),
+ getLastColumn: jest.fn().mockReturnValue(4),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 }),
getRange: jest.fn().mockReturnValue(mockRange)
};
@@ -526,12 +570,56 @@ describe("mediaHandlers", () => {
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
// First call: Headers
- mockRange.getValues.mockReturnValueOnce([["sku", "title", "thumbnail"]]);
+ mockRange.getValues.mockReturnValueOnce([["sku", "title", "body_html", "product_type", "product_style"]]);
// Second call: Row Values
- mockRange.getValues.mockReturnValueOnce([["TEST-SKU", "Test Product Title", "thumb.jpg"]]);
+ mockRange.getValues.mockReturnValueOnce([["TEST-SKU", "Test Product Title", "Desc", "Shirt", "Vintage"]]);
const info = getSelectedProductInfo()
- expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" })
+ expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title", description: "Desc", productType: "Shirt", productStyle: "Vintage" })
+ })
+
+ test("saveProductDefinition should update sheet and generate SKU", () => {
+ const mockRange = {
+ getRow: () => 5,
+ getValues: jest.fn().mockReturnValue([["sku", "title", "product_type", "product_style", "body_html"]]) // Headers
+ };
+ const mockSheet = {
+ getName: jest.fn().mockReturnValue("product_inventory"),
+ getActiveRange: jest.fn().mockReturnValue(mockRange),
+ getLastColumn: jest.fn().mockReturnValue(5),
+ getRange: jest.fn().mockReturnValue(mockRange)
+ };
+ (global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
+
+ const mockSSInstance = {
+ setCellValueByColumnName: jest.fn()
+ };
+ (GASSpreadsheetService as unknown as jest.Mock).mockReturnValue(mockSSInstance);
+
+ (newSku as jest.Mock).mockReturnValue("SKU-123");
+
+ const result = saveProductDefinition("TypeA", "StyleB", "Title", "Desc");
+
+ expect(mockSSInstance.setCellValueByColumnName).toHaveBeenCalledWith("product_inventory", 5, "product_type", "TypeA");
+ expect(mockSSInstance.setCellValueByColumnName).toHaveBeenCalledWith("product_inventory", 5, "product_style", "StyleB");
+ expect(mockSSInstance.setCellValueByColumnName).toHaveBeenCalledWith("product_inventory", 5, "title", "Title");
+ expect(mockSSInstance.setCellValueByColumnName).toHaveBeenCalledWith("product_inventory", 5, "body_html", "Desc");
+ expect(newSku).toHaveBeenCalledWith(5);
+ expect(result).toBe("SKU-123");
+ })
+
+ test("generateSkuForActiveRow should delegate to newSku", () => {
+ const mockSheet = {
+ getName: jest.fn().mockReturnValue("product_inventory"),
+ getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 })
+ };
+ (global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
+ ;(newSku as jest.Mock).mockReturnValue("SKU-GEN-123");
+
+ const result = generateSkuForActiveRow();
+
+ expect(newSku).toHaveBeenCalledWith(5);
+ expect(result).toBe("SKU-GEN-123");
})
test("getPickerConfig should return config", () => {
@@ -552,6 +640,51 @@ describe("mediaHandlers", () => {
debugScopes()
expect(ScriptApp.getOAuthToken).toHaveBeenCalled()
})
+
+
+ test("getMediaManagerInitialState should return state with product options", () => {
+ // Mock SpreadsheetApp behavior to simulate NO SKU selected
+ // so that getSelectedProductInfo returns empty/null SKU
+ const mockRange = { getValues: jest.fn() };
+ const mockSheet = {
+ getName: jest.fn().mockReturnValue("product_inventory"),
+ getLastColumn: jest.fn().mockReturnValue(5),
+ getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 }),
+ getRange: jest.fn().mockReturnValue(mockRange)
+ };
+ (global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
+
+ // First call: Headers (1st execution)
+ mockRange.getValues.mockReturnValueOnce([["sku", "title", "body_html", "product_type", "product_style"]]);
+ // Second call: Row Values (1st execution)
+ mockRange.getValues.mockReturnValueOnce([["", "", "", "", ""]]);
+
+ // First call: Headers (2nd execution)
+ mockRange.getValues.mockReturnValueOnce([["sku", "title", "body_html", "product_type", "product_style"]]);
+ // Second call: Row Values (2nd execution)
+ mockRange.getValues.mockReturnValueOnce([["", "", "", "", ""]]);
+
+
+
+ // Mock value sheet reads via getColumnValuesByName
+ const mockValues = [["TypeA"], ["TypeB"], ["TypeC"]];
+ (getColumnValuesByName as jest.Mock).mockReturnValue(mockValues);
+
+ const mockSpreadsheet = {
+ getSheetByName: jest.fn().mockImplementation((name) => {
+ return name === "values" ? {} : null;
+ })
+ };
+ (global.SpreadsheetApp.getActiveSpreadsheet as jest.Mock).mockReturnValue(mockSpreadsheet);
+
+ const state = getMediaManagerInitialState();
+
+ expect(state.productOptions).toBeDefined();
+ expect(state.productOptions?.types).toEqual(["TypeA", "TypeB", "TypeC"]);
+ // Since we use same mock return for both calls in the implementation if we just mocked the util
+ expect(state.productOptions?.styles).toEqual(["TypeA", "TypeB", "TypeC"]);
+ })
+
})
})
diff --git a/src/mediaHandlers.ts b/src/mediaHandlers.ts
index 50d4a0d..36c7d28 100644
--- a/src/mediaHandlers.ts
+++ b/src/mediaHandlers.ts
@@ -6,6 +6,17 @@ import { MediaService } from "./services/MediaService"
import { Shop } from "./shopifyApi"
import { Config } from "./config"
import { Product } from "./Product"
+import { newSku } from "./newSku"
+import { getColumnValuesByName } from "./sheetUtils"
+
+export function generateSkuForActiveRow() {
+ const sheet = SpreadsheetApp.getActiveSheet()
+ if (sheet.getName() !== "product_inventory") throw new Error("Active sheet must be product_inventory")
+ const row = sheet.getActiveRange().getRow()
+ if (row <= 1) throw new Error("Invalid row")
+
+ return newSku(row)
+}
export function showMediaManager() {
const productInfo = getSelectedProductInfo();
@@ -14,6 +25,9 @@ export function showMediaManager() {
// Pass variables to template
(template as any).initialSku = productInfo ? productInfo.sku : "";
(template as any).initialTitle = productInfo ? productInfo.title : "";
+ (template as any).initialDescription = productInfo ? productInfo.description : "";
+ (template as any).initialProductType = productInfo ? productInfo.productType : "";
+ (template as any).initialProductStyle = productInfo ? productInfo.productStyle : "";
const html = template.evaluate()
.setTitle("Media Manager")
@@ -22,7 +36,7 @@ export function showMediaManager() {
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
}
-export function getSelectedProductInfo(): { sku: string, title: string } | null {
+export function getSelectedProductInfo(): { sku: string, title: string, description: string, productType: string, productStyle: string } | null {
const ss = new GASSpreadsheetService()
// Optimization: Direct usage to avoid multiple service calls overhead
@@ -43,6 +57,11 @@ export function getSelectedProductInfo(): { sku: string, title: string } | null
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] as string[];
const skuIdx = headers.indexOf("sku");
const titleIdx = headers.indexOf("title");
+ const descIdx = headers.indexOf("body_html") !== -1 ? headers.indexOf("body_html") :
+ headers.indexOf("Description") !== -1 ? headers.indexOf("Description") :
+ headers.indexOf("description");
+ const typeIdx = headers.indexOf("product_type");
+ const styleIdx = headers.indexOf("product_style");
if (skuIdx === -1) return null; // No SKU column
@@ -52,8 +71,17 @@ export function getSelectedProductInfo(): { sku: string, title: string } | null
const sku = rowValues[skuIdx];
const title = titleIdx !== -1 ? rowValues[titleIdx] : "";
+ const description = descIdx !== -1 ? rowValues[descIdx] : "";
+ const productType = typeIdx !== -1 ? rowValues[typeIdx] : "";
+ const productStyle = styleIdx !== -1 ? rowValues[styleIdx] : "";
- return sku ? { sku: String(sku), title: String(title || "") } : null
+ return {
+ sku: String(sku || ""),
+ title: String(title || ""),
+ description: String(description || ""),
+ productType: String(productType || ""),
+ productStyle: String(productStyle || "")
+ }
}
export function getPickerConfig() {
@@ -317,9 +345,11 @@ export function getMediaDiagnostics(sku: string) {
export function getMediaManagerInitialState(providedSku?: string, providedTitle?: string): {
sku: string | null,
title: string,
+ description?: string,
diagnostics: any,
media: any[],
- token: string
+ token: string,
+ productOptions?: { types: string[], styles: string[] }
} {
let sku = providedSku;
let title = providedTitle || "";
@@ -329,16 +359,29 @@ export function getMediaManagerInitialState(providedSku?: string, providedTitle?
if (info) {
sku = info.sku;
title = info.title;
+ // We don't have a direct field for description in return type yet, let's add it
}
}
+ // Fetch Product Options for dropdowns (always needed for definition UI)
+ const productOptions = getProductOptionsFromValuesSheet();
+
+ // Re-fetch info to get description if we didn't get it above (or just rely on what we have)
+ let description = "";
+ if (!sku) {
+ const info = getSelectedProductInfo();
+ if (info) description = info.description;
+ }
+
if (!sku) {
return {
sku: null,
title: "",
+ description,
diagnostics: null,
media: [],
- token: ScriptApp.getOAuthToken()
+ token: ScriptApp.getOAuthToken(),
+ productOptions
}
}
@@ -360,15 +403,64 @@ export function getMediaManagerInitialState(providedSku?: string, providedTitle?
const shopifyId = product.shopify_id || ""
const initialState = mediaService.getInitialState(sku, shopifyId);
+
+
return {
sku,
+
title,
+ description: "", // Fallback or fetch if needed for existing products? For now mostly needed for new ones.
diagnostics: initialState.diagnostics,
media: initialState.media,
- token: ScriptApp.getOAuthToken()
+ token: ScriptApp.getOAuthToken(),
+ productOptions
}
}
+function getProductOptionsFromValuesSheet() {
+ // Helper to get unique non-empty values
+ const getUnique = (colName: string) => {
+ const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("values");
+ if (!sheet) return [];
+ const values = getColumnValuesByName(sheet, colName); // from sheetUtils
+ if (!values) return [];
+ return [...new Set(values.map(v => String(v[0]).trim()).filter(v => v !== "" && v !== colName))];
+ }
+ return {
+ types: getUnique("product_type"),
+ styles: getUnique("product_style")
+ };
+}
+
+export function saveProductDefinition(productType: string, productStyle: string, title: string, description: string) {
+ const sheet = SpreadsheetApp.getActiveSheet();
+ if (sheet.getName() !== "product_inventory") throw new Error("Active sheet must be product_inventory");
+ const row = sheet.getActiveRange().getRow();
+ if (row <= 1) throw new Error("Invalid row");
+
+ const ss = new GASSpreadsheetService();
+ // Update columns
+ ss.setCellValueByColumnName("product_inventory", row, "product_type", productType);
+ ss.setCellValueByColumnName("product_inventory", row, "product_style", productStyle);
+
+ // Description Column Resolution
+ const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] as string[];
+ const descColName = headers.includes("body_html") ? "body_html" :
+ headers.includes("Description") ? "Description" :
+ headers.includes("description") ? "description" : null;
+
+ if (title) ss.setCellValueByColumnName("product_inventory", row, "title", title);
+
+ // Save Description if column exists (allow empty string to clear)
+ if (descColName) {
+ ss.setCellValueByColumnName("product_inventory", row, descColName, description || "");
+ }
+
+ // Attempt to generate SKU immediately
+ const sku = newSku(row);
+ return sku; // Returns new SKU string or undefined
+}
+
export function linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
const config = new Config()
const driveService = new GASDriveService()
diff --git a/src/newSku.test.ts b/src/newSku.test.ts
new file mode 100644
index 0000000..d8b83a2
--- /dev/null
+++ b/src/newSku.test.ts
@@ -0,0 +1,134 @@
+
+import { newSku } from "./newSku"
+import { Shop } from "./shopifyApi"
+import {
+ getCellRangeByColumnName,
+ getCellValueByColumnName,
+ getColumnValuesByName,
+ vlookupByColumns,
+} from "./sheetUtils"
+
+// Mock dependencies
+jest.mock("./sheetUtils")
+jest.mock("./shopifyApi")
+
+// Mock Google Apps Script global
+global.SpreadsheetApp = {
+ getActive: jest.fn().mockReturnValue({
+ getSheetByName: jest.fn().mockReturnValue({}),
+ }),
+} as any
+
+describe("newSku", () => {
+ let mockSheet: any
+ let mockShop: any
+ const mockSkuCell = {
+ getValue: jest.fn(),
+ setValue: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockSheet = SpreadsheetApp.getActive().getSheetByName("product_inventory")
+
+ // Setup default sheetUtils mocks
+ ;(getCellRangeByColumnName as jest.Mock).mockReturnValue(mockSkuCell)
+ ;(getCellValueByColumnName as jest.Mock).mockImplementation((sheet, col, row) => {
+ if (col === "shopify_id") return null // Default: No existing Shopify ID
+ if (col === "product_type") return "T-Shirt"
+ if (col === "product_style") return "Regular"
+ return null
+ })
+ ;(getColumnValuesByName as jest.Mock).mockReturnValue([]) // Default: No existing SKUs
+ ;(vlookupByColumns as jest.Mock).mockImplementation((sheet, searchCol, searchKey, resCol) => {
+ if (searchKey === "T-Shirt") return "TS"
+ if (searchKey === "Regular") return "R"
+ return null
+ })
+
+ // Setup Shop mock
+ mockShop = {
+ GetProductById: jest.fn()
+ }
+ ;(Shop as unknown as jest.Mock).mockImplementation(() => mockShop)
+ })
+
+ it("should generate a new SKU if no Shopify ID exists", () => {
+ mockSkuCell.getValue.mockReturnValue("?") // Trigger condition
+
+ // Expected: TS (Prefix) + R (Suffix) + -0001
+ const result = newSku(2)
+
+ expect(result).toBe("TSR-0001")
+ expect(mockSkuCell.setValue).toHaveBeenCalledWith("TSR-0001")
+ })
+
+ it("should increment SKU based on existing max ID", () => {
+ mockSkuCell.getValue.mockReturnValue("?")
+ // Mock existing SKUs
+ ;(getColumnValuesByName as jest.Mock).mockReturnValue(["TSR-0005", "TSR-0002", "OTHER-0001"])
+
+ const result = newSku(2)
+
+ expect(result).toBe("TSR-0006")
+ expect(mockSkuCell.setValue).toHaveBeenCalledWith("TSR-0006")
+ })
+
+ it("should use existing Shopify SKU if shopify_id is present and product has SKU", () => {
+ mockSkuCell.getValue.mockReturnValue("?")
+
+ // Mock Shopify ID present in sheet
+ ;(getCellValueByColumnName as jest.Mock).mockImplementation((sheet, col, row) => {
+ if (col === "shopify_id") return "gid://shopify/Product/123"
+ return null
+ })
+
+ // Mock Shopify API return
+ mockShop.GetProductById.mockReturnValue({
+ variants: {
+ nodes: [{ sku: "EXISTING-SKU-123" }]
+ }
+ })
+
+ const result = newSku(2)
+
+ expect(result).toBe("EXISTING-SKU-123")
+ expect(mockSkuCell.setValue).toHaveBeenCalledWith("EXISTING-SKU-123")
+ // Should NOT look up types/styles if found in Shopify
+ expect(vlookupByColumns).not.toHaveBeenCalled()
+ })
+
+ it("should fall back to generation if Shopify product has no SKU", () => {
+ mockSkuCell.getValue.mockReturnValue("?")
+
+ // Mock Shopify ID present
+ ;(getCellValueByColumnName as jest.Mock).mockImplementation((sheet, col, row) => {
+ if (col === "shopify_id") return "gid://shopify/Product/123"
+ if (col === "product_type") return "T-Shirt"
+ if (col === "product_style") return "Regular"
+ return null
+ })
+
+ // Mock Shopify API return (Empty/No SKU)
+ mockShop.GetProductById.mockReturnValue({
+ variants: {
+ nodes: [{ sku: "" }]
+ }
+ })
+
+ const result = newSku(2)
+
+ // Should generate new one
+ expect(result).toBe("TSR-0001")
+ expect(mockSkuCell.setValue).toHaveBeenCalledWith("TSR-0001")
+ })
+
+ it("should not overwrite safe-to-keep values", () => {
+ mockSkuCell.getValue.mockReturnValue("KEEP-ME")
+
+ const result = newSku(2)
+
+ expect(result).toBeUndefined()
+ expect(mockSkuCell.setValue).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/newSku.ts b/src/newSku.ts
index a8a44eb..d75c2f0 100644
--- a/src/newSku.ts
+++ b/src/newSku.ts
@@ -5,7 +5,9 @@ import {
getCellRangeByColumnName,
getCellValueByColumnName,
getColumnValuesByName,
+ vlookupByColumns,
} from "./sheetUtils"
+import { Shop } from "./shopifyApi"
const LOCK_TIMEOUT_MS = 1000 * 10
@@ -16,21 +18,27 @@ export function newSkuHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
return
}
let row = e.range.getRowIndex()
- let idCell = getCellRangeByColumnName(sheet, "#", row)
- let idCellValue = idCell.getValue()
- console.log("idCellValue = '" + idCellValue + "'")
- if (idCellValue != "?" && idCellValue != "n") {
- console.log("new ID was not requested, returning")
+ let skuCell = getCellRangeByColumnName(sheet, "sku", row)
+ let skuCellValue = skuCell.getValue()
+ console.log("skuCellValue = '" + skuCellValue + "'")
+
+ // Only proceed if SKU is strictly '?' or 'n'
+ // (We don't want to overwrite blank cells that might just be new rows)
+ if (skuCellValue != "?" && skuCellValue != "n") {
+ console.log("new SKU was not requested (must be '?' or 'n'), returning")
return
}
+
// Acquire a user lock to prevent multiple onEdit calls from clashing
const documentLock = LockService.getDocumentLock()
try {
const config = new (Config);
documentLock.waitLock(LOCK_TIMEOUT_MS)
const sku = newSku(row)
- console.log("new sku: " + sku)
- createPhotoFolderForSku(config, String(sku))
+ if (sku) {
+ console.log("new sku: " + sku)
+ createPhotoFolderForSku(config, String(sku))
+ }
} catch (error) {
console.log("Error in newSkuHandler: " + error.message)
} finally {
@@ -40,43 +48,84 @@ export function newSkuHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
export function newSku(row: number) {
let sheet = SpreadsheetApp.getActive().getSheetByName("product_inventory")
- let skuPrefixCol = getColumnByName(sheet, "sku_prefix")
- console.log("skuPrefixCol: " + skuPrefixCol)
- let idCol = getColumnByName(sheet, "#")
- console.log("idCol: " + idCol)
- let idCell = getCellRangeByColumnName(sheet, "#", row)
+
+ let skuCell = getCellRangeByColumnName(sheet, "sku", row)
let safeToOverwrite: string[] = ["?", "n", ""]
- let idCellValue = idCell.getValue()
- let skuPrefixCellValue = getCellValueByColumnName(sheet, "sku_prefix", row)
- console.log("skuPrefixCellValue = '" + skuPrefixCellValue + "'")
- if (!safeToOverwrite.includes(idCellValue)) {
- console.log("ID '" + idCellValue + "' is not safe to overwrite, returning")
- return
+ let currentSku = skuCell.getValue()
+
+ if (!safeToOverwrite.includes(currentSku)) {
+ // Double check we aren't overwriting a valid SKU
+ console.log("SKU '" + currentSku + "' is not safe to overwrite, returning")
+ return
}
+
+ // 1. Check for existing Shopify SKU (Safety Check)
+ let shopifyId = getCellValueByColumnName(sheet, "shopify_id", row)
+ if (shopifyId && shopifyId !== "?" && shopifyId !== "n" && shopifyId !== "") {
+ console.log(`Checking Shopify for existing SKU (ID: ${shopifyId})`)
+ const shop = new Shop()
+ const product = shop.GetProductById(shopifyId)
+ if (product && product.variants && product.variants.nodes.length > 0) {
+ const existingSku = product.variants.nodes[0].sku
+ if (existingSku) {
+ console.log(`Found existing SKU in Shopify: ${existingSku}. Using it.`)
+ skuCell.setValue(existingSku)
+ return existingSku
+ }
+ }
+ }
+
+ // 2. Get Product Type & Style
+ let productType = getCellValueByColumnName(sheet, "product_type", row)
+ let productStyle = getCellValueByColumnName(sheet, "product_style", row)
+
+ if (!productType || !productStyle) {
+ console.log("Missing product_type or product_style, cannot generate SKU")
+ return
+ }
+
+ // Lookup Prefix & Suffix
+ // product_type -> sku_prefix (in values sheet)
+ let skuPrefix = vlookupByColumns("values", "product_type", productType, "sku_prefix")
+
+ // product_style -> sku_suffix (in values sheet)
+ // Note: Plan says "type_sku_code" -> "sku_suffix", assuming column rename happened or mapped via values sheet
+ let skuSuffix = vlookupByColumns("values", "product_style", productStyle, "sku_suffix")
+
+ if (!skuPrefix) {
+ console.log(`Could not find sku_prefix for product_type '${productType}'`)
+ return
+ }
+ if (!skuSuffix) {
+ console.log(`Could not find sku_suffix for product_style '${productStyle}'`)
+ return
+ }
+
+ let codeBase = `${skuPrefix}${skuSuffix}`
+
+ // Find next ID
var skuArray = getColumnValuesByName(sheet, "sku")
- var regExp = new RegExp(`^` + skuPrefixCellValue + `-0*(\\d+)$`)
+ // Regex: PrefixSuffix + "-0*" + (digits)
+ // e.g. TSR-0001
+ var regExp = new RegExp(`^` + codeBase + `-0*(\\d+)$`)
console.log("regExp: " + regExp.toString())
+
var maxId = 0
for (let i = 0; i < skuArray.length; i++) {
- console.log("checking row " + (i + 1))
- if (null == skuArray[i] || String(skuArray[i]) == "") {
- console.log("SKU cell looks null")
- continue
- }
- console.log("SKU cell: '" + skuArray[i] + "'")
- var match = regExp.exec(String(skuArray[i]))
- if (null === match) {
- console.log("SKU cell did not match")
- continue
- }
- let numId = Number(match[1])
- console.log("match: '" + match + "', numId: " + numId)
- maxId = Math.max(numId, maxId)
- console.log("numId: " + numId + ", maxId: " + maxId)
- }
- let newId = maxId + 1
- console.log("newId: " + newId)
- idCell.setValue(newId)
+ if (null == skuArray[i] || String(skuArray[i]) == "") continue
- return `${skuPrefixCellValue}-${newId.toString().padStart(4, "0")}`
+ var match = regExp.exec(String(skuArray[i]))
+ if (null === match) continue
+
+ let numId = Number(match[1])
+ maxId = Math.max(numId, maxId)
+ }
+
+ let newId = maxId + 1
+ let newSku = `${codeBase}-${newId.toString().padStart(4, "0")}`
+
+ console.log("Generated SKU: " + newSku)
+ skuCell.setValue(newSku)
+
+ return newSku
}
diff --git a/src/productTemplate.ts b/src/productTemplate.ts
index 5faa865..4b64d43 100644
--- a/src/productTemplate.ts
+++ b/src/productTemplate.ts
@@ -9,8 +9,7 @@ import {
export function productTemplate(row: number) {
//TODO: just use the columns that exist, if they match
let updateColumns = [
- "function",
- "type",
+ "product_style",
"category",
"product_type",
"tags",
diff --git a/src/sheetUtils.ts b/src/sheetUtils.ts
index 225d88e..73ab1f8 100644
--- a/src/sheetUtils.ts
+++ b/src/sheetUtils.ts
@@ -37,6 +37,9 @@ export function getColumnByName(
) {
let data = sheet.getRange("A1:1").getValues()
let column = data[0].indexOf(columnName)
+ if (column === -1) {
+ return -1
+ }
return column + 1
}
diff --git a/src/shopifyApi.ts b/src/shopifyApi.ts
index fa2de50..5af5a2f 100644
--- a/src/shopifyApi.ts
+++ b/src/shopifyApi.ts
@@ -529,7 +529,7 @@ export class Shop implements IShop {
let done = false
let query = ""
let cursor = ""
- let fields = ["id", "title"]
+ let fields = ["id", "title", "handle"]
var response = {
content: {},
headers: {},
@@ -538,7 +538,7 @@ export class Shop implements IShop {
do {
let pq = new ShopifyProductsQuery(query, fields, cursor)
response = this.shopifyGraphQLAPI(pq.JSON)
- console.log(response)
+ // console.log(response)
let productsResponse = new ShopifyProductsResponse(response.content)
if (productsResponse.products.edges.length <= 0) {
console.log("no products returned")
@@ -547,9 +547,9 @@ export class Shop implements IShop {
}
for (let i = 0; i < productsResponse.products.edges.length; i++) {
let edge = productsResponse.products.edges[i]
- console.log(JSON.stringify(edge))
+ // console.log(JSON.stringify(edge))
let p = new ShopifyProduct()
- Object.assign(edge.node, p)
+ Object.assign(p, edge.node)
products.push(p)
}
if (productsResponse.products.pageInfo.hasNextPage) {
@@ -558,6 +558,7 @@ export class Shop implements IShop {
done = true
}
} while (!done)
+ return products
}
GetProductBySku(sku: string) {
@@ -1094,6 +1095,7 @@ export class ShopifyProductsQuery {
variants(first:1) {
nodes {
id
+ sku
}
}
options {