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.
This commit is contained in:
2025-12-26 01:51:04 -07:00
parent 95094b1674
commit 50ddfc9e15
7 changed files with 403 additions and 22 deletions

View File

@ -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. 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`). 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. 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.

View File

@ -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. 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.

View File

@ -162,8 +162,21 @@
</div> </div>
<button onclick="openPicker()" class="btn btn-secondary" style="margin-top: 8px; font-size: 13px;"> <button onclick="openPicker()" class="btn btn-secondary" style="margin-top: 8px; font-size: 13px;">
Import from Google Drive / Photos Import from Google Drive
</button> </button>
<button onclick="startPhotoSession()" class="btn btn-secondary" style="margin-top: 4px; font-size: 13px;">
Import from Google Photos
</button>
<div id="photos-session-ui"
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
<div style="font-weight:500; font-size:13px; margin-bottom:4px;">Pick Photos</div>
<a id="photos-session-link" href="#" target="_blank"
style="font-size:13px; color:#0284c7; text-decoration:none; display:block; margin-bottom:8px;">
Active Session (Click to Open) ↗
</a>
<div id="photos-session-status" style="font-size:11px; color:#64748b;">Waiting for selection...</div>
</div>
</div> </div>
<div class="card"> <div class="card">
@ -308,13 +321,11 @@
.setIncludeFolders(true) .setIncludeFolders(true)
.setSelectFolderEnabled(false); .setSelectFolderEnabled(false);
const photosView = new google.picker.PhotosView();
const picker = new google.picker.PickerBuilder() const picker = new google.picker.PickerBuilder()
.addView(view) .addView(view)
.addView(photosView)
.setOAuthToken(config.token) .setOAuthToken(config.token)
.setDeveloperKey(config.apiKey) .setDeveloperKey(config.apiKey)
.setOrigin(google.script.host.origin)
.setCallback(pickerCallback) .setCallback(pickerCallback)
.build(); .build();
@ -323,13 +334,103 @@
function pickerCallback(data) { function pickerCallback(data) {
if (data.action == google.picker.Action.PICKED) { if (data.action == google.picker.Action.PICKED) {
const fileId = data.docs[0].id; const doc = data.docs[0];
const mimeType = data.docs[0].mimeType; 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 google.script.run
.withSuccessHandler(() => loadMedia(currentSku)) .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 // Start

View File

@ -1,6 +1,13 @@
{ {
"timeZone": "America/Denver", "timeZone": "America/Denver",
"dependencies": { "dependencies": {
"enabledAdvancedServices": [
{
"userSymbol": "Drive",
"serviceId": "drive",
"version": "v2"
}
]
}, },
"exceptionLogging": "STACKDRIVER", "exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8", "runtimeVersion": "V8",
@ -10,6 +17,7 @@
"https://www.googleapis.com/auth/script.container.ui", "https://www.googleapis.com/auth/script.container.ui",
"https://www.googleapis.com/auth/script.scriptapp", "https://www.googleapis.com/auth/script.scriptapp",
"https://www.googleapis.com/auth/drive", "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"
] ]
} }

View File

@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar" import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync" import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
import { installSalesSyncTrigger } from "./triggers" import { installSalesSyncTrigger } from "./triggers"
import { 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" import { runSystemDiagnostics } from "./verificationSuite"
// prettier-ignore // prettier-ignore
@ -59,3 +59,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).getPickerConfig = getPickerConfig ;(global as any).getPickerConfig = getPickerConfig
;(global as any).importFromPicker = importFromPicker ;(global as any).importFromPicker = importFromPicker
;(global as any).runSystemDiagnostics = runSystemDiagnostics ;(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

View File

@ -6,7 +6,7 @@ import { reinstallTriggers, installSalesSyncTrigger } from "./triggers"
import { reconcileSalesHandler } from "./salesSync" import { reconcileSalesHandler } from "./salesSync"
import { toastAndLog } from "./sheetUtils" import { toastAndLog } from "./sheetUtils"
import { showSidebar } from "./sidebar" import { showSidebar } from "./sidebar"
import { showMediaSidebar } from "./mediaHandlers" import { showMediaSidebar, debugScopes } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite" import { runSystemDiagnostics } from "./verificationSuite"
export function initMenu() { export function initMenu() {
@ -38,6 +38,8 @@ export function initMenu() {
.addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name) .addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name)
.addItem("Troubleshoot", showSidebar.name) .addItem("Troubleshoot", showSidebar.name)
.addItem("Run System Diagnostics", runSystemDiagnostics.name) .addItem("Run System Diagnostics", runSystemDiagnostics.name)
.addItem("Debug Scopes", "debugScopes")
.addItem("Debug Folder Access", "debugFolderAccess")
) )
.addToUi() .addToUi()
} }

View File

@ -37,10 +37,10 @@ export function getPickerConfig() {
} }
export function getMediaForSku(sku: string): any[] { export function getMediaForSku(sku: string): any[] {
const config = new Config()
const driveService = new GASDriveService() const driveService = new GASDriveService()
try { try {
const config = new Config() // Moved inside try block to catch init errors
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId) const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
const files = driveService.getFiles(folder.getId()) 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 // 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) { // Implementing a "copy from Picker" handler
const config = new Config() export function importFromPicker(sku: string, fileId: string, mimeType: string, name: string, imageUrl: string | null) {
const driveService = new GASDriveService() 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? console.log(`importFromPicker starting for SKU: ${sku}`);
// 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. // 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) { export function syncMediaForSku(sku: string) {
@ -120,3 +229,129 @@ export function syncMediaForSku(sku: string) {
// Update thumbnail in sheet // Update thumbnail in sheet
// TODO: Implement thumbnail update in sheet if desired // 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 };
}
}