Compare commits

...

8 Commits

Author SHA1 Message Date
dc33390650 Refine media state handling and fix CellImageBuilder errors
- Update MediaService delegation tests in src/mediaHandlers.test.ts to use mock.results for more reliable instance retrieval.
- Fix CellImageBuilder failure in src/mediaHandlers.ts by using public Shopify thumbnail URLs for synced items and direct Drive thumbnail endpoints for non-synced items.
- Fallback to IMAGE() formula in the spreadsheet for Drive items to avoid authentication issues with native cell images.
- Add test_*.txt to .gitignore to keep the workspace clean.
- Ensure all tests pass with updated log expectations and mock data.
2025-12-31 04:21:46 -07:00
f25fb359e8 Fix Shopify video previews and various improvements
- Ensure Shopify video sync updates Media Manager with active video previews
- Fix "Image load failed" error for video icons by using Base64 SVG
- Resolve Drive picker origin error by using google.script.host.origin
- Fix Drive video playback issues by using Drive iframe player
- Add `test:log` script to package.json for full output logging in Windows
- Update .gitignore to exclude coverage, test_output.txt, and .agent/
- Remove test_output.txt from git tracking
2025-12-31 01:10:18 -07:00
64ab548593 Fix Shopify video preview propagation on save
Updates logic to detect processing state (including READY-but-no-sources race condition) and propagates contentUrl updates to the frontend immediately.
2025-12-31 01:08:12 -07:00
772957058d Merge branch 'thumbnails-fix' 2025-12-31 00:15:55 -07:00
ben
16dec5e888 revert ebc1a39ce3
revert feat: Implement Server-Side Chunked Transfer for Drive Uploads

- Implemented 'Client-Orchestrated, Server-Side Chunked Transfer' to bypass CORS and 50MB limits for Google Photos.
- Added 'getResumableUploadUrl' to GASDriveService for high-priority video processing.
- Refactored 'MediaManager.html' to orchestrate uploads using 'transferRemoteChunk' loop.
- Added 'getRemoteFileSize' and 'transferRemoteChunk' to 'mediaHandlers.ts'.
- Updated 'global.ts' to expose new backend functions.
2025-12-31 00:14:52 -07:00
ben
ec6602cbde revert f1ab3b7b84
revert feat: Add custom video thumbnails for Drive uploads

- Implemented custom thumbnail injection in GASDriveService.getResumableUploadUrl.
- Fetches thumbnails from Google Photos using w320 size to avoid API limits.
- Added strict < 2MB size check for thumbnails.
- Updated mediaHandlers and MediaManager to pass sourceUrl to the backend.
- This allows Drive to display a visual cue immediately for video files still processing.
2025-12-31 00:14:38 -07:00
f1ab3b7b84 feat: Add custom video thumbnails for Drive uploads
- Implemented custom thumbnail injection in GASDriveService.getResumableUploadUrl.
- Fetches thumbnails from Google Photos using w320 size to avoid API limits.
- Added strict < 2MB size check for thumbnails.
- Updated mediaHandlers and MediaManager to pass sourceUrl to the backend.
- This allows Drive to display a visual cue immediately for video files still processing.
2025-12-30 00:38:57 -07:00
ebc1a39ce3 feat: Implement Server-Side Chunked Transfer for Drive Uploads
- Implemented 'Client-Orchestrated, Server-Side Chunked Transfer' to bypass CORS and 50MB limits for Google Photos.
- Added 'getResumableUploadUrl' to GASDriveService for high-priority video processing.
- Refactored 'MediaManager.html' to orchestrate uploads using 'transferRemoteChunk' loop.
- Added 'getRemoteFileSize' and 'transferRemoteChunk' to 'mediaHandlers.ts'.
- Updated 'global.ts' to expose new backend functions.
2025-12-29 22:08:21 -07:00
10 changed files with 192 additions and 12 deletions

4
.gitignore vendored
View File

@ -3,4 +3,6 @@ dist/**
desktop.ini desktop.ini
.continue/** .continue/**
.clasp.json .clasp.json
coverage/ coverage/
test_*.txt
.agent/

View File

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

View File

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

View File

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

View File

@ -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,10 +57,30 @@ 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", () => {

View File

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

View File

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

View File

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

View File

@ -73,6 +73,7 @@ export class ShopifyMediaService implements IShopifyMediaService {
id id
alt alt
mediaContentType mediaContentType
status
preview { preview {
image { image {
originalSrc originalSrc

Binary file not shown.