Compare commits

...

4 Commits

Author SHA1 Message Date
605a4488ac fix(ui): resolve overlap between media item badges and sticky action bar
- Added z-index: 20 to .action-bar to ensure it stays above absolute-positioned badges (z-index: 10).
- Added position: relative to list items in plan and match modals to correctly contain badges.
- Ensured action bar has solid background to cover scrolling content.
2026-01-04 10:54:14 -07:00
eeead33b2c feat: auto-create Draft Shopify products in Media Manager and fix description saving
- Implement auto-sync to Shopify: saving media for an unsynced product now creates it as a Draft in Shopify.
- Update Product.ts to default new items to DRAFT status.
- Allow getMediaSavePlan to run without a shopify_id (planning for new products).
- Fix description saving in mediaHandlers to reconcile 'body_html' and common variants.
- Sanitize empty quotes in MediaManager description textarea.
- Update mediaHandlers.test.ts to verify auto-creation behavior and fix mock pollution.
2026-01-03 12:01:55 -07:00
778c0d1620 Fix Media Manager UI bugs and add SKU migration logic
- Fix syntax errors and logic in MediaManager.html

- Fix SpreadsheetApp mocking in mediaHandlers.test.ts

- Add SKU logic migration plan and backfill script

- Update Product.ts and global.ts exports

- Update newSku.ts and add newSku.test.ts

- Ensure all tests pass (71/71)
2026-01-03 11:44:49 -07:00
f3d8514e62 Optimize Media Planning by skipping thumbnail generation
This change modifies the validation/planning phase to skip the expensive thumbnail generation step in 'getUnifiedMediaState'. Since the planning phase primarily needs file IDs and names to calculate deletions, adoptions, and reorders, skipping the thumbnail verification/retrieval (including sidecar checks) significantly reduces the latency of the 'Save Changes' operation.
2026-01-03 08:05:44 -07:00
17 changed files with 1319 additions and 261 deletions

View 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

View File

@ -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'; });

View File

@ -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
View 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.`)
}
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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"]);
})
})
})

View File

@ -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()

View File

@ -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
View 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()
})
})

View File

@ -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
}

View File

@ -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",

View File

@ -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

View File

@ -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: {

View File

@ -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!) {

View File

@ -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
}

View File

@ -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 {