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 @@
- Import from Google Drive / Photos
+ Import from Google Drive
+
+ Import from Google Photos
+
+
+
@@ -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 };
+ }
+}