import { GASSpreadsheetService } from "./services/GASSpreadsheetService" import { GASDriveService } from "./services/GASDriveService" import { ShopifyMediaService } from "./services/ShopifyMediaService" import { GASNetworkService } from "./services/GASNetworkService" import { MediaService } from "./services/MediaService" import { Shop } from "./shopifyApi" import { Config } from "./config" import { Product } from "./Product" export function showMediaManager() { const productInfo = getSelectedProductInfo(); const template = HtmlService.createTemplateFromFile("MediaManager"); // Pass variables to template (template as any).initialSku = productInfo ? productInfo.sku : ""; (template as any).initialTitle = productInfo ? productInfo.title : ""; const html = template.evaluate() .setTitle("Media Manager") .setWidth(1100) .setHeight(750); SpreadsheetApp.getUi().showModalDialog(html, "Media Manager"); } export function getSelectedProductInfo(): { sku: string, title: string } | null { const ss = new GASSpreadsheetService() // Optimization: Direct usage to avoid multiple service calls overhead // Use SpreadsheetApp only once if possible to get active context const sheet = SpreadsheetApp.getActiveSheet() if (sheet.getName() !== "product_inventory") return null const row = sheet.getActiveRange().getRow() if (row <= 1) return null // Header // Optimization: Get the whole row values in one go // We need to know which index is SKU and Title. // Getting headers once is cheaper than searching by name twice if we cache or just linear scan once. // Actually, getCellValueByColumnName does: getSheet -> getHeaders (read) -> getRowData (read). // Doing it twice = 6 operations. // Let's do it manually efficiently: const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] as string[]; const skuIdx = headers.indexOf("sku"); const titleIdx = headers.indexOf("title"); if (skuIdx === -1) return null; // No SKU column // Read the specific row // getRange(row, 1, 1, lastCol) const rowValues = sheet.getRange(row, 1, 1, sheet.getLastColumn()).getValues()[0]; const sku = rowValues[skuIdx]; const title = titleIdx !== -1 ? rowValues[titleIdx] : ""; return sku ? { sku: String(sku), title: String(title || "") } : null } export function getPickerConfig() { const config = new Config() return { apiKey: config.googlePickerApiKey, token: ScriptApp.getOAuthToken(), email: Session.getEffectiveUser().getEmail(), parentId: config.productPhotosFolderId // Root folder to start picker in? Optionally could be SKU folder } } export function getMediaForSku(sku: string): any[] { 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) // Resolve Product ID (Best Effort) const product = new Product(sku) // Ensure we have the latest correct ID from Shopify, repairing the sheet if needed try { product.MatchToShopifyProduct(shop); } catch (e) { console.warn("MatchToShopifyProduct failed", e); } const shopifyId = product.shopify_id || "" return mediaService.getUnifiedMediaState(sku, shopifyId) } export function saveMediaChanges(sku: string, finalState: any[], jobId: string | null = null) { 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) const product = new Product(sku) // Ensure we have the latest correct ID from Shopify try { product.MatchToShopifyProduct(shop); } catch (e) { console.warn("MatchToShopifyProduct failed", e); } if (!product.shopify_id) { // Allow saving Drive-only changes? No, we need Shopify context for "Staging" usually. // But if we just rename drive files, we could? // For now, fail safe. throw new Error("Product must be synced to Shopify before saving media changes.") } const logs = mediaService.processMediaChanges(sku, finalState, product.shopify_id, jobId) // Update Sheet Thumbnail (Top of Gallery) updateSpreadsheetThumbnail(sku); return logs } export function updateSpreadsheetThumbnail(sku: string, forcedThumbnailUrl: string | null = null) { 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) const ss = new GASSpreadsheetService(); // Optimization: If forced URL provided (optimistic update), skip state calculation if (forcedThumbnailUrl) { try { const row = ss.getRowNumberByColumnValue("product_inventory", "sku", sku); if (row) { const thumbUrl = forcedThumbnailUrl; try { const image = SpreadsheetApp.newCellImage() .setSourceUrl(thumbUrl) .setAltTextTitle(sku) .setAltTextDescription(`Thumbnail for ${sku}`) .build(); ss.setCellValueByColumnName("product_inventory", row, "thumbnail", image); } catch (builderErr) { ss.setCellValueByColumnName("product_inventory", row, "thumbnail", `=IMAGE("${thumbUrl}")`); } } return; } catch (e) { console.warn("Failed to update sheet thumbnail (forced)", e); throw new Error("Sheet Update Failed: " + e.message); } } const product = new Product(sku); // Need Shopify ID for accurate state logic? // getUnifiedMediaState uses it. try { product.MatchToShopifyProduct(shop); } catch(e) {} 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 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); } catch (builderErr) { // Fallback to formula ss.setCellValueByColumnName("product_inventory", row, "thumbnail", `=IMAGE("${thumbUrl}")`); } } } } catch (e) { console.warn("Failed to update sheet thumbnail", e); throw new Error("Sheet Update Failed: " + e.message); } } export function getMediaSavePlan(sku: string, finalState: any[]) { 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) const product = new Product(sku) // Ensure we have the latest correct ID from Shopify try { product.MatchToShopifyProduct(shop); } catch (e) { console.warn("MatchToShopifyProduct failed", e); } if (!product.shopify_id) { throw new Error("Product must be synced to Shopify before saving media changes.") } return mediaService.calculatePlan(sku, finalState, product.shopify_id); } export function executeSavePhase(sku: string, phase: string, planData: any, jobId: string | null = null) { 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) const product = new Product(sku) try { product.MatchToShopifyProduct(shop); } catch (e) { console.warn("MatchToShopifyProduct failed", e); } if (!product.shopify_id) { throw new Error("Product must be synced to Shopify before saving media changes.") } return mediaService.executeSavePhase(sku, phase, planData, product.shopify_id, jobId); } export function pollJobLogs(jobId: string): string[] { try { const cache = CacheService.getDocumentCache(); const json = cache.get(`job_logs_${jobId}`); return json ? JSON.parse(json) : []; } catch(e) { return []; } } export function getMediaDiagnostics(sku: 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) // Resolve Product ID const product = new Product(sku) // Ensure we have the latest correct ID from Shopify try { product.MatchToShopifyProduct(shop); } catch (e) { console.warn("MatchToShopifyProduct failed", e); } const shopifyId = product.shopify_id || "" const diagnostics = mediaService.getDiagnostics(sku, shopifyId) // Inject OAuth token for frontend video streaming (Drive API alt=media) return { ...diagnostics, token: ScriptApp.getOAuthToken() } } export function getMediaManagerInitialState(providedSku?: string, providedTitle?: string): { sku: string | null, title: string, diagnostics: any, media: any[], token: string } { let sku = providedSku; let title = providedTitle || ""; if (!sku) { const info = getSelectedProductInfo(); if (info) { sku = info.sku; title = info.title; } } if (!sku) { return { sku: null, title: "", diagnostics: null, media: [], token: ScriptApp.getOAuthToken() } } 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) // Resolve Product ID const product = new Product(sku) try { product.MatchToShopifyProduct(shop); } catch (e) { console.warn("MatchToShopifyProduct failed", e); } const shopifyId = product.shopify_id || "" const initialState = mediaService.getInitialState(sku, shopifyId); return { sku, title, diagnostics: initialState.diagnostics, media: initialState.media, 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) { const config = new Config() const driveService = new GASDriveService() const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId) const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, filename) driveService.saveFile(blob, folder.getId()) } // Picker Callback specific handler if needed, or we just rely on frontend passing back file ID // Implementing a "copy from Picker" handler // Implementing a "copy from Picker" handler export function importFromPicker(sku: string, fileId: string, mimeType: string, name: string, imageUrl: string | null) { const driveService = new GASDriveService() let config; try { config = new Config(); } catch(e) { console.error("Config init failed in importFromPicker", e); throw new Error("Configuration Error: " + e.message); } console.log(`importFromPicker starting for SKU: ${sku}`); // 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) 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 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"; } } } // 3. Download Main Content console.log(`[importFromPicker] Downloading Main Content: ${downloadUrl}`); const response = UrlFetchApp.fetch(downloadUrl, { headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` }, muteHttpExceptions: true }); if (response.getResponseCode() !== 200) { const errorBody = response.getContentText().substring(0, 500); throw new Error(`Request failed for ${downloadUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`); } const blob = response.getBlob(); 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"; } blob.setName(fileName); // 4. Create Main File (Standard DriveApp with Fallback) try { finalFile = DriveApp.createFile(blob); } catch (createErr) { 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; } } finalFile.setDescription(`Source: ${imageUrl}`); console.log(`Step 1 Success (Standard/Fallback): ID: ${finalFile.getId()}`); // 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()}`); // 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() }); } catch (thumbErr) { console.error("Failed to create sidecar thumbnail", thumbErr); } } } else { throw new Error("No File ID and No Image URL provided."); } } catch (e) { console.error("Step 1 Failed (File Creation)", e); throw e; } // STEP 2: Get Target Folder let folder: GoogleAppsScript.Drive.Folder; try { folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId); console.log(`Step 2 Success: Target folder found/created. Name: ${folder.getName()}`); } catch (e) { console.error("Step 2 Failed (Target Folder Access)", e); throw new Error(`File saved to Drive Root, but failed to put in SKU folder: ${e.message}`); } // STEP 3: Move File(s) to Folder try { finalFile.moveTo(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}`); } } export function debugScopes() { const token = ScriptApp.getOAuthToken(); console.log("Current Token exists: " + (token ? "YES" : "NO")); // We can't see exact scopes easily from server side without a library, // but we can check if the specific Photos pickup works? // No, let's just confirm the code is running the latest version. } export function debugFolderAccess() { const config = new Config() const ui = SpreadsheetApp.getUi(); if (!config.productPhotosFolderId) { ui.alert("Config Error", "No productPhotosFolderId found in vars.", ui.ButtonSet.OK); return; } const id = config.productPhotosFolderId.trim(); const info = [`Configured ID: '${id}'`]; try { info.push(`User: ${Session.getActiveUser().getEmail()}`); info.push(`Effective: ${Session.getEffectiveUser().getEmail()}`); const folder = DriveApp.getFolderById(id); info.push(`Success! Found Folder: ${folder.getName()}`); info.push(`URL: ${folder.getUrl()}`); info.push("Access seems OK from Menu context."); } catch (e) { info.push("FAILED to access as FOLDER."); info.push(`Error: ${e.message}`); // Try as file try { const file = DriveApp.getFileById(id); info.push(`\nWAIT! This ID belongs to a FILE, not a FOLDER!`); info.push(`File Name: ${file.getName()}`); info.push(`Mime: ${file.getMimeType()}`); } catch (e2) { info.push(`\nNot a File either: ${e2.message}`); } // Try Advanced Drive API try { const drive = (typeof Drive !== 'undefined') ? (Drive as any) : undefined; if (!drive) { info.push("\nAdvanced Drive Service (Drive) is NOT enabled. Please enable it in 'Services' > 'Drive API'."); } else { const advItem = drive.Files.get(id, { supportsAllDrives: true }); info.push(`\nSuccess via Advanced Drive API!`); info.push(`Title: ${advItem.title}`); info.push(`Mime: ${advItem.mimeType}`); info.push(`Note: If this works but DriveApp fails, this is likely a Shared Drive or permissions issue.`); } } catch (e3) { info.push(`\nAdvanced Drive API Failed: ${e3.message}`); } } ui.alert("Folder Access Debug", info.join("\n\n"), ui.ButtonSet.OK); } export function createPhotoSession() { const url = 'https://photospicker.googleapis.com/v1/sessions'; const token = ScriptApp.getOAuthToken(); const options = { method: 'post' as const, contentType: 'application/json', headers: { Authorization: `Bearer ${token}` }, payload: JSON.stringify({}) // Default session }; try { const response = UrlFetchApp.fetch(url, options); const data = JSON.parse(response.getContentText()); return data; // { id: "...", pickerUri: "..." } } catch (e) { console.error("Failed to create photo session", e); throw new Error("Failed to create photo session: " + e.message); } } export function checkPhotoSession(sessionId: string) { // Use pageSize=100 or check documentation. Default is usually fine. // We need to poll until we get mediaItems. const url = `https://photospicker.googleapis.com/v1/mediaItems?sessionId=${sessionId}&pageSize=10`; const token = ScriptApp.getOAuthToken(); const options = { method: 'get' as const, headers: { Authorization: `Bearer ${token}` }, muteHttpExceptions: true }; try { const response = UrlFetchApp.fetch(url, options); const text = response.getContentText(); console.log(`Polling session ${sessionId}: ${response.getResponseCode()}`); if (response.getResponseCode() !== 200) { // 400 Bad Request often means "Picker session not ready" or "Empty" if using the wrong check. // But documentation says FAILED_PRECONDITION (400?) if user hasn't finished. console.log("Polling response: " + response.getResponseCode() + " " + text); return { status: 'waiting' }; // Treat as waiting } const data = JSON.parse(text); // data.mediaItems might be undefined if nothing picked yet? // Or API waits? Actually checking documentation: it returns empty list or hangs? // It usually returns immediatley. If empty, user hasn't picked. if (data.mediaItems && data.mediaItems.length > 0) { return { status: 'complete', mediaItems: data.mediaItems }; } return { status: 'waiting' }; } catch (e) { console.error("Failed to check photo session", e); return { status: 'error', message: e.message }; } }