From 50ddfc9e15bd0408b19de42c40352f0ccdce6656 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Fri, 26 Dec 2025 01:51:04 -0700 Subject: [PATCH] Feature: Robust Google Photos Integration & Media Hardening - Implemented Google Photos Picker with Session API. - Fixed 403 Forbidden errors by adding OAuth headers to download requests. - Implemented MediaHandler resilience: - 3-Step Import (Save to Root -> Verify Folder -> Move). - Advanced Drive API Fallback (v3/v2) for file creation. - Blob Sanitization (Utilities.newBlob) to fix server errors. - Enabled Advanced Drive Service in ppsscript.json. - Updated Documentation (MEMORY.md, ARCHITECTURE.md) with findings. --- MEMORY.md | 10 +- docs/ARCHITECTURE.md | 23 ++++ src/MediaSidebar.html | 117 +++++++++++++++++-- src/appsscript.json | 10 +- src/global.ts | 6 +- src/initMenu.ts | 4 +- src/mediaHandlers.ts | 255 ++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 403 insertions(+), 22 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 4940200..08be64c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -31,4 +31,12 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser 32: - **Google Picker**: Integrated via `picker.js` using an API Key and OAuth Token passed securely from backend. 33: - **Drive as Source of Truth**: All uploads go to Drive first (Folder structure: `Root/SKU/Files`). 34: - **Shopify Sync**: `MediaService` orchestrates the complex `Staged Uploads` -> `Create Media` mutation flow. -35: - **Security**: `appsscript.json` requires explicit scopes for `userinfo.email` (Picker) and `drive` (Files). API Keys are stored in `vars` sheet, never hardcoded. +35: - **Security**: `appsscript.json` requires explicit scopes for `userinfo.email` (Picker), `drive` (Files), and `drive` (Advanced Service). API Keys are stored in `vars` sheet, never hardcoded. + +### Media Handling Quirks +- **Google Photos Picker**: + - The `baseUrl` returned by the Picker API is hidden inside `mediaFile.baseUrl` (not top-level). + - Downloading this URL requires an **Authorization header** with the script's OAuth token, or it returns 403. + - `DriveApp.createFile(blob)` is fragile with blobs from `UrlFetchApp`. We use a 2-step fallback: + 1. Sanitize with `Utilities.newBlob()`. + 2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2663bfc..79b156f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -118,3 +118,26 @@ We use **Husky** and **lint-staged** to enforce quality standards at the commit Classes (like `Product`) should accept an `ISpreadsheetService` in their constructor. This allows providing the Mock service during tests to verify logic without touching real Google Sheets. + +### 7. Media Manager (`src/mediaHandlers.ts`, `src/MediaSidebar.html`) + +We implemented a "Sidebar-First" architecture for product media to handle the complexity of Google Picker and Shopify Sync. + +#### Frontend (`MediaSidebar.html`) +- **Glassmorphism UI**: Uses modern CSS for a premium feel. +- **Polling**: Since the sidebar can't listen to Sheet events directly efficiently, it polls `getMediaState(sku)` to detect when the user selects a different product row. +- **Google Picker API**: + - Uses the **New Google Photos Picker** (Session-based) for selecting photos. + - Uses the **Google Drive Picker** (Legacy) for selecting existing Drive files. + - Handles OAuth token passing securely from the server side (`google.script.run`). + +#### Backend (`mediaHandlers.ts`) +- **Import Strategy**: + - **Safe Zone**: Files are first downloaded/copied to the Drive Root to ensure we have the asset. + - **Move**: Then they are moved to the organized SKU folder (`/Product Photos/[SKU]/`). + - **Resilience**: The file creation logic tries multiple methods (Standard `DriveApp`, Sanitized Blob, Advanced `Drive` API) to handle the notoriously fickle nature of UrlFetchApp blobs. +- **Shopify Sync**: + - `MediaService` manages the state. + - Calculates checksums to avoid re-uploading duplicate images. + - Uses Shopify's "Staged Uploads" -> "Create Media" mutation flow. + diff --git a/src/MediaSidebar.html b/src/MediaSidebar.html index f232316..09609c3 100644 --- a/src/MediaSidebar.html +++ b/src/MediaSidebar.html @@ -162,8 +162,21 @@ + + +
@@ -308,13 +321,11 @@ .setIncludeFolders(true) .setSelectFolderEnabled(false); - const photosView = new google.picker.PhotosView(); - const picker = new google.picker.PickerBuilder() .addView(view) - .addView(photosView) .setOAuthToken(config.token) .setDeveloperKey(config.apiKey) + .setOrigin(google.script.host.origin) .setCallback(pickerCallback) .build(); @@ -323,13 +334,103 @@ function pickerCallback(data) { if (data.action == google.picker.Action.PICKED) { - const fileId = data.docs[0].id; - const mimeType = data.docs[0].mimeType; + const doc = data.docs[0]; + const fileId = doc.id; + const mimeType = doc.mimeType; + const name = doc.name; + const url = doc.url; // Often the link to the file in Drive/Photos + + // For Photos, we might need the direct image URL, which is often in thumbnails or requires specific handling + // doc.thumbnails contains 's75-c' style URLs. We can strip the size to get full size? + // Actually, for Photos API items, 'url' might be the user-facing URL. + // Let's pass the 'thumbnails' closest to original if possible, or just pass the whole doc object to backend? + // Simpler: pass specific fields. + + const imageUrl = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null; google.script.run .withSuccessHandler(() => loadMedia(currentSku)) - .importFromPicker(currentSku, fileId, mimeType); - } + .importFromPicker(currentSku, fileId, mimeType, name, imageUrl); + } + } + + // --- Photos Session Logic (New API) --- + + let pollingTimer = null; + + function startPhotoSession() { + // Reset UI + document.getElementById('photos-session-ui').style.display = 'block'; + document.getElementById('photos-session-status').innerText = "Creating session..."; + document.getElementById('photos-session-link').style.display = 'none'; + + google.script.run + .withSuccessHandler(onSessionCreated) + .withFailureHandler(e => { + alert('Failed to start session: ' + e.message); + document.getElementById('photos-session-ui').style.display = 'none'; + }) + .createPhotoSession(); + } + + function onSessionCreated(session) { + if (!session || !session.pickerUri) { + alert("Failed to get picker URI"); + return; + } + + const link = document.getElementById('photos-session-link'); + link.href = session.pickerUri; + link.style.display = 'block'; + link.innerText = "Click here to pick photos ↗"; + + document.getElementById('photos-session-status').innerText = "Waiting for you to pick photos..."; + + // Open automatically? Browsers block it. User must click. + // Start polling + if (pollingTimer) clearInterval(pollingTimer); + pollingTimer = setInterval(() => pollSession(session.id), 2000); // Poll every 2s + } + + function pollSession(sessionId) { + google.script.run + .withSuccessHandler(result => { + console.log("Poll result:", result); + if (result.status === 'complete') { + clearInterval(pollingTimer); + document.getElementById('photos-session-status').innerText = "Importing photos..."; + processPickedPhotos(result.mediaItems); + } else if (result.status === 'error') { + document.getElementById('photos-session-status').innerText = "Error: " + result.message; + } + }) + .checkPhotoSession(sessionId); + } + + function processPickedPhotos(items) { + // Reuse importFromPicker logic logic? + // We can call importFromPicker for each item. + let processed = 0; + + items.forEach(item => { + // console.log("Processing item:", item); + // The new Picker API returns baseUrl nested inside mediaFile + const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl; + + google.script.run + .withSuccessHandler(() => { + processed++; + if (processed === items.length) { + document.getElementById('photos-session-status').innerText = "Done!"; + loadMedia(currentSku); + setTimeout(() => { + document.getElementById('photos-session-ui').style.display = 'none'; + }, 3000); + } + }) + .importFromPicker(currentSku, null, item.mimeType, item.filename, imageUrl); + }); + } // Start diff --git a/src/appsscript.json b/src/appsscript.json index 029a0bb..da78956 100644 --- a/src/appsscript.json +++ b/src/appsscript.json @@ -1,6 +1,13 @@ { "timeZone": "America/Denver", "dependencies": { + "enabledAdvancedServices": [ + { + "userSymbol": "Drive", + "serviceId": "drive", + "version": "v2" + } + ] }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", @@ -10,6 +17,7 @@ "https://www.googleapis.com/auth/script.container.ui", "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/userinfo.email", + "https://www.googleapis.com/auth/photospicker.mediaitems.readonly" ] } diff --git a/src/global.ts b/src/global.ts index ab1bc45..c42bc3e 100644 --- a/src/global.ts +++ b/src/global.ts @@ -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 { showMediaSidebar, getSelectedSku, getMediaForSku, saveFileToDrive, syncMediaForSku, getPickerConfig, importFromPicker } from "./mediaHandlers" +import { showMediaSidebar, getSelectedSku, getMediaForSku, saveFileToDrive, syncMediaForSku, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers" import { runSystemDiagnostics } from "./verificationSuite" // prettier-ignore @@ -59,3 +59,7 @@ import { runSystemDiagnostics } from "./verificationSuite" ;(global as any).getPickerConfig = getPickerConfig ;(global as any).importFromPicker = importFromPicker ;(global as any).runSystemDiagnostics = runSystemDiagnostics +;(global as any).debugScopes = debugScopes +;(global as any).createPhotoSession = createPhotoSession +;(global as any).checkPhotoSession = checkPhotoSession +;(global as any).debugFolderAccess = debugFolderAccess diff --git a/src/initMenu.ts b/src/initMenu.ts index bb023b8..20b5a7a 100644 --- a/src/initMenu.ts +++ b/src/initMenu.ts @@ -6,7 +6,7 @@ import { reinstallTriggers, installSalesSyncTrigger } from "./triggers" import { reconcileSalesHandler } from "./salesSync" import { toastAndLog } from "./sheetUtils" import { showSidebar } from "./sidebar" -import { showMediaSidebar } from "./mediaHandlers" +import { showMediaSidebar, debugScopes } from "./mediaHandlers" import { runSystemDiagnostics } from "./verificationSuite" export function initMenu() { @@ -38,6 +38,8 @@ export function initMenu() { .addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name) .addItem("Troubleshoot", showSidebar.name) .addItem("Run System Diagnostics", runSystemDiagnostics.name) + .addItem("Debug Scopes", "debugScopes") + .addItem("Debug Folder Access", "debugFolderAccess") ) .addToUi() } diff --git a/src/mediaHandlers.ts b/src/mediaHandlers.ts index 30e6bf0..6841906 100644 --- a/src/mediaHandlers.ts +++ b/src/mediaHandlers.ts @@ -37,10 +37,10 @@ export function getPickerConfig() { } export function getMediaForSku(sku: string): any[] { - const config = new Config() const driveService = new GASDriveService() try { + const config = new Config() // Moved inside try block to catch init errors const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId) const files = driveService.getFiles(folder.getId()) @@ -80,19 +80,128 @@ export function saveFileToDrive(sku: string, filename: string, mimeType: string, // Picker Callback specific handler if needed, or we just rely on frontend passing back file ID // Implementing a "copy from Picker" handler -export function importFromPicker(sku: string, fileId: string, mimeType: string) { - const config = new Config() +// 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); + } - // Check if file is already in our folder structure? - // If user picks from "Photos", it's a separate Blob. We might need to copy it to our SKU folder. - // Use DriveApp to get the file (if we have permissions) and make a copy. + console.log(`importFromPicker starting for SKU: ${sku}`); + + // STEP 1: Acquire/Create File in Root (Safe Zone) + let finalFile: GoogleAppsScript.Drive.File; + + 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) { + // Case B: URL (Photos) -> Blob -> File + // Handling high-res parameter + if (imageUrl.includes("googleusercontent.com") && !imageUrl.includes("=d")) { + imageUrl += "=d"; // Download param + } + const response = UrlFetchApp.fetch(imageUrl, { + 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}`); + } + 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)}`); + } + + const fileName = name || `photo_${Date.now()}.jpg`; + blob.setName(fileName); + + 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()}`); + } 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. + + if (typeof Drive === 'undefined') { + throw new Error("Advanced Drive Service is not enabled. Please enable 'Drive API' in Apps Script Services."); + } + + const drive = Drive as any; + let insertedFile; + + 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)."); + } + + 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}`); + } + } + + } else { + throw new Error("No File ID and No Image URL provided."); + } + } catch (e) { + console.error("Step 1 Failed (File Creation)", e); + throw e; // Re-throw modified error + } + + // 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); + // 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 + try { + finalFile.moveTo(folder); + console.log(`Step 3 Success: File 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}`); + } - console.log(`Importing ${fileId} for ${sku}`) - const file = DriveApp.getFileById(fileId) // Assuming we have scope - const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId) - file.makeCopy(file.getName(), folder) } export function syncMediaForSku(sku: string) { @@ -120,3 +229,129 @@ export function syncMediaForSku(sku: string) { // Update thumbnail in sheet // TODO: Implement thumbnail update in sheet if desired } + +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 }; + } +}