From 690f8c5c38531fb7daa63fa9bf3c23f687ab87a2 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Tue, 30 Dec 2025 23:41:59 -0700 Subject: [PATCH] Implement sidecar video thumbnails and improved processing UI - Implemented "sidecar" thumbnail logic: imports video thumbnails from Google Photos as hidden Drive files to display immediately while videos process. - Updated MediaService to serve sidecar thumbnails via server-side Base64 encoding, bypassing CORS restrictions. - Implemented lifecycle management: detects video processing completion to automatically cleanup sidecar files and fallback to native Drive thumbnails. - Enhanced Media Manager UI: added processing warning banner and refined processing tile styling (centered, lighter overlay). - Upgraded Drive API to v3 and improved file creation robustness with Advanced API fallbacks. --- src/MediaManager.html | 65 +++++++++-- src/appsscript.json | 2 +- src/mediaHandlers.test.ts | 9 +- src/mediaHandlers.ts | 135 +++++++++++++---------- src/services/MediaService.test.ts | 47 ++++++++ src/services/MediaService.ts | 173 ++++++++++++++++++++++++++---- src/services/MockDriveService.ts | 22 ++-- 7 files changed, 358 insertions(+), 95 deletions(-) diff --git a/src/MediaManager.html b/src/MediaManager.html index 6784465..84a0218 100644 --- a/src/MediaManager.html +++ b/src/MediaManager.html @@ -164,25 +164,30 @@ /* Processing State */ .media-item.processing-card { background-color: #334155 !important; - /* Slate-700 */ - display: flex; - align-items: center; - justify-content: center; + position: relative; /* Ensure absolute children are contained */ + /* Removed flex centering to let image stretch */ } .media-item.processing-card .media-content { - display: none !important; + display: block !important; + opacity: 0.8; /* Lighter overlay (was 0.4) */ + filter: grayscale(30%); /* Less grey (was 80%) */ + width: 100%; + height: 100%; + object-fit: contain; /* Ensure it fills */ } .processing-icon { - font-size: 40px; - width: 40px; - height: 40px; + position: absolute; + bottom: 6px; + right: 6px; + font-size: 20px; /* Smaller */ + z-index: 20; /* Above badges */ display: flex; align-items: center; justify-content: center; - z-index: 5; transition: transform 0.6s ease-in-out; + /* Remove fixed width/height so it fits content */ } /* .flipping removed, handled by JS inline style */ @@ -454,6 +459,16 @@ style="padding:16px; background:#f8fafc; border-bottom:1px solid var(--border); font-family:monospace; font-size:12px; line-height:1.6; display:none;"> + + +
@@ -772,6 +787,13 @@ var activeCount = items.filter(function (i) { return !i._deleted; }).length; document.getElementById('item-count').innerText = '(' + activeCount + ')'; + // processing check + var hasProcessing = items.some(function (i) { return !i._deleted && i.isProcessing; }); + var banner = document.getElementById('processing-banner'); + if (banner) { + banner.style.display = hasProcessing ? 'flex' : 'none'; + } + if (items.length === 0) { this.grid.innerHTML = '
No media found. Upload something!
'; return; @@ -889,6 +911,31 @@ mediaEl = document.createElement('img'); mediaEl.src = item.thumbnail || ""; mediaEl.loading = "lazy"; + mediaEl.referrerPolicy = "no-referrer"; + mediaEl.onerror = function () { + var currentSrc = this.src; + console.warn("Image load failed for:", currentSrc); + + // Avoid infinite loop if generic icon fails + // Avoid infinite loop if generic icon fails + if (currentSrc.startsWith("data:image")) { + this.style.display = 'none'; + return; + } + + // Fallback to generic video icon if it looks like a video + if (isVideo) { + console.log("Falling back to generic video icon..."); + // Base64 Video Icon (Blue) to avoid network issues + this.src = "data:image/png;base64,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"; + // Ensure visibility if processing + if (item.isProcessing) { + this.style.opacity = "0.5"; + } + } else { + this.style.display = 'none'; // Hide if failed image + } + }; } mediaEl.className = 'media-content'; diff --git a/src/appsscript.json b/src/appsscript.json index fb2f929..a2f118b 100644 --- a/src/appsscript.json +++ b/src/appsscript.json @@ -5,7 +5,7 @@ { "userSymbol": "Drive", "serviceId": "drive", - "version": "v2" + "version": "v3" } ] }, diff --git a/src/mediaHandlers.test.ts b/src/mediaHandlers.test.ts index bd3f6cf..64c58f1 100644 --- a/src/mediaHandlers.test.ts +++ b/src/mediaHandlers.test.ts @@ -36,7 +36,8 @@ jest.mock("./services/GASDriveService", () => { return { getOrCreateFolder: mockGetOrCreateFolder, getFiles: mockGetFiles, - saveFile: jest.fn() + saveFile: jest.fn(), + updateFileProperties: jest.fn() } }) } @@ -63,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 = { @@ -157,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: () => "" }) diff --git a/src/mediaHandlers.ts b/src/mediaHandlers.ts index 85d3844..8bcdcbc 100644 --- a/src/mediaHandlers.ts +++ b/src/mediaHandlers.ts @@ -152,100 +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")) { if (mimeType && mimeType.startsWith("video/")) { - // For videos, =dv retrieves the actual video file (download video) - if (!imageUrl.includes("=dv")) { - imageUrl += "=dv"; - } + 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 { - // For images, =d retrieves the full download - if (!imageUrl.includes("=d")) { - imageUrl += "=d"; + // Images + if (!downloadUrl.includes("=d")) { + downloadUrl += "=d"; } } } - console.log(`[importFromPicker] Fetching URL: ${imageUrl}`); - 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()}`); let fileName = name || `photo_${Date.now()}.jpg`; - // Fix Filename Extension if MimeType mismatch - // (e.g. we downloaded a video, but filename is .jpg) if (blob.getContentType().startsWith('video/') && fileName.match(/\.jpg|\.png|\.jpeg$/i)) { - console.log(`[importFromPicker] Filename extension correction needed for video. Old: ${fileName}`); fileName = fileName.replace(/\.[^/.]+$/, "") + ".mp4"; - console.log(`[importFromPicker] New Filename: ${fileName}`); } - 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: File created in Root. ID: ${finalFile.getId()}, Mime: ${finalFile.getMimeType()}`); + 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 { @@ -253,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 @@ -263,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}`); } - } diff --git a/src/services/MediaService.test.ts b/src/services/MediaService.test.ts index 45332df..3668072 100644 --- a/src/services/MediaService.test.ts +++ b/src/services/MediaService.test.ts @@ -257,4 +257,51 @@ describe("MediaService Robust Sync", () => { 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") + }) }) diff --git a/src/services/MediaService.ts b/src/services/MediaService.ts index cf8e611..d9339b9 100644 --- a/src/services/MediaService.ts +++ b/src/services/MediaService.ts @@ -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() - // Map of Drive Files + // PRE-PASS: Identify Sidecar Thumbnails + // Map + const sidecarThumbMap = new Map(); + const sidecarFileIds = new Set(); + + // 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,8 +141,12 @@ 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 = ""; @@ -120,21 +157,102 @@ 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 { - // Try to get Drive thumbnail - thumbnail = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`; + // 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) { - console.warn(`Failed to get thumbnail for ${d.file.getName()} (likely processing): ${e}`); - // Return a generic placeholder (Gray 1x1 pixel) + Flag as processing - // thumbnail = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; - // Better placeholder: https://ssl.gstatic.com/docs/doclist/images/icon_10_movie_list.png (Video Icon) or just gray - thumbnail = "https://ssl.gstatic.com/docs/doclist/images/icon_128_video_blue.png"; // Official Video Icon - isProcessing = true; + // 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="; + } } } @@ -154,7 +272,6 @@ export class MediaService { : `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 @@ -243,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})`) } diff --git a/src/services/MockDriveService.ts b/src/services/MockDriveService.ts index e10df29..69c3fb1 100644 --- a/src/services/MockDriveService.ts +++ b/src/services/MockDriveService.ts @@ -43,23 +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}`, getSize: () => blob.getBytes ? blob.getBytes().length : 0, - getAppProperty: (key) => { - return (newFile as any)._properties?.[key] - } + 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, [])