Compare commits
4 Commits
1068c912dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 605a4488ac | |||
| eeead33b2c | |||
| 778c0d1620 | |||
| f3d8514e62 |
67
docs/SKU logic migration plan.md
Normal file
67
docs/SKU logic migration plan.md
Normal file
@ -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
|
||||
@ -300,6 +300,7 @@
|
||||
border-bottom-right-radius: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
@ -574,6 +575,12 @@
|
||||
.unlink-btn:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
/* Ensure plan list items contain their absolute children */
|
||||
#details-content li,
|
||||
#match-modal-text li {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@ -723,6 +730,78 @@
|
||||
<button onclick="google.script.host.close()" class="btn" style="width: auto;">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Generate SKU Prompt -->
|
||||
<div id="generate-sku-ui"
|
||||
style="display:none; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding-top: 80px;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">🏷️</div>
|
||||
<h3 style="margin: 0 0 8px 0; color: var(--text);">Generate SKU?</h3>
|
||||
<p style="color: var(--text-secondary); max-width: 300px; margin-bottom: 24px; line-height: 1.5;">
|
||||
This product needs a SKU to proceed.<br>
|
||||
<strong>Type:</strong> <span id="prompt-type"></span><br>
|
||||
<strong>Style:</strong> <span id="prompt-style"></span>
|
||||
</p>
|
||||
<button onclick="controller.generateSku()" class="btn btn-primary" style="width: auto;">Generate SKU</button>
|
||||
</div>
|
||||
|
||||
<!-- Define Product Prompt -->
|
||||
<div id="define-product-ui"
|
||||
style="display:none; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding-top: 80px;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">📝</div>
|
||||
<h3 style="margin: 0 0 8px 0; color: var(--text);">missing Information</h3>
|
||||
<p style="color: var(--text-secondary); max-width: 300px; margin-bottom: 24px; line-height: 1.5;">
|
||||
Please define <strong>Product Type</strong> and <strong>Style</strong> to generate a SKU.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; width: 100%; max-width: 400px; text-align: left;">
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label
|
||||
style="font-size: 12px; font-weight: 500; color: var(--text-secondary); display:block; margin-bottom: 4px;">Product
|
||||
Title</label>
|
||||
<input id="input-product-title" type="text" placeholder="e.g. Vintage T-Shirt"
|
||||
style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-surface); color: var(--text); font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label
|
||||
style="font-size: 12px; font-weight: 500; color: var(--text-secondary); display:block; margin-bottom: 4px;">Description</label>
|
||||
<textarea id="input-product-desc" rows="3" placeholder="Product details..."
|
||||
style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-surface); color: var(--text); font-family: inherit; font-size: 14px; resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<!-- Type -->
|
||||
<div style="flex: 1;">
|
||||
<label
|
||||
style="font-size: 12px; font-weight: 500; color: var(--text-secondary); display:block; margin-bottom: 4px;">Product
|
||||
Type</label>
|
||||
<select id="input-product-type"
|
||||
style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-surface); color: var(--text);">
|
||||
<option value="">Select Type...</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Style -->
|
||||
<div style="flex: 1;">
|
||||
<label
|
||||
style="font-size: 12px; font-weight: 500; color: var(--text-secondary); display:block; margin-bottom: 4px;">Product
|
||||
Style</label>
|
||||
<select id="input-product-style"
|
||||
style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-surface); color: var(--text);">
|
||||
<option value="">Select Style...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-top: 8px;">
|
||||
<button id="btn-save-def" onclick="controller.saveProductDefinition()" class="btn btn-primary"
|
||||
style="flex: 1;">Save & Generate SKU</button>
|
||||
<button onclick="google.script.host.close()" class="btn" style="width: auto;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)">
|
||||
<div class="modal-content">
|
||||
@ -811,14 +890,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 +912,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 +1107,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 +1791,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 +1833,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 = '<option value="">Select Type...</option>';
|
||||
styleSelect.innerHTML = '<option value="">Select Style...</option>';
|
||||
|
||||
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
|
||||
@ -1793,6 +1927,26 @@
|
||||
if (diagnostics.shopify.status === 'ok') {
|
||||
ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success');
|
||||
ui.setShopifyLink(diagnostics.shopify.adminUrl);
|
||||
|
||||
// BooLouMud Link
|
||||
if (diagnostics.shopify.onlineStoreUrl) {
|
||||
var quickLinks = document.getElementById('quick-links');
|
||||
// Check if link already exists (via ID?) or just append if empty.
|
||||
// Since loadMedia clears/resets UI state usually, but quick-links might persist?
|
||||
// Actually quick-links is empty on load? No, let's clear it first to be safe or check.
|
||||
// But ui.setShopifyLink might use it?
|
||||
// Let's perform a direct safe inject.
|
||||
if (quickLinks && !document.getElementById('booloumud-link')) {
|
||||
var link = document.createElement('a');
|
||||
link.id = 'booloumud-link';
|
||||
link.href = diagnostics.shopify.onlineStoreUrl;
|
||||
link.target = '_blank';
|
||||
link.style.textDecoration = 'none';
|
||||
link.style.color = 'var(--primary)';
|
||||
link.innerHTML = 'BooLouMud ↗';
|
||||
quickLinks.appendChild(link);
|
||||
}
|
||||
}
|
||||
} else if (diagnostics.shopify.status === 'skipped') {
|
||||
ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info');
|
||||
} else {
|
||||
@ -1834,6 +1988,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'; });
|
||||
|
||||
@ -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] + "'")
|
||||
@ -199,6 +198,10 @@ export class Product {
|
||||
"UpdateShopifyProduct: no product matched, this will be a new product"
|
||||
)
|
||||
newProduct = true
|
||||
// Default to DRAFT for auto-created products
|
||||
if (!this.shopify_status) {
|
||||
this.shopify_status = "DRAFT";
|
||||
}
|
||||
}
|
||||
console.log("UpdateShopifyProduct: calling productSet")
|
||||
let sps = this.ToShopifyProductSet()
|
||||
|
||||
156
src/backfill_sku.ts
Normal file
156
src/backfill_sku.ts
Normal file
@ -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<string, string>() // 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.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -4,5 +4,7 @@ export interface IShopifyMediaService {
|
||||
getProductMedia(productId: string): any[]
|
||||
productDeleteMedia(productId: string, mediaId: string): any
|
||||
productReorderMedia(productId: string, moves: any[]): any
|
||||
getProduct(productId: string): any
|
||||
getProductWithMedia(productId: string): any
|
||||
getShopDomain(): string
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
@ -80,7 +96,9 @@ jest.mock("./Product", () => {
|
||||
sku: sku,
|
||||
shopify_id: "shopify_id_123",
|
||||
title: "Test Product Title",
|
||||
shopify_status: "ACTIVE",
|
||||
MatchToShopifyProduct: jest.fn(),
|
||||
UpdateShopifyProduct: jest.fn(),
|
||||
ImportFromInventory: jest.fn()
|
||||
}
|
||||
})
|
||||
@ -123,7 +141,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 +289,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 +346,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")
|
||||
@ -358,16 +397,21 @@ describe("mediaHandlers", () => {
|
||||
expect(calledInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything(), null)
|
||||
})
|
||||
|
||||
test("should throw if product not synced", () => {
|
||||
const MockProduct = Product as unknown as jest.Mock
|
||||
MockProduct.mockImplementationOnce(() => ({
|
||||
shopify_id: null,
|
||||
MatchToShopifyProduct: jest.fn(),
|
||||
ImportFromInventory: jest.fn()
|
||||
}))
|
||||
test("saveMediaChanges should auto-create product if not synced", () => {
|
||||
const MockProduct = Product as unknown as jest.Mock
|
||||
const mockUpdateShopify = jest.fn().mockImplementation(function(this: any) {
|
||||
this.shopify_id = "NEW_ID"
|
||||
})
|
||||
MockProduct.mockImplementationOnce(() => ({
|
||||
shopify_id: null,
|
||||
MatchToShopifyProduct: jest.fn(),
|
||||
UpdateShopifyProduct: mockUpdateShopify,
|
||||
ImportFromInventory: jest.fn()
|
||||
}))
|
||||
|
||||
expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced")
|
||||
})
|
||||
saveMediaChanges("SKU123", [])
|
||||
expect(mockUpdateShopify).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should update sheet thumbnail with first image", () => {
|
||||
// Setup mock MediaService to NOT throw and just return logs
|
||||
@ -475,13 +519,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 +536,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 +557,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 +569,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 +577,89 @@ 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(),
|
||||
getRowNumberByColumnValue: jest.fn().mockReturnValue(5), // Added for robustness
|
||||
getHeaders: jest.fn().mockReturnValue(["sku", "title", "product_type", "product_style", "body_html"])
|
||||
};
|
||||
(GASSpreadsheetService as unknown as jest.Mock).mockReturnValueOnce(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("saveMediaChanges should auto-create product if unsynced", () => {
|
||||
// Mock defaults for this test
|
||||
const mockRange = { getRow: () => 5 };
|
||||
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);
|
||||
|
||||
// Setup Unsynced Product
|
||||
const MockProduct = Product as unknown as jest.Mock
|
||||
const mockUpdateShopify = jest.fn().mockImplementation(function(this: any) {
|
||||
this.shopify_id = "CREATED_ID_123"
|
||||
this.shopify_status = "DRAFT"
|
||||
})
|
||||
|
||||
MockProduct.mockImplementationOnce(() => ({
|
||||
shopify_id: "",
|
||||
MatchToShopifyProduct: jest.fn(),
|
||||
UpdateShopifyProduct: mockUpdateShopify
|
||||
}))
|
||||
|
||||
// Proceed with save
|
||||
const finalState = [{ id: "1" }]
|
||||
saveMediaChanges("SKU_NEW", finalState)
|
||||
|
||||
expect(mockUpdateShopify).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 +680,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"]);
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -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() {
|
||||
@ -113,10 +141,12 @@ export function saveMediaChanges(sku: string, finalState: any[], jobId: string |
|
||||
}
|
||||
|
||||
if (!product.shopify_id) {
|
||||
// Allow saving Drive-only changes? No, we need Shopify context for "Staging" usually.
|
||||
// But if we just rename drive files, we could?
|
||||
// For now, fail safe.
|
||||
throw new Error("Product must be synced to Shopify before saving media changes.")
|
||||
console.log("saveMediaChanges: Product not synced. Auto-creating Draft Product...");
|
||||
product.UpdateShopifyProduct(shop);
|
||||
|
||||
if (!product.shopify_id) {
|
||||
throw new Error("Failed to auto-create Draft Product. Cannot save media.");
|
||||
}
|
||||
}
|
||||
|
||||
const logs = mediaService.processMediaChanges(sku, finalState, product.shopify_id, jobId)
|
||||
@ -224,10 +254,11 @@ export function getMediaSavePlan(sku: string, finalState: any[]) {
|
||||
}
|
||||
|
||||
if (!product.shopify_id) {
|
||||
throw new Error("Product must be synced to Shopify before saving media changes.")
|
||||
console.log("getMediaSavePlan: Product not synced. Proceeding with empty Shopify state.");
|
||||
}
|
||||
|
||||
return mediaService.calculatePlan(sku, finalState, product.shopify_id);
|
||||
// Pass empty string if no ID, ensure calculatePlan handles it (it expects string)
|
||||
return mediaService.calculatePlan(sku, finalState, product.shopify_id || "");
|
||||
}
|
||||
|
||||
export function executeSavePhase(sku: string, phase: string, planData: any, jobId: string | null = null) {
|
||||
@ -246,7 +277,9 @@ export function executeSavePhase(sku: string, phase: string, planData: any, jobI
|
||||
}
|
||||
|
||||
if (!product.shopify_id) {
|
||||
throw new Error("Product must be synced to Shopify before saving media changes.")
|
||||
console.log("executeSavePhase: Product not synced. Auto-creating Draft Product...");
|
||||
product.UpdateShopifyProduct(shop);
|
||||
if (!product.shopify_id) throw new Error("Failed to auto-create Draft Product.");
|
||||
}
|
||||
|
||||
return mediaService.executeSavePhase(sku, phase, planData, product.shopify_id, jobId);
|
||||
@ -268,7 +301,9 @@ export function executeFullSavePlan(sku: string, plan: any, jobId: string | null
|
||||
}
|
||||
|
||||
if (!product.shopify_id) {
|
||||
throw new Error("Product must be synced to Shopify before saving media changes.")
|
||||
console.log("executeFullSavePlan: Product not synced. Auto-creating Draft Product...");
|
||||
product.UpdateShopifyProduct(shop);
|
||||
if (!product.shopify_id) throw new Error("Failed to auto-create Draft Product.");
|
||||
}
|
||||
|
||||
return mediaService.executeFullSavePlan(sku, plan, product.shopify_id, jobId);
|
||||
@ -317,9 +352,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 +366,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 +410,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()
|
||||
|
||||
@ -20,7 +20,15 @@ const mockShopify = {
|
||||
productCreateMedia: jest.fn(),
|
||||
productDeleteMedia: jest.fn(),
|
||||
productReorderMedia: jest.fn(),
|
||||
stagedUploadsCreate: jest.fn()
|
||||
stagedUploadsCreate: jest.fn(),
|
||||
getProductWithMedia: jest.fn().mockImplementation(() => {
|
||||
// Delegate to specific mocks if set, otherwise default
|
||||
const media = mockShopify.getProductMedia() || [];
|
||||
return {
|
||||
product: { id: "gid://shopify/Product/123", title: "Mock Product", handle: "mock-product", onlineStoreUrl: "" },
|
||||
media: media
|
||||
}
|
||||
})
|
||||
}
|
||||
const mockNetwork = {
|
||||
fetch: jest.fn(),
|
||||
|
||||
134
src/newSku.test.ts
Normal file
134
src/newSku.test.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
127
src/newSku.ts
127
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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -42,7 +42,7 @@ export class MediaService {
|
||||
private fetchRawData(sku: string, shopifyProductId: string) {
|
||||
const result = {
|
||||
drive: { folder: null, files: [], error: null, folderUrl: null },
|
||||
shopify: { media: [], error: null }
|
||||
shopify: { media: [], product: null, error: null }
|
||||
};
|
||||
|
||||
// 1. Unsafe Drive Check
|
||||
@ -59,7 +59,11 @@ export class MediaService {
|
||||
// 2. Unsafe Shopify Check
|
||||
if (shopifyProductId) {
|
||||
try {
|
||||
result.shopify.media = this.shopifyMediaService.getProductMedia(shopifyProductId);
|
||||
const combined = this.shopifyMediaService.getProductWithMedia(shopifyProductId);
|
||||
if (combined) {
|
||||
result.shopify.media = combined.media;
|
||||
result.shopify.product = combined.product;
|
||||
}
|
||||
} catch (e) {
|
||||
result.shopify.error = e;
|
||||
}
|
||||
@ -71,7 +75,7 @@ export class MediaService {
|
||||
getDiagnostics(sku: string, shopifyProductId: string, rawData?: any) {
|
||||
const results = {
|
||||
drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null },
|
||||
shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null },
|
||||
shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, onlineStoreUrl: null, error: null },
|
||||
matching: { status: 'pending', error: null },
|
||||
activeJobId: null
|
||||
}
|
||||
@ -111,6 +115,14 @@ export class MediaService {
|
||||
// Admin URL construction (Best effort)
|
||||
const domain = this.shopifyMediaService.getShopDomain ? this.shopifyMediaService.getShopDomain() : 'admin.shopify.com';
|
||||
results.shopify.adminUrl = `https://${domain.replace('.myshopify.com', '')}.myshopify.com/admin/products/${shopifyProductId.split('/').pop()}`;
|
||||
|
||||
// Online Store URL logic
|
||||
if (data.shopify.product && data.shopify.product.onlineStoreUrl) {
|
||||
results.shopify.onlineStoreUrl = data.shopify.product.onlineStoreUrl;
|
||||
} else if (data.shopify.product && data.shopify.product.handle) {
|
||||
results.shopify.onlineStoreUrl = `https://${domain}/products/${data.shopify.product.handle}`;
|
||||
}
|
||||
|
||||
results.shopify.status = 'ok';
|
||||
}
|
||||
} else {
|
||||
@ -120,7 +132,7 @@ export class MediaService {
|
||||
return results;
|
||||
}
|
||||
|
||||
getUnifiedMediaState(sku: string, shopifyProductId: string, rawData?: any): any[] {
|
||||
getUnifiedMediaState(sku: string, shopifyProductId: string, rawData?: any, skipThumbnails: boolean = false): any[] {
|
||||
console.log(`MediaService: Getting unified state for SKU ${sku}`);
|
||||
|
||||
const data = rawData || this.fetchRawData(sku, shopifyProductId);
|
||||
@ -164,26 +176,6 @@ export class MediaService {
|
||||
return { file: f, shopifyId, galleryOrder, type, customThumbnailId, parentVideoId }
|
||||
})
|
||||
|
||||
// Populate Sidecar Map
|
||||
driveFileStats.forEach(stat => {
|
||||
if (stat.type === 'thumbnail' && stat.parentVideoId) {
|
||||
sidecarFileIds.add(stat.file.getId());
|
||||
// URL-based approach failed (CORS/Auth).
|
||||
// Switch to Server-Side Base64 encoding (Robust).
|
||||
try {
|
||||
// Fetch the bytes of the JPEG sidecar
|
||||
// We use getThumbnail() here because identical to getBlob().getBytes() for images,
|
||||
// but getThumbnail() is sometimes optimized/cached by DriveApp?
|
||||
// actually getBlob() is safer for the "original" sidecar content.
|
||||
const bytes = stat.file.getBlob().getBytes();
|
||||
const b64 = Utilities.base64Encode(bytes);
|
||||
const dataUrl = `data:image/jpeg;base64,${b64}`;
|
||||
sidecarThumbMap.set(stat.parentVideoId, dataUrl);
|
||||
} catch (e) {
|
||||
console.warn(`[MediaService] Failed to read sidecar file ${stat.file.getName()}: ${e}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort: Gallery Order ASC, then Filename ASC
|
||||
driveFileStats.sort((a, b) => {
|
||||
@ -193,156 +185,201 @@ export class MediaService {
|
||||
return a.file.getName().localeCompare(b.file.getName());
|
||||
});
|
||||
|
||||
|
||||
// Batch Status Check for Videos with Sidecars
|
||||
const videoStatusMap = new Map<string, any>();
|
||||
// Identify videos that MIGHT be ready (have sidecar)
|
||||
const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId()));
|
||||
|
||||
if (videosToCheck.length > 0 && typeof Drive !== 'undefined') {
|
||||
try {
|
||||
// Check status for ALL videos in folder. Easier than filtering by specific IDs in 'q' which has length limits.
|
||||
// We assume the folder ID is valid.
|
||||
const folderId = data.drive.folder ? data.drive.folder.getId() : null;
|
||||
if (folderId) {
|
||||
// @ts-ignore
|
||||
const response = Drive.Files.list({
|
||||
q: `'${folderId}' in parents and mimeType contains 'video/' and trashed = false`,
|
||||
fields: 'files(id, hasThumbnail, thumbnailLink, videoMediaMetadata)'
|
||||
});
|
||||
if (response.files) {
|
||||
response.files.forEach((f: any) => videoStatusMap.set(f.id, f));
|
||||
if (!skipThumbnails) {
|
||||
// Populate Sidecar Map
|
||||
driveFileStats.forEach(stat => {
|
||||
if (stat.type === 'thumbnail' && stat.parentVideoId) {
|
||||
sidecarFileIds.add(stat.file.getId());
|
||||
// URL-based approach failed (CORS/Auth).
|
||||
// Switch to Server-Side Base64 encoding (Robust).
|
||||
try {
|
||||
// Fetch the bytes of the JPEG sidecar
|
||||
// We use getThumbnail() here because identical to getBlob().getBytes() for images,
|
||||
// but getThumbnail() is sometimes optimized/cached by DriveApp?
|
||||
// actually getBlob() is safer for the "original" sidecar content.
|
||||
const bytes = stat.file.getBlob().getBytes();
|
||||
const b64 = Utilities.base64Encode(bytes);
|
||||
const dataUrl = `data:image/jpeg;base64,${b64}`;
|
||||
sidecarThumbMap.set(stat.parentVideoId, dataUrl);
|
||||
} catch (e) {
|
||||
console.warn(`[MediaService] Failed to read sidecar file ${stat.file.getName()}: ${e}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[MediaService] Batch video status check failed", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Match Logic (Strict ID Match Only)
|
||||
driveFileStats.forEach(d => {
|
||||
// Skip Sidecar Files in main list
|
||||
if (sidecarFileIds.has(d.file.getId())) return;
|
||||
// Batch Status Check for Videos with Sidecars
|
||||
const videoStatusMap = new Map<string, any>();
|
||||
// Identify videos that MIGHT be ready (have sidecar)
|
||||
const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId()));
|
||||
|
||||
let match = null
|
||||
let isProcessing = false
|
||||
let thumbnail = "";
|
||||
|
||||
// 1. ID Match
|
||||
if (d.shopifyId) {
|
||||
match = shopifyMedia.find(m => m.id === d.shopifyId)
|
||||
if (match) matchedShopifyIds.add(match.id)
|
||||
if (videosToCheck.length > 0 && typeof Drive !== 'undefined') {
|
||||
try {
|
||||
// Check status for ALL videos in folder. Easier than filtering by specific IDs in 'q' which has length limits.
|
||||
// We assume the folder ID is valid.
|
||||
const folderId = data.drive.folder ? data.drive.folder.getId() : null;
|
||||
if (folderId) {
|
||||
// @ts-ignore
|
||||
const response = Drive.Files.list({
|
||||
q: `'${folderId}' in parents and mimeType contains 'video/' and trashed = false`,
|
||||
fields: 'files(id, hasThumbnail, thumbnailLink, videoMediaMetadata)'
|
||||
});
|
||||
if (response.files) {
|
||||
response.files.forEach((f: any) => videoStatusMap.set(f.id, f));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[MediaService] Batch video status check failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail Logic
|
||||
if (match && match.preview && match.preview.image && match.preview.image.originalSrc) {
|
||||
thumbnail = match.preview.image.originalSrc;
|
||||
} else {
|
||||
// Drive Thumbnail Strategy
|
||||
// Determine if Native Drive Thumbnail is ready/valid
|
||||
let nativeThumbReady = false;
|
||||
let nativeThumbUrl = "";
|
||||
// Match Logic (Strict ID Match Only)
|
||||
driveFileStats.forEach(d => {
|
||||
// Skip Sidecar Files in main list
|
||||
if (sidecarFileIds.has(d.file.getId())) return;
|
||||
|
||||
try {
|
||||
// We assume if getThumbnail() succeeds and returns "substantial" data, it's ready.
|
||||
// Or check availability of thumbnailLink if we had used Advanced API.
|
||||
// Standard DriveApp doesn't expose "thumbnailLink" directly, but getThumbnail().
|
||||
// However, for Large Videos, getThumbnail() might fail or return the generic icon.
|
||||
// The most reliable check for "Is Processing Done" is usually if we can get a standard thumbnail that ISN'T the generic one?
|
||||
// Hard to tell generic from bytes.
|
||||
// Alternative: If we have a Sidecar, WE ARE IN CHARGE.
|
||||
// We only switch if we are SURE.
|
||||
// Let's us try to fetch the thumbnail bytes.
|
||||
const thumbBlob = d.file.getThumbnail();
|
||||
if (thumbBlob && thumbBlob.getContentType() !== 'application/vnd.google-apps.folder') {
|
||||
// Check size? Generic icons are small?
|
||||
// Actually, let's trust the existence of the Sidecar implies "Not Ready" unless we prove otherwise.
|
||||
// But we want to CLEANUP.
|
||||
// Let's use Advanced Drive API to check `thumbnailLink` existence for this specific file, if it has a sidecar.
|
||||
// This minimizes API calls to ONLY when we have a sidecar candidate.
|
||||
// Batch Optimized Check
|
||||
if (videoStatusMap.has(d.file.getId())) {
|
||||
const meta = videoStatusMap.get(d.file.getId());
|
||||
// Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid..
|
||||
// Check `videoMediaMetadata.width` to ensure processing is complete (width is often missing during processing)
|
||||
if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
|
||||
// SUCCESS: Drive has finished processing.
|
||||
nativeThumbReady = true;
|
||||
console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`);
|
||||
let match = null
|
||||
let isProcessing = false
|
||||
let thumbnail = "";
|
||||
|
||||
// Cleanup Sidecar
|
||||
const sidecarId = d.customThumbnailId; // Direct lookup from properties
|
||||
if (sidecarId) {
|
||||
try {
|
||||
this.driveService.trashFile(sidecarId);
|
||||
sidecarFileIds.delete(sidecarId);
|
||||
sidecarThumbMap.delete(d.file.getId());
|
||||
console.log(`[MediaService] Trashed sidecar ${sidecarId}`);
|
||||
} catch (trashErr) {
|
||||
console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr);
|
||||
}
|
||||
// 1. ID Match
|
||||
if (d.shopifyId) {
|
||||
match = shopifyMedia.find(m => m.id === d.shopifyId)
|
||||
if (match) matchedShopifyIds.add(match.id)
|
||||
}
|
||||
|
||||
// Thumbnail Logic
|
||||
if (match && match.preview && match.preview.image && match.preview.image.originalSrc) {
|
||||
thumbnail = match.preview.image.originalSrc;
|
||||
} else {
|
||||
// Drive Thumbnail Strategy
|
||||
// Determine if Native Drive Thumbnail is ready/valid
|
||||
let nativeThumbReady = false;
|
||||
|
||||
try {
|
||||
// We assume if getThumbnail() succeeds and returns "substantial" data, it's ready.
|
||||
// Or check availability of thumbnailLink if we had used Advanced API.
|
||||
// Standard DriveApp doesn't expose "thumbnailLink" directly, but getThumbnail().
|
||||
// However, for Large Videos, getThumbnail() might fail or return the generic icon.
|
||||
// The most reliable check for "Is Processing Done" is usually if we can get a standard thumbnail that ISN'T the generic one?
|
||||
// Hard to tell generic from bytes.
|
||||
// Alternative: If we have a Sidecar, WE ARE IN CHARGE.
|
||||
// We only switch if we are SURE.
|
||||
// Let's us try to fetch the thumbnail bytes.
|
||||
const thumbBlob = d.file.getThumbnail();
|
||||
if (thumbBlob && thumbBlob.getContentType() !== 'application/vnd.google-apps.folder') {
|
||||
// Check size? Generic icons are small?
|
||||
// Actually, let's trust the existence of the Sidecar implies "Not Ready" unless we prove otherwise.
|
||||
// But we want to CLEANUP.
|
||||
// Let's use Advanced Drive API to check `thumbnailLink` existence for this specific file, if it has a sidecar.
|
||||
// This minimizes API calls to ONLY when we have a sidecar candidate.
|
||||
// Batch Optimized Check
|
||||
if (videoStatusMap.has(d.file.getId())) {
|
||||
const meta = videoStatusMap.get(d.file.getId());
|
||||
// Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid..
|
||||
// Check `videoMediaMetadata.width` to ensure processing is complete (width is often missing during processing)
|
||||
if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
|
||||
// SUCCESS: Drive has finished processing.
|
||||
nativeThumbReady = true;
|
||||
console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`);
|
||||
|
||||
// Cleanup Sidecar
|
||||
const sidecarId = d.customThumbnailId; // Direct lookup from properties
|
||||
if (sidecarId) {
|
||||
try {
|
||||
this.driveService.trashFile(sidecarId);
|
||||
sidecarFileIds.delete(sidecarId);
|
||||
sidecarThumbMap.delete(d.file.getId());
|
||||
console.log(`[MediaService] Trashed sidecar ${sidecarId}`);
|
||||
} catch (trashErr) {
|
||||
console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore individual file errors
|
||||
}
|
||||
|
||||
// 1. Check Sidecar (If it still exists after potential cleanup)
|
||||
if (sidecarThumbMap.has(d.file.getId())) {
|
||||
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
|
||||
thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
|
||||
isProcessing = true; // SHOW HOURGLASS (Request #3)
|
||||
} else if (match && (
|
||||
match.status === 'PROCESSING' ||
|
||||
match.status === 'UPLOADED' ||
|
||||
(match.mediaContentType === 'VIDEO' && (!match.sources || match.sources.length === 0) && match.status !== 'FAILED')
|
||||
)) {
|
||||
// Shopify Processing (Explicit Status OR Ready-but-missing-sources)
|
||||
console.log(`[MediaService] Shopify Media is Processing: ${d.file.getName()} (Status: ${match.status}, Sources: ${match.sources ? match.sources.length : 0})`);
|
||||
isProcessing = true;
|
||||
// Use Drive thumb as fallback if Shopify preview not ready
|
||||
if (!thumbnail) {
|
||||
try {
|
||||
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
|
||||
if (nativeThumb.length > 100) thumbnail = nativeThumb;
|
||||
} catch(e) { /* ignore thumbnail generation error */ }
|
||||
}
|
||||
} else {
|
||||
// 2. Native / Fallback
|
||||
try {
|
||||
// Try to get Drive thumbnail
|
||||
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
|
||||
if (nativeThumb.length > 100) { // Check if valid (sometimes returns empty?)
|
||||
thumbnail = nativeThumb;
|
||||
}
|
||||
} catch (e) {
|
||||
// Processing / Error
|
||||
console.warn(`Failed to get native thumbnail for ${d.file.getName()}: ${e}`);
|
||||
isProcessing = true; // Assume processing
|
||||
thumbnail = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNDgiIHdpZHRoPSI0OCIgdmlld0JveD0iMCAwIDQ4IDQ4Ij48cGF0aCBmaWxsPSIjNDI4NUY0IiBkPSJNMzYgOEgxMmMtMi4yMSAwLTQgMS43OS00IDR2MjRjMCAyLjIxIDEuNzkgNCA0IDRoMjRjMi4yMSAwIDQtMS43OSA0LTRWMTJjMC0yLjIxLTEuNzktNC00LTR6TTIwIDMxVjE3bDEyIDctMTIgN3oiLz48L3N2Zz4=";
|
||||
// Ignore individual file errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unifiedState.push({
|
||||
id: d.file.getId(), // Use Drive ID as primary key
|
||||
driveId: d.file.getId(),
|
||||
shopifyId: match ? match.id : null,
|
||||
filename: d.file.getName(),
|
||||
source: match ? 'synced' : 'drive_only',
|
||||
thumbnail: thumbnail,
|
||||
status: 'active',
|
||||
galleryOrder: d.galleryOrder,
|
||||
mimeType: d.file.getMimeType(),
|
||||
// Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL
|
||||
contentUrl: (match && match.sources)
|
||||
? (match.sources.find((s: any) => s.mimeType === 'video/mp4')?.url || match.sources[0]?.url)
|
||||
: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`,
|
||||
isProcessing: isProcessing
|
||||
// 1. Check Sidecar (If it still exists after potential cleanup)
|
||||
if (sidecarThumbMap.has(d.file.getId())) {
|
||||
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
|
||||
thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
|
||||
isProcessing = true; // SHOW HOURGLASS (Request #3)
|
||||
} else if (match && (
|
||||
match.status === 'PROCESSING' ||
|
||||
match.status === 'UPLOADED' ||
|
||||
(match.mediaContentType === 'VIDEO' && (!match.sources || match.sources.length === 0) && match.status !== 'FAILED')
|
||||
)) {
|
||||
// Shopify Processing (Explicit Status OR Ready-but-missing-sources)
|
||||
console.log(`[MediaService] Shopify Media is Processing: ${d.file.getName()} (Status: ${match.status}, Sources: ${match.sources ? match.sources.length : 0})`);
|
||||
isProcessing = true;
|
||||
// Use Drive thumb as fallback if Shopify preview not ready
|
||||
if (!thumbnail) {
|
||||
try {
|
||||
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
|
||||
if (nativeThumb.length > 100) thumbnail = nativeThumb;
|
||||
} catch(e) { /* ignore thumbnail generation error */ }
|
||||
}
|
||||
} else {
|
||||
// 2. Native / Fallback
|
||||
try {
|
||||
// Try to get Drive thumbnail
|
||||
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
|
||||
if (nativeThumb.length > 100) { // Check if valid (sometimes returns empty?)
|
||||
thumbnail = nativeThumb;
|
||||
}
|
||||
} catch (e) {
|
||||
// Processing / Error
|
||||
console.warn(`Failed to get native thumbnail for ${d.file.getName()}: ${e}`);
|
||||
isProcessing = true; // Assume processing
|
||||
thumbnail = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNDgiIHdpZHRoPSI0OCIgdmlld0JveD0iMCAwIDQ4IDQ4Ij48cGF0aCBmaWxsPSIjNDI4NUY0IiBkPSJNMzYgOEgxMmMtMi4yMSAwLTQgMS43OS00IDR2MjRjMCAyLjIxIDEuNzkgNCA0IDRoMjRjMi4yMSAwIDQtMS43OSA0LTRWMTJjMC0yLjIxLTEuNzktNC00LTR6TTIwIDMxVjE3bDEyIDctMTIgN3oiLz48L3N2Zz4=";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unifiedState.push({
|
||||
id: d.file.getId(), // Use Drive ID as primary key
|
||||
driveId: d.file.getId(),
|
||||
shopifyId: match ? match.id : null,
|
||||
filename: d.file.getName(),
|
||||
source: match ? 'synced' : 'drive_only',
|
||||
thumbnail: thumbnail,
|
||||
status: 'active',
|
||||
galleryOrder: d.galleryOrder,
|
||||
mimeType: d.file.getMimeType(),
|
||||
// Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL
|
||||
contentUrl: (match && match.sources)
|
||||
? (match.sources.find((s: any) => s.mimeType === 'video/mp4')?.url || match.sources[0]?.url)
|
||||
: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`,
|
||||
isProcessing: isProcessing
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Skip Thumbnails Logic (Fast Path)
|
||||
driveFileStats.forEach(d => {
|
||||
// Minimal State for Planning
|
||||
let match = null
|
||||
if (d.shopifyId) {
|
||||
match = shopifyMedia.find(m => m.id === d.shopifyId)
|
||||
if (match) matchedShopifyIds.add(match.id)
|
||||
}
|
||||
|
||||
unifiedState.push({
|
||||
id: d.file.getId(),
|
||||
driveId: d.file.getId(),
|
||||
shopifyId: match ? match.id : null,
|
||||
filename: d.file.getName(),
|
||||
source: match ? 'synced' : 'drive_only',
|
||||
thumbnail: "", // Skipped
|
||||
status: 'active',
|
||||
galleryOrder: d.galleryOrder,
|
||||
mimeType: d.file.getMimeType(),
|
||||
contentUrl: "", // Skipped
|
||||
isProcessing: false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Find Shopify Orphans
|
||||
shopifyMedia.forEach(m => {
|
||||
@ -403,7 +440,7 @@ export class MediaService {
|
||||
|
||||
calculatePlan(sku: string, finalState: any[], shopifyProductId: string) {
|
||||
// 1. Get Current State
|
||||
const currentState = this.getUnifiedMediaState(sku, shopifyProductId);
|
||||
const currentState = this.getUnifiedMediaState(sku, shopifyProductId, undefined, true);
|
||||
const finalIds = new Set(finalState.map(f => f.id));
|
||||
|
||||
// 2. Identify Deletions
|
||||
|
||||
@ -33,6 +33,22 @@ export class MockShopifyMediaService implements IShopifyMediaService {
|
||||
return []
|
||||
}
|
||||
|
||||
getProduct(productId: string): any {
|
||||
return {
|
||||
id: productId,
|
||||
title: "Mock Product",
|
||||
handle: "mock-product",
|
||||
onlineStoreUrl: "https://mock-shop.myshopify.com/products/mock-product"
|
||||
}
|
||||
}
|
||||
|
||||
getProductWithMedia(productId: string): any {
|
||||
return {
|
||||
product: this.getProduct(productId),
|
||||
media: this.getProductMedia(productId)
|
||||
};
|
||||
}
|
||||
|
||||
productDeleteMedia(productId: string, mediaId: string): any {
|
||||
return {
|
||||
productDeleteMedia: {
|
||||
|
||||
@ -106,6 +106,80 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
||||
return response.content.data.product.media.edges.map((edge: any) => edge.node)
|
||||
}
|
||||
|
||||
getProduct(productId: string): any {
|
||||
const query = /* GraphQL */ `
|
||||
query getProduct($productId: ID!) {
|
||||
product(id: $productId) {
|
||||
id
|
||||
title
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
}
|
||||
`
|
||||
const variables = { productId }
|
||||
const payload = buildGqlQuery(query, variables)
|
||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||
if (!response || !response.content || !response.content.data || !response.content.data.product) {
|
||||
console.warn("getProduct: Product not found or access denied for ID:", productId);
|
||||
return null;
|
||||
}
|
||||
return response.content.data.product
|
||||
}
|
||||
|
||||
getProductWithMedia(productId: string): any {
|
||||
const query = /* GraphQL */ `
|
||||
query getProductWithMedia($productId: ID!) {
|
||||
product(id: $productId) {
|
||||
id
|
||||
title
|
||||
handle
|
||||
onlineStoreUrl
|
||||
media(first: 250) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
alt
|
||||
mediaContentType
|
||||
status
|
||||
preview {
|
||||
image {
|
||||
originalSrc
|
||||
}
|
||||
}
|
||||
... on Video {
|
||||
sources {
|
||||
url
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
... on MediaImage {
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const variables = { productId }
|
||||
const payload = buildGqlQuery(query, variables)
|
||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||
if (!response || !response.content || !response.content.data || !response.content.data.product) {
|
||||
console.warn("getProductWithMedia: Product not found or access denied for ID:", productId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize return structure to match expectations
|
||||
const p = response.content.data.product;
|
||||
return {
|
||||
product: { id: p.id, title: p.title, handle: p.handle, onlineStoreUrl: p.onlineStoreUrl },
|
||||
media: p.media.edges.map((edge: any) => edge.node)
|
||||
};
|
||||
}
|
||||
|
||||
productDeleteMedia(productId: string, mediaId: string): any {
|
||||
const query = /* GraphQL */ `
|
||||
mutation productDeleteMedia($mediaIds: [ID!]!, $productId: ID!) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user