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:
10
MEMORY.md
10
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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -162,8 +162,21 @@
|
||||
</div>
|
||||
|
||||
<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 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 class="card">
|
||||
@ -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,15 +334,105 @@
|
||||
|
||||
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
|
||||
init();
|
||||
</script>
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user