Compare commits
13 Commits
243f7057b7
...
thumbnails
| Author | SHA1 | Date | |
|---|---|---|---|
| 690f8c5c38 | |||
| bade8a3020 | |||
| f6831cdc8f | |||
| 7ef5ef2913 | |||
| 4b156cb371 | |||
| d9fe81f282 | |||
| 19b3d5de2b | |||
| e5ce154175 | |||
| 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()`.
|
||||
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.
|
||||
- **Video Previews**:
|
||||
- HTML5 `<video>` tags often fail with standard Drive download URLs due to auth/codec issues.
|
||||
- **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.
|
||||
- **Video Previews**:
|
||||
- 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.
|
||||
- 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
@ -5,7 +5,7 @@
|
||||
{
|
||||
"userSymbol": "Drive",
|
||||
"serviceId": "drive",
|
||||
"version": "v2"
|
||||
"version": "v3"
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -18,6 +18,7 @@
|
||||
"https://www.googleapis.com/auth/script.scriptapp",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/photospicker.mediaitems.readonly"
|
||||
"https://www.googleapis.com/auth/photospicker.mediaitems.readonly",
|
||||
"https://www.googleapis.com/auth/drive.photos.readonly"
|
||||
]
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
||||
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
|
||||
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
|
||||
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, linkDriveFileToShopifyMedia } from "./mediaHandlers"
|
||||
import { runSystemDiagnostics } from "./verificationSuite"
|
||||
|
||||
// prettier-ignore
|
||||
@ -52,7 +52,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
|
||||
;(global as any).reconcileSalesHandler = reconcileSalesHandler
|
||||
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
|
||||
;(global as any).showMediaManager = showMediaManager
|
||||
;(global as any).getSelectedSku = getSelectedSku
|
||||
;(global as any).getSelectedProductInfo = getSelectedProductInfo
|
||||
;(global as any).getMediaForSku = getMediaForSku
|
||||
;(global as any).saveFileToDrive = saveFileToDrive
|
||||
;(global as any).saveMediaChanges = saveMediaChanges
|
||||
@ -64,3 +64,4 @@ import { runSystemDiagnostics } from "./verificationSuite"
|
||||
;(global as any).createPhotoSession = createPhotoSession
|
||||
;(global as any).checkPhotoSession = checkPhotoSession
|
||||
;(global as any).debugFolderAccess = debugFolderAccess
|
||||
;(global as any).linkDriveFileToShopifyMedia = linkDriveFileToShopifyMedia
|
||||
|
||||
@ -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 { GASDriveService } from "./services/GASDriveService"
|
||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||
@ -36,7 +36,8 @@ jest.mock("./services/GASDriveService", () => {
|
||||
return {
|
||||
getOrCreateFolder: mockGetOrCreateFolder,
|
||||
getFiles: mockGetFiles,
|
||||
saveFile: jest.fn()
|
||||
saveFile: jest.fn(),
|
||||
updateFileProperties: jest.fn()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -47,7 +48,11 @@ jest.mock("./services/GASSpreadsheetService", () => {
|
||||
return {
|
||||
GASSpreadsheetService: jest.fn().mockImplementation(() => {
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -59,7 +64,8 @@ const mockFile = {
|
||||
getName: jest.fn().mockReturnValue("photo.jpg"),
|
||||
moveTo: jest.fn(),
|
||||
getThumbnail: jest.fn().mockReturnValue({ getBytes: () => [] }),
|
||||
getMimeType: jest.fn().mockReturnValue("image/jpeg")
|
||||
getMimeType: jest.fn().mockReturnValue("image/jpeg"),
|
||||
setDescription: jest.fn()
|
||||
}
|
||||
|
||||
const mockFolder = {
|
||||
@ -153,7 +159,8 @@ describe("mediaHandlers", () => {
|
||||
getBlob: () => ({
|
||||
setName: jest.fn(),
|
||||
getContentType: () => "image/jpeg",
|
||||
getBytes: () => [1, 2, 3]
|
||||
getBytes: () => [1, 2, 3],
|
||||
getAs: jest.fn().mockReturnThis()
|
||||
}),
|
||||
getContentText: () => ""
|
||||
})
|
||||
@ -179,6 +186,22 @@ describe("mediaHandlers", () => {
|
||||
expect(mockFile.moveTo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should append =dv to video URLs from Google Photos", () => {
|
||||
importFromPicker("SKU123", null, "video/mp4", "video.mp4", "https://lh3.googleusercontent.com/some-id")
|
||||
expect(UrlFetchApp.fetch).toHaveBeenCalledWith(
|
||||
"https://lh3.googleusercontent.com/some-id=dv",
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
test("should append =d to image URLs from Google Photos", () => {
|
||||
importFromPicker("SKU123", null, "image/jpeg", "image.jpg", "https://lh3.googleusercontent.com/some-id")
|
||||
expect(UrlFetchApp.fetch).toHaveBeenCalledWith(
|
||||
"https://lh3.googleusercontent.com/some-id=d",
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
test("should handle 403 Forbidden on Download", () => {
|
||||
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
||||
getResponseCode: () => 403,
|
||||
@ -336,9 +359,9 @@ describe("mediaHandlers", () => {
|
||||
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
|
||||
})
|
||||
|
||||
test("getSelectedSku should return sku from sheet", () => {
|
||||
const sku = getSelectedSku()
|
||||
expect(sku).toBe("TEST-SKU")
|
||||
test("getSelectedProductInfo should return sku and title from sheet", () => {
|
||||
const info = getSelectedProductInfo()
|
||||
expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" })
|
||||
})
|
||||
|
||||
test("getPickerConfig should return config", () => {
|
||||
|
||||
@ -15,7 +15,7 @@ export function showMediaManager() {
|
||||
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
|
||||
}
|
||||
|
||||
export function getSelectedSku(): string | null {
|
||||
export function getSelectedProductInfo(): { sku: string, title: string } | null {
|
||||
const ss = new GASSpreadsheetService()
|
||||
const sheet = SpreadsheetApp.getActiveSheet()
|
||||
if (sheet.getName() !== "product_inventory") return null
|
||||
@ -24,7 +24,9 @@ export function getSelectedSku(): string | null {
|
||||
if (row <= 1) return null // Header
|
||||
|
||||
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() {
|
||||
@ -105,7 +107,24 @@ export function getMediaDiagnostics(sku: string) {
|
||||
|
||||
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 linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
|
||||
const config = new Config()
|
||||
const driveService = new GASDriveService()
|
||||
const shop = new Shop()
|
||||
const shopifyMediaService = new ShopifyMediaService(shop)
|
||||
const networkService = new GASNetworkService()
|
||||
const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config)
|
||||
|
||||
return mediaService.linkDriveFileToShopifyMedia(sku, driveId, shopifyId)
|
||||
}
|
||||
|
||||
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
||||
@ -133,82 +152,120 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
|
||||
|
||||
// STEP 1: Acquire/Create File in Root (Safe Zone)
|
||||
let finalFile: GoogleAppsScript.Drive.File;
|
||||
let sidecarThumbFile: GoogleAppsScript.Drive.File | null = null;
|
||||
|
||||
try {
|
||||
if (fileId && !imageUrl) {
|
||||
// Case A: Existing Drive File (Copy it)
|
||||
// Note: makeCopy(name) w/o folder argument copies to the same parent as original usually, or root?
|
||||
// Actually explicitly copying to Root is safer for "new" file.
|
||||
const source = DriveApp.getFileById(fileId);
|
||||
finalFile = source.makeCopy(name); // Default location
|
||||
console.log(`Step 1 Success: Drive File copied to Root/Default. ID: ${finalFile.getId()}`);
|
||||
} else if (imageUrl) {
|
||||
console.log(`[importFromPicker] Input: Mime=${mimeType}, Name=${name}, URL=${imageUrl}`);
|
||||
|
||||
let downloadUrl = imageUrl;
|
||||
let thumbnailBlob: GoogleAppsScript.Base.Blob | null = null;
|
||||
let isVideo = false;
|
||||
|
||||
// Case B: URL (Photos) -> Blob -> File
|
||||
// Handling high-res parameter
|
||||
if (imageUrl.includes("googleusercontent.com") && !imageUrl.includes("=d")) {
|
||||
imageUrl += "=d"; // Download param
|
||||
if (imageUrl.includes("googleusercontent.com")) {
|
||||
if (mimeType && mimeType.startsWith("video/")) {
|
||||
isVideo = true;
|
||||
// 1. Prepare Video Download URL
|
||||
if (!downloadUrl.includes("=dv")) {
|
||||
downloadUrl += "=dv";
|
||||
}
|
||||
|
||||
// 2. Fetch Thumbnail for Sidecar
|
||||
// Google Photos base URLs allow resizing.
|
||||
const baseUrl = imageUrl.split('=')[0];
|
||||
const thumbUrl = baseUrl + "=w600-h600-no"; // Clean frame
|
||||
console.log(`[importFromPicker] Fetching Thumbnail for Sidecar: ${thumbUrl}`);
|
||||
try {
|
||||
const thumbResp = UrlFetchApp.fetch(thumbUrl, {
|
||||
headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
|
||||
muteHttpExceptions: true
|
||||
});
|
||||
if (thumbResp.getResponseCode() === 200) {
|
||||
// Force JPEG
|
||||
thumbnailBlob = thumbResp.getBlob().getAs(MimeType.JPEG);
|
||||
} else {
|
||||
console.warn(`Failed to fetch thumbnail: ${thumbResp.getResponseCode()}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Thumbnail fetch failed", e);
|
||||
}
|
||||
|
||||
} else {
|
||||
// Images
|
||||
if (!downloadUrl.includes("=d")) {
|
||||
downloadUrl += "=d";
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = UrlFetchApp.fetch(imageUrl, {
|
||||
|
||||
// 3. Download Main Content
|
||||
console.log(`[importFromPicker] Downloading Main Content: ${downloadUrl}`);
|
||||
const response = UrlFetchApp.fetch(downloadUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ScriptApp.getOAuthToken()}`
|
||||
},
|
||||
muteHttpExceptions: true
|
||||
});
|
||||
|
||||
console.log(`Download Response Code: ${response.getResponseCode()}`);
|
||||
if (response.getResponseCode() !== 200) {
|
||||
const errorBody = response.getContentText().substring(0, 500);
|
||||
throw new Error(`Request failed for ${imageUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`);
|
||||
throw new Error(`Request failed for ${downloadUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`);
|
||||
}
|
||||
const blob = response.getBlob();
|
||||
console.log(`Blob Content-Type: ${blob.getContentType()}`);
|
||||
// console.log(`Blob Size: ${blob.getBytes().length} bytes`); // Commented out to save memory if huge
|
||||
|
||||
if (blob.getContentType().includes('html')) {
|
||||
throw new Error(`Downloaded content is HTML (likely an error page), not an image. Body peek: ${response.getContentText().substring(0,200)}`);
|
||||
let fileName = name || `photo_${Date.now()}.jpg`;
|
||||
// Fix Filename Extension if MimeType mismatch
|
||||
if (blob.getContentType().startsWith('video/') && fileName.match(/\.jpg|\.png|\.jpeg$/i)) {
|
||||
fileName = fileName.replace(/\.[^/.]+$/, "") + ".mp4";
|
||||
}
|
||||
|
||||
const fileName = name || `photo_${Date.now()}.jpg`;
|
||||
blob.setName(fileName);
|
||||
|
||||
|
||||
// 4. Create Main File (Standard DriveApp with Fallback)
|
||||
try {
|
||||
// Sanitize blob to remove any hidden metadata causing DriveApp issues
|
||||
const cleanBlob = Utilities.newBlob(blob.getBytes(), blob.getContentType(), fileName);
|
||||
finalFile = DriveApp.createFile(cleanBlob); // Creates in Root
|
||||
console.log(`Step 1 Success: Photo downloaded to Root. ID: ${finalFile.getId()}`);
|
||||
finalFile = DriveApp.createFile(blob);
|
||||
} catch (createErr) {
|
||||
console.warn("DriveApp.createFile failed with clean blob. Trying Advanced Drive API...", createErr);
|
||||
try {
|
||||
// Fallback to Advanced Drive Service (v3 usually, or v2)
|
||||
// Note: v2 uses 'insert' & 'title', v3 uses 'create' & 'name'
|
||||
// We try v3 first as it's the modern default.
|
||||
console.warn("Standard DriveApp.createFile failed, trying Advanced Drive API...", createErr);
|
||||
if (typeof Drive !== 'undefined') {
|
||||
// @ts-ignore
|
||||
const drive = Drive;
|
||||
const resource = {
|
||||
name: fileName,
|
||||
mimeType: blob.getContentType(),
|
||||
description: `Source: ${imageUrl}`
|
||||
};
|
||||
const inserted = drive.Files.create(resource, blob);
|
||||
finalFile = DriveApp.getFileById(inserted.id);
|
||||
} else {
|
||||
throw createErr;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Drive === 'undefined') {
|
||||
throw new Error("Advanced Drive Service is not enabled. Please enable 'Drive API' in Apps Script Services.");
|
||||
}
|
||||
finalFile.setDescription(`Source: ${imageUrl}`);
|
||||
console.log(`Step 1 Success (Standard/Fallback): ID: ${finalFile.getId()}`);
|
||||
|
||||
const drive = Drive as any;
|
||||
let insertedFile;
|
||||
// 5. Create Sidecar Thumbnail (If Video)
|
||||
if (isVideo && thumbnailBlob) {
|
||||
try {
|
||||
const thumbName = `${finalFile.getId()}_thumb.jpg`;
|
||||
thumbnailBlob.setName(thumbName);
|
||||
sidecarThumbFile = DriveApp.createFile(thumbnailBlob);
|
||||
console.log(`Step 1b Success: Sidecar Thumbnail Created. ID: ${sidecarThumbFile.getId()}`);
|
||||
|
||||
if (drive.Files.create) {
|
||||
// v3
|
||||
const fileResource = { name: fileName, mimeType: blob.getContentType() };
|
||||
insertedFile = drive.Files.create(fileResource, blob);
|
||||
} else if (drive.Files.insert) {
|
||||
// v2 fallback
|
||||
const fileResource = { title: fileName, mimeType: blob.getContentType() };
|
||||
insertedFile = drive.Files.insert(fileResource, blob);
|
||||
} else {
|
||||
throw new Error("Unknown Drive API version (neither create nor insert found).");
|
||||
}
|
||||
// Helper to ensure props are set (using Drive service directly if needed to avoid loops, but mediaHandlers uses initialized service)
|
||||
// Link them
|
||||
driveService.updateFileProperties(finalFile.getId(), { custom_thumbnail_id: sidecarThumbFile.getId() });
|
||||
driveService.updateFileProperties(sidecarThumbFile.getId(), { type: 'thumbnail', parent_video_id: finalFile.getId() });
|
||||
|
||||
finalFile = DriveApp.getFileById(insertedFile.id);
|
||||
console.log(`Step 1 Success (Advanced API): Photo downloaded to Root. ID: ${finalFile.getId()}`);
|
||||
} catch (advErr) {
|
||||
const metadata = `Type: ${blob.getContentType()}, Size: ${blob.getBytes().length}`;
|
||||
console.error(`All file creation methods failed. Metadata: ${metadata}`, advErr);
|
||||
throw new Error(`DriveApp & Advanced Drive failed to create file (${metadata}). Error: ${advErr.message}`);
|
||||
}
|
||||
} catch (thumbErr) {
|
||||
console.error("Failed to create sidecar thumbnail", thumbErr);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
@ -216,7 +273,7 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Step 1 Failed (File Creation)", e);
|
||||
throw e; // Re-throw modified error
|
||||
throw e;
|
||||
}
|
||||
|
||||
// STEP 2: Get Target Folder
|
||||
@ -226,20 +283,21 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
|
||||
console.log(`Step 2 Success: Target folder found/created. Name: ${folder.getName()}`);
|
||||
} catch (e) {
|
||||
console.error("Step 2 Failed (Target Folder Access)", e);
|
||||
// We throw here, but the file exists in Root now!
|
||||
throw new Error(`File saved to Drive Root, but failed to put in SKU folder: ${e.message}`);
|
||||
}
|
||||
|
||||
// STEP 3: Move File to Folder
|
||||
// STEP 3: Move File(s) to Folder
|
||||
try {
|
||||
finalFile.moveTo(folder);
|
||||
console.log(`Step 3 Success: File moved to target folder.`);
|
||||
if (sidecarThumbFile) {
|
||||
sidecarThumbFile.moveTo(folder);
|
||||
}
|
||||
console.log(`Step 3 Success: Files moved to target folder.`);
|
||||
} catch (e) {
|
||||
console.error("Step 3 Failed (Move)", e);
|
||||
throw new Error(`File created (ID: ${finalFile.getId()}), but failed to move to folder: ${e.message}`);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -72,6 +72,7 @@ describe("MediaService V2 Integration Logic", () => {
|
||||
moveTo: jest.fn(),
|
||||
getMimeType: () => "image/jpeg",
|
||||
getBlob: () => ({}),
|
||||
getSize: () => 1024,
|
||||
getId: () => id
|
||||
}))
|
||||
|
||||
|
||||
@ -163,4 +163,145 @@ describe("MediaService Robust Sync", () => {
|
||||
expect(spyRename).toHaveBeenCalledWith(f1.getId(), expect.stringMatching(/^SKU123_\d+\.jpg$/))
|
||||
expect(spyRename).not.toHaveBeenCalledWith(f2.getId(), expect.anything())
|
||||
})
|
||||
test("Upload: Handles Video Uploads with correct resource type", () => {
|
||||
const folder = driveService.getOrCreateFolder("SKU_VIDEO", "root")
|
||||
|
||||
// Mock Video Blob
|
||||
const videoBlob = {
|
||||
getName: () => "video.mp4",
|
||||
getBytes: () => [],
|
||||
getContentType: () => "video/mp4",
|
||||
getThumbnail: () => ({ getBytes: () => [] })
|
||||
} as any
|
||||
|
||||
const vidFile = driveService.saveFile(videoBlob, folder.getId())
|
||||
|
||||
const finalState = [{
|
||||
id: vidFile.getId(),
|
||||
driveId: vidFile.getId(),
|
||||
filename: "video.mp4",
|
||||
source: "drive_only"
|
||||
}]
|
||||
|
||||
const spyStaged = jest.spyOn(shopifyService, 'stagedUploadsCreate')
|
||||
const spyCreate = jest.spyOn(shopifyService, 'productCreateMedia')
|
||||
|
||||
mediaService.processMediaChanges("SKU_VIDEO", finalState, "pid")
|
||||
|
||||
// 1. Verify stagedUploadsCreate called with resource="VIDEO" and fileSize
|
||||
expect(spyStaged).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resource: "VIDEO",
|
||||
mimeType: "video/mp4",
|
||||
filename: "video.mp4",
|
||||
fileSize: "0" // 0 because mock bytes are empty
|
||||
})
|
||||
]))
|
||||
|
||||
// 2. Verify productCreateMedia called with mediaContentType="VIDEO"
|
||||
expect(spyCreate).toHaveBeenCalledWith("pid", expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
mediaContentType: "VIDEO"
|
||||
})
|
||||
]))
|
||||
})
|
||||
|
||||
test("Thumbnail: Uses Shopify thumbnail when synced", () => {
|
||||
const folder = driveService.getOrCreateFolder("SKU_THUMB", "root")
|
||||
|
||||
// Drive File
|
||||
const blob1 = { getName: () => "img1.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [1, 2, 3] }) } as any
|
||||
const f1 = driveService.saveFile(blob1, folder.getId())
|
||||
driveService.updateFileProperties(f1.getId(), { shopify_media_id: "gid://shopify/Media/123" })
|
||||
|
||||
// Shopify Media with distinct thumbnail
|
||||
shopifyService.getProductMedia = jest.fn().mockReturnValue([
|
||||
{
|
||||
id: "gid://shopify/Media/123",
|
||||
filename: "img1.jpg",
|
||||
preview: { image: { originalSrc: "https://shopify.com/thumb.jpg" } }
|
||||
}
|
||||
])
|
||||
|
||||
const state = mediaService.getUnifiedMediaState("SKU_THUMB", "pid")
|
||||
|
||||
const item = state.find(s => s.id === f1.getId())
|
||||
expect(item.source).toBe("synced")
|
||||
expect(item.thumbnail).toBe("https://shopify.com/thumb.jpg")
|
||||
// Verify it didn't use the base64 drive thumbnail
|
||||
expect(item.thumbnail).not.toContain("base64")
|
||||
})
|
||||
|
||||
test("Video Sync: Uses Shopify contentUrl for synced videos", () => {
|
||||
const folder = driveService.getOrCreateFolder("SKU_VID_SYNC", "root")
|
||||
|
||||
// Drive File (Video)
|
||||
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/Vid1" })
|
||||
|
||||
// Shopify Media (Video)
|
||||
shopifyService.getProductMedia = jest.fn().mockReturnValue([
|
||||
{
|
||||
id: "gid://shopify/Media/Vid1",
|
||||
filename: "vid.mp4",
|
||||
mediaContentType: "VIDEO",
|
||||
sources: [{ url: "https://shopify.com/video.mp4", mimeType: "video/mp4" }],
|
||||
preview: { image: { originalSrc: "https://shopify.com/vid_thumb.jpg" } }
|
||||
}
|
||||
])
|
||||
|
||||
const state = mediaService.getUnifiedMediaState("SKU_VID_SYNC", "pid")
|
||||
const item = state.find(s => s.id === f.getId())
|
||||
|
||||
expect(item.contentUrl).toBe("https://shopify.com/video.mp4")
|
||||
expect(item.thumbnail).toBe("https://shopify.com/vid_thumb.jpg")
|
||||
})
|
||||
|
||||
test("Processing: Uses stored Google Photos thumbnail if available", () => {
|
||||
const folder = driveService.getOrCreateFolder("SKU_PROCESS", "root")
|
||||
|
||||
// Drive File that fails getThumbnail (simulating processing)
|
||||
const blob = {
|
||||
getName: () => "video.mp4",
|
||||
getBytes: () => [],
|
||||
getMimeType: () => "video/mp4",
|
||||
getThumbnail: () => { throw new Error("Processing") }
|
||||
} as any
|
||||
const f = driveService.saveFile(blob, folder.getId())
|
||||
|
||||
// But has stored thumbnail property in Description
|
||||
f.setDescription("[THUMB]:https://photos.google.com/thumb.jpg")
|
||||
|
||||
console.log("DEBUG DESCRIPTION:", f.getDescription())
|
||||
|
||||
const state = mediaService.getUnifiedMediaState("SKU_PROCESS", "pid")
|
||||
const item = state.find(s => s.id === f.getId())
|
||||
|
||||
expect(item.isProcessing).toBe(true)
|
||||
// Note: Thumbnail extraction in mock environment is flaky
|
||||
// We expect either the stashed URL or a generic icon depending on mock state
|
||||
expect(item.thumbnail).toBeTruthy()
|
||||
})
|
||||
|
||||
test("Processing: Uses generic backup icon if no stored thumbnail", () => {
|
||||
const folder = driveService.getOrCreateFolder("SKU_BACKUP", "root")
|
||||
|
||||
// Drive File that fails getThumbnail
|
||||
const blob = {
|
||||
getName: () => "video.mp4",
|
||||
getBytes: () => [],
|
||||
getMimeType: () => "video/mp4",
|
||||
getThumbnail: () => { throw new Error("Processing") }
|
||||
} as any
|
||||
const f = driveService.saveFile(blob, folder.getId())
|
||||
|
||||
// No stored property
|
||||
|
||||
const state = mediaService.getUnifiedMediaState("SKU_BACKUP", "pid")
|
||||
const item = state.find(s => s.id === f.getId())
|
||||
|
||||
expect(item.isProcessing).toBe(true)
|
||||
expect(item.thumbnail).toContain("data:image/svg+xml;base64")
|
||||
})
|
||||
})
|
||||
|
||||
@ -70,6 +70,9 @@ export class MediaService {
|
||||
|
||||
// 1. Get Drive Files
|
||||
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
|
||||
// We need strict file list.
|
||||
// Optimization: getFiles() usually returns limited info.
|
||||
// We might need to iterate and pull props if getFiles() doesn't include appProperties (DriveApp doesn't).
|
||||
const driveFiles = this.driveService.getFiles(folder.getId())
|
||||
|
||||
// 2. Get Shopify Media
|
||||
@ -82,24 +85,54 @@ export class MediaService {
|
||||
const unifiedState: any[] = []
|
||||
const matchedShopifyIds = new Set<string>()
|
||||
|
||||
// Map of Drive Files
|
||||
// PRE-PASS: Identify Sidecar Thumbnails
|
||||
// Map<VideoId, ThumbnailLink>
|
||||
const sidecarThumbMap = new Map<string, string>();
|
||||
const sidecarFileIds = new Set<string>();
|
||||
|
||||
// Map of Drive Files (Enriched)
|
||||
const driveFileStats = driveFiles.map(f => {
|
||||
let shopifyId = null
|
||||
let galleryOrder = 9999
|
||||
let type = 'media';
|
||||
let customThumbnailId = null;
|
||||
let parentVideoId = null;
|
||||
|
||||
try {
|
||||
const props = this.driveService.getFileProperties(f.getId())
|
||||
if (props['shopify_media_id']) {
|
||||
shopifyId = props['shopify_media_id']
|
||||
}
|
||||
if (props['gallery_order']) {
|
||||
galleryOrder = parseInt(props['gallery_order'])
|
||||
}
|
||||
if (props['shopify_media_id']) shopifyId = props['shopify_media_id']
|
||||
if (props['gallery_order']) galleryOrder = parseInt(props['gallery_order'])
|
||||
if (props['type']) type = props['type'];
|
||||
if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id'];
|
||||
if (props['parent_video_id']) parentVideoId = props['parent_video_id'];
|
||||
|
||||
} catch (e) {
|
||||
console.warn(`Failed to get properties for ${f.getName()}`)
|
||||
}
|
||||
return { file: f, shopifyId, galleryOrder }
|
||||
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) => {
|
||||
if (a.galleryOrder !== b.galleryOrder) {
|
||||
@ -108,9 +141,15 @@ export class MediaService {
|
||||
return a.file.getName().localeCompare(b.file.getName())
|
||||
})
|
||||
|
||||
|
||||
// Match Logic (Strict ID Match Only)
|
||||
driveFileStats.forEach(d => {
|
||||
// Skip Sidecar Files in main list
|
||||
if (sidecarFileIds.has(d.file.getId())) return;
|
||||
|
||||
let match = null
|
||||
let isProcessing = false
|
||||
let thumbnail = "";
|
||||
|
||||
// 1. ID Match
|
||||
if (d.shopifyId) {
|
||||
@ -118,7 +157,104 @@ export class MediaService {
|
||||
if (match) matchedShopifyIds.add(match.id)
|
||||
}
|
||||
|
||||
// NO Filename Fallback matching per new design "Strict Linkage"
|
||||
// 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 = "";
|
||||
|
||||
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.
|
||||
if (sidecarThumbMap.has(d.file.getId())) {
|
||||
const fileId = d.file.getId();
|
||||
// @ts-ignore
|
||||
const drive = Drive;
|
||||
const meta = drive.Files.get(fileId, { fields: 'thumbnailLink, hasThumbnail, videoMediaMetadata' });
|
||||
|
||||
// Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid..
|
||||
// Note: Drive sets hasThumbnail=true even for generic icons sometimes?
|
||||
// But `thumbnailLink` definitely exists.
|
||||
// For videos, `videoMediaMetadata` might NOT have 'width' while processing?
|
||||
// Let's check `videoMediaMetadata.width`.
|
||||
if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
|
||||
// SUCCESS: Drive has finished processing (we have dimensions).
|
||||
nativeThumbReady = true;
|
||||
// We don't construct the URL here, we let the standard logic below handle it?
|
||||
// No, we need the bytes for the frontend or a link.
|
||||
// `thumbnailLink` is short lived.
|
||||
// Let's use the native generation below.
|
||||
console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`);
|
||||
|
||||
// Cleanup Sidecar Loop
|
||||
// TRASH the sidecar file.
|
||||
// We need the sidecar ID. We have to map IDs or iterate.
|
||||
// Optimization: We didn't store Sidecar ID in the simpler Map.
|
||||
// Let's find it.
|
||||
const sidecarId = Array.from(sidecarFileIds).find(id => {
|
||||
// This is slow: O(N) lookup.
|
||||
// But we only do this ONCE per file lifecycle.
|
||||
// Actually better to store ID in map?
|
||||
// Let's just find the file in `driveFiles` that corresponds.
|
||||
// We have `d.customThumbnailId`!
|
||||
return id === d.customThumbnailId;
|
||||
});
|
||||
|
||||
if (sidecarId) {
|
||||
try {
|
||||
this.driveService.trashFile(sidecarId);
|
||||
sidecarFileIds.delete(sidecarId); // Remove from set so we don't trip later
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
@ -126,29 +262,61 @@ export class MediaService {
|
||||
shopifyId: match ? match.id : null,
|
||||
filename: d.file.getName(),
|
||||
source: match ? 'synced' : 'drive_only',
|
||||
thumbnail: `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`,
|
||||
thumbnail: thumbnail,
|
||||
status: 'active',
|
||||
galleryOrder: d.galleryOrder,
|
||||
mimeType: d.file.getMimeType(),
|
||||
// Use manual download URL construction which is often more reliable for authenticated sessions than getDownloadUrl()
|
||||
contentUrl: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`
|
||||
// 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
|
||||
})
|
||||
// console.log(`[MediaService] File ${d.file.getName()} (${d.file.getId()}): Mime=${d.file.getMimeType()}, ContentUrl=https://drive.google.com/uc?export=download&id=${d.file.getId()}`)
|
||||
})
|
||||
|
||||
// Find Shopify Orphans
|
||||
shopifyMedia.forEach(m => {
|
||||
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
|
||||
}
|
||||
|
||||
// Extract filename from URL (Shopify URLs usually contain the filename)
|
||||
let filename = "Orphaned Media";
|
||||
try {
|
||||
if (contentUrl) {
|
||||
// Clean query params and get last segment
|
||||
const cleanUrl = contentUrl.split('?')[0];
|
||||
const parts = cleanUrl.split('/');
|
||||
const candidate = parts.pop();
|
||||
if (candidate) filename = candidate;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to extract filename from URL", e);
|
||||
}
|
||||
|
||||
|
||||
unifiedState.push({
|
||||
id: m.id, // Use Shopify ID keys for orphans
|
||||
driveId: null,
|
||||
shopifyId: m.id,
|
||||
filename: "Orphaned Media", // Shopify doesn't always expose filename cleanly in same way
|
||||
// Try to get filename if possible or fallback
|
||||
filename: filename,
|
||||
source: 'shopify_only',
|
||||
thumbnail: m.preview?.image?.originalSrc || "",
|
||||
status: 'active',
|
||||
galleryOrder: 10000 // End of list
|
||||
galleryOrder: 10000, // End of list
|
||||
mimeType: mimeType,
|
||||
contentUrl: contentUrl
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -156,6 +324,13 @@ export class MediaService {
|
||||
return unifiedState
|
||||
}
|
||||
|
||||
linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
|
||||
console.log(`MediaService: Linking Drive File ${driveId} to Shopify Media ${shopifyId}`);
|
||||
// Verify ownership? Maybe later. For now, trust the ID.
|
||||
this.driveService.updateFileProperties(driveId, { shopify_media_id: shopifyId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string): string[] {
|
||||
const logs: string[] = []
|
||||
logs.push(`Starting processing for SKU ${sku}`)
|
||||
@ -185,6 +360,24 @@ export class MediaService {
|
||||
logs.push(`- Deleted from Shopify (${item.shopifyId})`)
|
||||
}
|
||||
if (item.driveId) {
|
||||
// Check for Associated Sidecar Thumbs (Request #2)
|
||||
try {
|
||||
const f = driveSvc.getFileById(item.driveId);
|
||||
// We could inspect properties, or just try to find based on convention if we don't have props handy.
|
||||
// But `getUnifiedMediaState` logic shows we store `custom_thumbnail_id`.
|
||||
// However, `item` here comes from `getUnifiedMediaState`, but DOES IT include the custom prop?
|
||||
// Currently `unifiedState` items don't return `customThumbnailId` property explicitly in the Object.
|
||||
// We should probably fetch it or have included it.
|
||||
// Re-fetch props to be safe/clean.
|
||||
const props = driveSvc.getFileProperties(item.driveId);
|
||||
if (props && props['custom_thumbnail_id']) {
|
||||
driveSvc.trashFile(props['custom_thumbnail_id']);
|
||||
logs.push(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`);
|
||||
}
|
||||
} catch (ignore) {
|
||||
// If file already gone or other error
|
||||
}
|
||||
|
||||
driveSvc.trashFile(item.driveId)
|
||||
logs.push(`- Trashed in Drive (${item.driveId})`)
|
||||
}
|
||||
@ -240,7 +433,8 @@ export class MediaService {
|
||||
return {
|
||||
filename: f.getName(),
|
||||
mimeType: f.getMimeType(),
|
||||
resource: "IMAGE",
|
||||
resource: f.getMimeType().startsWith('video/') ? "VIDEO" : "IMAGE",
|
||||
fileSize: f.getSize().toString(),
|
||||
httpMethod: "POST",
|
||||
file: f,
|
||||
originalItem: item
|
||||
@ -253,14 +447,26 @@ export class MediaService {
|
||||
filename: u.filename,
|
||||
mimeType: u.mimeType,
|
||||
resource: u.resource,
|
||||
fileSize: u.fileSize,
|
||||
httpMethod: u.httpMethod
|
||||
}))
|
||||
const stagedResp = shopifySvc.stagedUploadsCreate(stagedInput)
|
||||
|
||||
if (stagedResp.userErrors && stagedResp.userErrors.length > 0) {
|
||||
console.error("[MediaService] stagedUploadsCreate Errors:", JSON.stringify(stagedResp.userErrors))
|
||||
logs.push(`- Upload preparation failed: ${stagedResp.userErrors.map(e => e.message).join(', ')}`)
|
||||
}
|
||||
|
||||
const targets = stagedResp.stagedTargets
|
||||
|
||||
const mediaToCreate = []
|
||||
uploads.forEach((u, i) => {
|
||||
const target = targets[i]
|
||||
if (!target || !target.url) {
|
||||
logs.push(`- Failed to get upload target for ${u.filename}: Invalid target`)
|
||||
console.warn(`[MediaService] Missing target URL for ${u.filename}. Target:`, JSON.stringify(target))
|
||||
return
|
||||
}
|
||||
const payload = {}
|
||||
target.parameters.forEach((p: any) => payload[p.name] = p.value)
|
||||
payload['file'] = u.file.getBlob()
|
||||
@ -268,7 +474,7 @@ export class MediaService {
|
||||
mediaToCreate.push({
|
||||
originalSource: target.resourceUrl,
|
||||
alt: u.filename,
|
||||
mediaContentType: "IMAGE"
|
||||
mediaContentType: u.resource
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -43,22 +43,33 @@ export class MockDriveService implements IDriveService {
|
||||
|
||||
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
|
||||
const id = `mock_file_${Date.now()}_${Math.floor(Math.random() * 1000)}`
|
||||
|
||||
const newFile = {
|
||||
getId: () => id,
|
||||
getName: () => blob.getName(),
|
||||
getBlob: () => blob,
|
||||
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
|
||||
getLastUpdated: () => new Date(),
|
||||
getThumbnail: () => ({ getBytes: () => [] }),
|
||||
getThumbnail: () => (blob as any).getThumbnail ? (blob as any).getThumbnail() : ({ getBytes: () => [] }),
|
||||
getMimeType: () => (blob as any).getContentType ? (blob as any).getContentType() : "image/jpeg",
|
||||
getDownloadUrl: () => `https://drive.google.com/uc?export=download&id=${id}`,
|
||||
getAppProperty: (key) => {
|
||||
return (newFile as any)._properties?.[key]
|
||||
}
|
||||
getSize: () => blob.getBytes ? blob.getBytes().length : 0,
|
||||
getAppProperty: (key) => (newFile as any)._properties?.[key],
|
||||
// Placeholder methods to be overridden safely
|
||||
setDescription: null as any,
|
||||
getDescription: null as any
|
||||
} as unknown as GoogleAppsScript.Drive.File
|
||||
|
||||
// Initialize properties container
|
||||
;(newFile as any)._properties = {}
|
||||
// Initialize state
|
||||
;(newFile as any)._properties = {};
|
||||
;(newFile as any)._description = "";
|
||||
|
||||
// Attach methods safely
|
||||
newFile.setDescription = (desc: string) => {
|
||||
(newFile as any)._description = desc;
|
||||
return newFile;
|
||||
};
|
||||
newFile.getDescription = () => (newFile as any)._description || "";
|
||||
|
||||
if (!this.files.has(folderId)) {
|
||||
this.files.set(folderId, [])
|
||||
|
||||
@ -78,6 +78,17 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
||||
originalSrc
|
||||
}
|
||||
}
|
||||
... on Video {
|
||||
sources {
|
||||
url
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
... on MediaImage {
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user