Compare commits
8 Commits
thumbnails
...
dc33390650
| Author | SHA1 | Date | |
|---|---|---|---|
| dc33390650 | |||
| f25fb359e8 | |||
| 64ab548593 | |||
| 772957058d | |||
| 16dec5e888 | |||
| ec6602cbde | |||
| f1ab3b7b84 | |||
| ebc1a39ce3 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,3 +4,5 @@ desktop.ini
|
|||||||
.continue/**
|
.continue/**
|
||||||
.clasp.json
|
.clasp.json
|
||||||
coverage/
|
coverage/
|
||||||
|
test_*.txt
|
||||||
|
.agent/
|
||||||
|
|||||||
@ -46,3 +46,6 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
|
|||||||
- **Client-Side Syntax**:
|
- **Client-Side Syntax**:
|
||||||
- **ES5 ONLY**: Do not use `class` in client-side HTML files. The Apps Script sanitizer often fails to parse them. Use `function` constructors.
|
- **ES5 ONLY**: Do not use `class` in client-side HTML files. The Apps Script sanitizer often fails to parse them. Use `function` constructors.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- **Test Output**: When running tests, use `npm run test:log` to capture full output to `test_output.txt`. This avoids terminal truncation and allows agents to read the full results without manual redirection.
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"deploy": "clasp push",
|
"deploy": "clasp push",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
"test:log": "jest > test_output.txt 2>&1",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -464,7 +464,7 @@
|
|||||||
style="display:none; background-color:#fffbeb; color:#92400e; padding:12px; border-radius:8px; margin: 0 16px 12px 16px; font-size:13px; border:1px solid #fcd34d; align-items:flex-start; gap:8px;">
|
style="display:none; background-color:#fffbeb; color:#92400e; padding:12px; border-radius:8px; margin: 0 16px 12px 16px; font-size:13px; border:1px solid #fcd34d; align-items:flex-start; gap:8px;">
|
||||||
<span style="font-size:16px; line-height:1;">⏳</span>
|
<span style="font-size:16px; line-height:1;">⏳</span>
|
||||||
<div>
|
<div>
|
||||||
Some videos are still being transcoded by Drive. The video preview might not work yet, but they can still be saved,
|
Some videos are still being processed. The video preview might not work yet, but they can still be saved,
|
||||||
reordered, or deleted.
|
reordered, or deleted.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1516,6 +1516,8 @@
|
|||||||
console.log("[MediaManager] Processing complete for " + item.filename);
|
console.log("[MediaManager] Processing complete for " + item.filename);
|
||||||
item.isProcessing = false;
|
item.isProcessing = false;
|
||||||
item.thumbnail = newItem.thumbnail;
|
item.thumbnail = newItem.thumbnail;
|
||||||
|
item.contentUrl = newItem.contentUrl; // Propagate URL
|
||||||
|
item.source = newItem.source; // Propagate source update (synced)
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,8 +23,16 @@ jest.mock("./config", () => {
|
|||||||
jest.mock("./services/GASNetworkService")
|
jest.mock("./services/GASNetworkService")
|
||||||
jest.mock("./services/ShopifyMediaService")
|
jest.mock("./services/ShopifyMediaService")
|
||||||
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
|
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
|
||||||
jest.mock("./services/MediaService")
|
jest.mock("./services/MediaService", () => {
|
||||||
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) }))
|
return {
|
||||||
|
MediaService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
getUnifiedMediaState: jest.fn().mockReturnValue([]),
|
||||||
|
processMediaChanges: jest.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
// Mock GASDriveService
|
// Mock GASDriveService
|
||||||
@ -49,11 +57,31 @@ jest.mock("./services/GASSpreadsheetService", () => {
|
|||||||
GASSpreadsheetService: jest.fn().mockImplementation(() => {
|
GASSpreadsheetService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => {
|
getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => {
|
||||||
|
// console.log(`Mock GASSpreadsheetService getCellValueByColumnName called: ${col}`);
|
||||||
if (col === "sku") return "TEST-SKU"
|
if (col === "sku") return "TEST-SKU"
|
||||||
if (col === "title") return "Test Product Title"
|
if (col === "title") return "Test Product Title"
|
||||||
return null
|
return null
|
||||||
|
}),
|
||||||
|
getRowNumberByColumnValue: jest.fn().mockReturnValue(5),
|
||||||
|
setCellValueByColumnName: jest.fn(),
|
||||||
|
getHeaders: jest.fn().mockReturnValue(["sku", "title", "thumbnail"]),
|
||||||
|
getRowData: jest.fn()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock Product
|
||||||
|
jest.mock("./Product", () => {
|
||||||
|
return {
|
||||||
|
Product: jest.fn().mockImplementation((sku) => {
|
||||||
|
return {
|
||||||
|
sku: sku,
|
||||||
|
shopify_id: "shopify_id_123",
|
||||||
|
title: "Test Product Title",
|
||||||
|
MatchToShopifyProduct: jest.fn(),
|
||||||
|
ImportFromInventory: jest.fn()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -88,7 +116,13 @@ global.SpreadsheetApp = {
|
|||||||
getName: jest.fn().mockReturnValue("product_inventory"),
|
getName: jest.fn().mockReturnValue("product_inventory"),
|
||||||
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 })
|
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 })
|
||||||
}),
|
}),
|
||||||
getActive: jest.fn()
|
getActive: jest.fn(),
|
||||||
|
newCellImage: jest.fn().mockReturnValue({
|
||||||
|
setSourceUrl: jest.fn().mockReturnThis(),
|
||||||
|
setAltTextTitle: jest.fn().mockReturnThis(),
|
||||||
|
setAltTextDescription: jest.fn().mockReturnThis(),
|
||||||
|
build: jest.fn().mockReturnValue("CELL_IMAGE_OBJECT")
|
||||||
|
})
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
// UrlFetchApp
|
// UrlFetchApp
|
||||||
@ -132,10 +166,17 @@ global.Session = {
|
|||||||
global.HtmlService = {
|
global.HtmlService = {
|
||||||
createHtmlOutputFromFile: jest.fn().mockReturnValue({
|
createHtmlOutputFromFile: jest.fn().mockReturnValue({
|
||||||
setTitle: jest.fn().mockReturnThis(),
|
setTitle: jest.fn().mockReturnThis(),
|
||||||
setWidth: jest.fn().mockReturnThis()
|
setWidth: jest.fn().mockReturnThis(),
|
||||||
|
setHeight: jest.fn().mockReturnThis()
|
||||||
})
|
})
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
|
// MimeType
|
||||||
|
global.MimeType = {
|
||||||
|
JPEG: "image/jpeg",
|
||||||
|
PNG: "image/png"
|
||||||
|
} as any
|
||||||
|
|
||||||
|
|
||||||
describe("mediaHandlers", () => {
|
describe("mediaHandlers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -249,7 +290,8 @@ describe("mediaHandlers", () => {
|
|||||||
|
|
||||||
// Get the instance that was created
|
// Get the instance that was created
|
||||||
const MockMediaService = MediaService as unknown as jest.Mock
|
const MockMediaService = MediaService as unknown as jest.Mock
|
||||||
const mockInstance = MockMediaService.mock.instances[MockMediaService.mock.instances.length - 1]
|
expect(MockMediaService).toHaveBeenCalled()
|
||||||
|
const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
|
||||||
|
|
||||||
// Checking delegation
|
// Checking delegation
|
||||||
expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", expect.anything())
|
expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", expect.anything())
|
||||||
@ -263,16 +305,56 @@ describe("mediaHandlers", () => {
|
|||||||
saveMediaChanges("SKU123", finalState)
|
saveMediaChanges("SKU123", finalState)
|
||||||
|
|
||||||
const MockMediaService = MediaService as unknown as jest.Mock
|
const MockMediaService = MediaService as unknown as jest.Mock
|
||||||
const mockInstance = MockMediaService.mock.instances[MockMediaService.mock.instances.length - 1]
|
const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
|
||||||
|
|
||||||
expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything())
|
expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything())
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should throw if product not synced", () => {
|
test("should throw if product not synced", () => {
|
||||||
const { Product } = require("./Product")
|
const MockProduct = Product as unknown as jest.Mock
|
||||||
Product.mockImplementationOnce(() => ({ shopify_id: null, MatchToShopifyProduct: jest.fn() }))
|
MockProduct.mockImplementationOnce(() => ({
|
||||||
|
shopify_id: null,
|
||||||
|
MatchToShopifyProduct: jest.fn(),
|
||||||
|
ImportFromInventory: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced")
|
expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should update sheet thumbnail with first image", () => {
|
||||||
|
// Setup mock MediaService to NOT throw and just return logs
|
||||||
|
const MockMediaService = MediaService as unknown as jest.Mock
|
||||||
|
const mockGetUnifiedMediaState = jest.fn().mockReturnValue([
|
||||||
|
{ id: "2", driveId: "drive_file_2", galleryOrder: 1, contentUrl: "https://cdn.shopify.com/test.jpg", thumbnail: "https://cdn.shopify.com/test.jpg" }
|
||||||
|
])
|
||||||
|
MockMediaService.mockImplementation(() => ({
|
||||||
|
processMediaChanges: jest.fn().mockReturnValue(["Log 1"]),
|
||||||
|
getUnifiedMediaState: mockGetUnifiedMediaState
|
||||||
|
}))
|
||||||
|
|
||||||
|
const finalState = [
|
||||||
|
{ id: "1", driveId: "drive_file_1", galleryOrder: 10 },
|
||||||
|
{ id: "2", driveId: "drive_file_2", galleryOrder: 1 } // Should be first
|
||||||
|
]
|
||||||
|
|
||||||
|
const logs = saveMediaChanges("TEST-SKU", finalState)
|
||||||
|
|
||||||
|
expect(logs).toEqual(expect.arrayContaining([
|
||||||
|
expect.stringContaining("Updated sheet thumbnail")
|
||||||
|
]))
|
||||||
|
|
||||||
|
// Verify spreadsheet service interaction
|
||||||
|
const MockSpreadsheet = GASSpreadsheetService as unknown as jest.Mock
|
||||||
|
expect(MockSpreadsheet).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const mockSS = MockSpreadsheet.mock.results[MockSpreadsheet.mock.results.length - 1].value
|
||||||
|
expect(mockSS.setCellValueByColumnName).toHaveBeenCalledWith(
|
||||||
|
"product_inventory",
|
||||||
|
5,
|
||||||
|
"thumbnail",
|
||||||
|
"CELL_IMAGE_OBJECT"
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Photo Session API", () => {
|
describe("Photo Session API", () => {
|
||||||
|
|||||||
@ -84,7 +84,54 @@ export function saveMediaChanges(sku: string, finalState: any[]) {
|
|||||||
throw new Error("Product must be synced to Shopify before saving media changes.")
|
throw new Error("Product must be synced to Shopify before saving media changes.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return mediaService.processMediaChanges(sku, finalState, product.shopify_id)
|
const logs = mediaService.processMediaChanges(sku, finalState, product.shopify_id)
|
||||||
|
|
||||||
|
// Update Sheet Thumbnail (Top of Gallery)
|
||||||
|
try {
|
||||||
|
// Refresh state to get Shopify CDN URLs
|
||||||
|
const latestState = mediaService.getUnifiedMediaState(sku, product.shopify_id);
|
||||||
|
const sorted = latestState.sort((a, b) => (a.galleryOrder || 0) - (b.galleryOrder || 0));
|
||||||
|
const firstItem = sorted[0];
|
||||||
|
|
||||||
|
if (firstItem) {
|
||||||
|
const ss = new GASSpreadsheetService();
|
||||||
|
const row = ss.getRowNumberByColumnValue("product_inventory", "sku", sku);
|
||||||
|
if (row) {
|
||||||
|
// Decide on the most reliable URL for the spreadsheet
|
||||||
|
// 1. If it's a synced Shopify item, use the Shopify preview image URL (public)
|
||||||
|
// 2. Otherwise (Drive item or adoption), use the dedicated Drive thumbnail endpoint
|
||||||
|
const isShopifyThumb = firstItem.thumbnail && firstItem.thumbnail.startsWith('http');
|
||||||
|
const driveThumbUrl = `https://drive.google.com/thumbnail?id=${firstItem.driveId}&sz=w400`;
|
||||||
|
const thumbUrl = isShopifyThumb ? firstItem.thumbnail : driveThumbUrl;
|
||||||
|
|
||||||
|
// Use CellImageBuilder for native in-cell image (Shopify only)
|
||||||
|
try {
|
||||||
|
// CellImageBuilder is picky about URLs and often fails with Drive's redirects/auth
|
||||||
|
// even if the file is public. Formula-based IMAGE() is more robust for Drive.
|
||||||
|
if (!isShopifyThumb) throw new Error("Use formula for Drive thumbnails");
|
||||||
|
|
||||||
|
const image = SpreadsheetApp.newCellImage()
|
||||||
|
.setSourceUrl(thumbUrl)
|
||||||
|
.setAltTextTitle(sku)
|
||||||
|
.setAltTextDescription(`Thumbnail for ${sku}`)
|
||||||
|
.build();
|
||||||
|
ss.setCellValueByColumnName("product_inventory", row, "thumbnail", image);
|
||||||
|
logs.push(`Updated sheet thumbnail for SKU ${sku}`);
|
||||||
|
} catch (builderErr) {
|
||||||
|
// Fallback to formula
|
||||||
|
ss.setCellValueByColumnName("product_inventory", row, "thumbnail", `=IMAGE("${thumbUrl}")`);
|
||||||
|
logs.push(`Updated sheet thumbnail (Formula) for SKU ${sku}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logs.push(`Warning: Could not find row for SKU ${sku} to update thumbnail.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to update sheet thumbnail", e);
|
||||||
|
logs.push(`Warning: Failed to update sheet thumbnail: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -304,4 +304,29 @@ describe("MediaService Robust Sync", () => {
|
|||||||
expect(item.isProcessing).toBe(true)
|
expect(item.isProcessing).toBe(true)
|
||||||
expect(item.thumbnail).toContain("data:image/svg+xml;base64")
|
expect(item.thumbnail).toContain("data:image/svg+xml;base64")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Processing: Marks item as processing if Shopify status is PROCESSING", () => {
|
||||||
|
const folder = driveService.getOrCreateFolder("SKU_SHOP_PROCESS", "root")
|
||||||
|
|
||||||
|
// Drive File
|
||||||
|
const blob = { getName: () => "vid.mp4", getBytes: () => [], getMimeType: () => "video/mp4", getThumbnail: () => ({ getBytes: () => [] }) } as any
|
||||||
|
const f = driveService.saveFile(blob, folder.getId())
|
||||||
|
driveService.updateFileProperties(f.getId(), { shopify_media_id: "gid://shopify/Media/Proc1" })
|
||||||
|
|
||||||
|
// Shopify Media (Processing)
|
||||||
|
shopifyService.getProductMedia = jest.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
id: "gid://shopify/Media/Proc1",
|
||||||
|
filename: "vid.mp4",
|
||||||
|
mediaContentType: "VIDEO",
|
||||||
|
status: "PROCESSING",
|
||||||
|
preview: { image: { originalSrc: null } } // Preview might be missing during processing
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const state = mediaService.getUnifiedMediaState("SKU_SHOP_PROCESS", "pid")
|
||||||
|
const item = state.find(s => s.id === f.getId())
|
||||||
|
|
||||||
|
expect(item.isProcessing).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -106,6 +106,8 @@ export class MediaService {
|
|||||||
if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id'];
|
if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id'];
|
||||||
if (props['parent_video_id']) parentVideoId = props['parent_video_id'];
|
if (props['parent_video_id']) parentVideoId = props['parent_video_id'];
|
||||||
|
|
||||||
|
console.log(`[DEBUG] File ${f.getName()} Props:`, JSON.stringify(props));
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Failed to get properties for ${f.getName()}`)
|
console.warn(`Failed to get properties for ${f.getName()}`)
|
||||||
}
|
}
|
||||||
@ -239,6 +241,21 @@ export class MediaService {
|
|||||||
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
|
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
|
||||||
thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
|
thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
|
||||||
isProcessing = true; // SHOW HOURGLASS (Request #3)
|
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) {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 2. Native / Fallback
|
// 2. Native / Fallback
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -73,6 +73,7 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
|||||||
id
|
id
|
||||||
alt
|
alt
|
||||||
mediaContentType
|
mediaContentType
|
||||||
|
status
|
||||||
preview {
|
preview {
|
||||||
image {
|
image {
|
||||||
originalSrc
|
originalSrc
|
||||||
|
|||||||
BIN
test_output.txt
BIN
test_output.txt
Binary file not shown.
Reference in New Issue
Block a user