Compare commits
5 Commits
243f7057b7
...
55d18138b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 55d18138b7 | |||
| 945fb610f9 | |||
| d67897aa17 | |||
| c738ab3ef7 | |||
| d9d884e1fc |
@ -40,5 +40,9 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
|
|||||||
1. Sanitize with `Utilities.newBlob()`.
|
1. Sanitize with `Utilities.newBlob()`.
|
||||||
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.
|
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.
|
||||||
- **Video Previews**:
|
- **Video Previews**:
|
||||||
- HTML5 `<video>` tags often fail with standard Drive download URLs due to auth/codec issues.
|
- **Video Previews**:
|
||||||
- **Strategy**: Use an `<iframe>` embedding the `https://drive.google.com/file/d/{ID}/preview` URL. This leverages Google's native player for reliable auth and transcoding.
|
- Use `document.createElement('video')` to inject video tags. Avoid template strings (`<video src="...">`) as the parser sanitizes them aggressively.
|
||||||
|
- Fallback to `<iframe>` only if native playback fails.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@ -141,3 +141,19 @@ We implemented a "Sidebar-First" architecture for product media to handle the co
|
|||||||
- Calculates checksums to avoid re-uploading duplicate images.
|
- Calculates checksums to avoid re-uploading duplicate images.
|
||||||
- Uses Shopify's "Staged Uploads" -> "Create Media" mutation flow.
|
- Uses Shopify's "Staged Uploads" -> "Create Media" mutation flow.
|
||||||
|
|
||||||
|
### 8. Apps Script & HTML Service Constraints
|
||||||
|
|
||||||
|
When working with `HtmlService` (client-side code), the environment differs significantly from the server-side V8 runtime.
|
||||||
|
|
||||||
|
1. **Server-Side (`.ts`/`.gs`)**:
|
||||||
|
- **Runtime**: V8 Engine.
|
||||||
|
- **Syntax**: Modern ES6+ (Classes, Arrow Functions, `const`/`let`) is fully supported.
|
||||||
|
- **Recommendation**: Use standard TypeScript patterns.
|
||||||
|
|
||||||
|
2. **Client-Side (`.html` served via `createHtmlOutputFromFile`)**:
|
||||||
|
- **Runtime**: Legacy Browser Environment / Strict Caja Sanitization.
|
||||||
|
- **Constraint**: The parser often chokes on ES6 `class` syntax and complex template strings inside HTML attributes.
|
||||||
|
- **Rule 1**: **NO ES6 CLASSES**. Use ES5 `function` constructors and `prototype` methods.
|
||||||
|
- **Rule 2**: **NO Complex Template Strings in Attributes**. Do not use `src="${var}"` if the variable contains a URL. Use `document.createElement` and set properties (e.g., `element.src = value`) programmatically.
|
||||||
|
- **Rule 3**: **Unified Script Tags**. Consolidate scripts into a single block where possible to avoid parser merge errors.
|
||||||
|
- **Rule 4**: **Var over Let/Const**. Top-level variables should use `var` or explicit `window` assignment to ensure they are accessible to inline HTML handlers (e.g., `onclick="handler()"`).
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
|||||||
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
|
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
|
||||||
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
|
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
|
||||||
import { installSalesSyncTrigger } from "./triggers"
|
import { installSalesSyncTrigger } from "./triggers"
|
||||||
import { showMediaManager, getSelectedSku, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers"
|
import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers"
|
||||||
import { runSystemDiagnostics } from "./verificationSuite"
|
import { runSystemDiagnostics } from "./verificationSuite"
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
@ -52,7 +52,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
|
|||||||
;(global as any).reconcileSalesHandler = reconcileSalesHandler
|
;(global as any).reconcileSalesHandler = reconcileSalesHandler
|
||||||
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
|
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
|
||||||
;(global as any).showMediaManager = showMediaManager
|
;(global as any).showMediaManager = showMediaManager
|
||||||
;(global as any).getSelectedSku = getSelectedSku
|
;(global as any).getSelectedProductInfo = getSelectedProductInfo
|
||||||
;(global as any).getMediaForSku = getMediaForSku
|
;(global as any).getMediaForSku = getMediaForSku
|
||||||
;(global as any).saveFileToDrive = saveFileToDrive
|
;(global as any).saveFileToDrive = saveFileToDrive
|
||||||
;(global as any).saveMediaChanges = saveMediaChanges
|
;(global as any).saveMediaChanges = saveMediaChanges
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedSku, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers"
|
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { GASDriveService } from "./services/GASDriveService"
|
import { GASDriveService } from "./services/GASDriveService"
|
||||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||||
@ -47,7 +47,11 @@ jest.mock("./services/GASSpreadsheetService", () => {
|
|||||||
return {
|
return {
|
||||||
GASSpreadsheetService: jest.fn().mockImplementation(() => {
|
GASSpreadsheetService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
getCellValueByColumnName: jest.fn().mockReturnValue("TEST-SKU")
|
getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => {
|
||||||
|
if (col === "sku") return "TEST-SKU"
|
||||||
|
if (col === "title") return "Test Product Title"
|
||||||
|
return null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -336,9 +340,9 @@ describe("mediaHandlers", () => {
|
|||||||
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
|
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("getSelectedSku should return sku from sheet", () => {
|
test("getSelectedProductInfo should return sku and title from sheet", () => {
|
||||||
const sku = getSelectedSku()
|
const info = getSelectedProductInfo()
|
||||||
expect(sku).toBe("TEST-SKU")
|
expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("getPickerConfig should return config", () => {
|
test("getPickerConfig should return config", () => {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function showMediaManager() {
|
|||||||
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
|
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectedSku(): string | null {
|
export function getSelectedProductInfo(): { sku: string, title: string } | null {
|
||||||
const ss = new GASSpreadsheetService()
|
const ss = new GASSpreadsheetService()
|
||||||
const sheet = SpreadsheetApp.getActiveSheet()
|
const sheet = SpreadsheetApp.getActiveSheet()
|
||||||
if (sheet.getName() !== "product_inventory") return null
|
if (sheet.getName() !== "product_inventory") return null
|
||||||
@ -24,7 +24,9 @@ export function getSelectedSku(): string | null {
|
|||||||
if (row <= 1) return null // Header
|
if (row <= 1) return null // Header
|
||||||
|
|
||||||
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
|
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
|
||||||
return sku ? String(sku) : null
|
const title = ss.getCellValueByColumnName("product_inventory", row, "title")
|
||||||
|
|
||||||
|
return sku ? { sku: String(sku), title: String(title || "") } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPickerConfig() {
|
export function getPickerConfig() {
|
||||||
@ -105,7 +107,13 @@ export function getMediaDiagnostics(sku: string) {
|
|||||||
|
|
||||||
const shopifyId = product.shopify_id || ""
|
const shopifyId = product.shopify_id || ""
|
||||||
|
|
||||||
return mediaService.getDiagnostics(sku, shopifyId)
|
const diagnostics = mediaService.getDiagnostics(sku, shopifyId)
|
||||||
|
|
||||||
|
// Inject OAuth token for frontend video streaming (Drive API alt=media)
|
||||||
|
return {
|
||||||
|
...diagnostics,
|
||||||
|
token: ScriptApp.getOAuthToken()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
||||||
|
|||||||
@ -139,6 +139,20 @@ export class MediaService {
|
|||||||
// Find Shopify Orphans
|
// Find Shopify Orphans
|
||||||
shopifyMedia.forEach(m => {
|
shopifyMedia.forEach(m => {
|
||||||
if (!matchedShopifyIds.has(m.id)) {
|
if (!matchedShopifyIds.has(m.id)) {
|
||||||
|
let mimeType = 'image/jpeg'; // Default
|
||||||
|
let contentUrl = "";
|
||||||
|
|
||||||
|
if (m.mediaContentType === 'VIDEO' && m.sources) {
|
||||||
|
// Find MP4
|
||||||
|
const mp4 = m.sources.find((s: any) => s.mimeType === 'video/mp4')
|
||||||
|
if (mp4) {
|
||||||
|
mimeType = mp4.mimeType
|
||||||
|
contentUrl = mp4.url
|
||||||
|
}
|
||||||
|
} else if (m.mediaContentType === 'IMAGE' && m.image) {
|
||||||
|
contentUrl = m.image.url
|
||||||
|
}
|
||||||
|
|
||||||
unifiedState.push({
|
unifiedState.push({
|
||||||
id: m.id, // Use Shopify ID keys for orphans
|
id: m.id, // Use Shopify ID keys for orphans
|
||||||
driveId: null,
|
driveId: null,
|
||||||
@ -148,7 +162,9 @@ export class MediaService {
|
|||||||
source: 'shopify_only',
|
source: 'shopify_only',
|
||||||
thumbnail: m.preview?.image?.originalSrc || "",
|
thumbnail: m.preview?.image?.originalSrc || "",
|
||||||
status: 'active',
|
status: 'active',
|
||||||
galleryOrder: 10000 // End of list
|
galleryOrder: 10000, // End of list
|
||||||
|
mimeType: mimeType,
|
||||||
|
contentUrl: contentUrl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -78,6 +78,17 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
|||||||
originalSrc
|
originalSrc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
... on Video {
|
||||||
|
sources {
|
||||||
|
url
|
||||||
|
mimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on MediaImage {
|
||||||
|
image {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user