- Update mediaHandlers.ts to accept an optional forcedThumbnailUrl in updateSpreadsheetThumbnail, enabling updates without re-fetching backend state. - Update MediaManager.html execution plan to trigger the sheet update immediately (optimistically) using the predicted first item from the plan, running in parallel with other execution phases. - Ensure the execution flow waits for both the sheet update and other operations to complete before finishing.
653 lines
25 KiB
TypeScript
653 lines
25 KiB
TypeScript
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 };
|
|
}
|
|
}
|