Compare commits
2 Commits
95094b1674
...
3da46958f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 3da46958f7 | |||
| 50ddfc9e15 |
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.
|
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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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,15 +334,105 @@
|
|||||||
|
|
||||||
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
|
||||||
init();
|
init();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
378
src/mediaHandlers.test.ts
Normal file
378
src/mediaHandlers.test.ts
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
|
||||||
|
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaSidebar, getSelectedSku, getPickerConfig, saveFileToDrive, syncMediaForSku, debugScopes } from "./mediaHandlers"
|
||||||
|
import { Config } from "./config"
|
||||||
|
import { GASDriveService } from "./services/GASDriveService"
|
||||||
|
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||||
|
import { MediaService } from "./services/MediaService"
|
||||||
|
import { Product } from "./Product"
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
// Mock Config
|
||||||
|
jest.mock("./config", () => {
|
||||||
|
return {
|
||||||
|
Config: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
productPhotosFolderId: "root_photos_folder",
|
||||||
|
googlePickerApiKey: "key123"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.mock("./services/GASNetworkService")
|
||||||
|
jest.mock("./services/ShopifyMediaService")
|
||||||
|
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
|
||||||
|
jest.mock("./services/MediaService", () => ({ MediaService: jest.fn().mockReturnValue({ syncMediaForSku: jest.fn() }) }))
|
||||||
|
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) }))
|
||||||
|
|
||||||
|
|
||||||
|
// Mock GASDriveService
|
||||||
|
const mockGetOrCreateFolder = jest.fn()
|
||||||
|
const mockGetFiles = jest.fn()
|
||||||
|
jest.mock("./services/GASDriveService", () => {
|
||||||
|
return {
|
||||||
|
GASDriveService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
getOrCreateFolder: mockGetOrCreateFolder,
|
||||||
|
getFiles: mockGetFiles,
|
||||||
|
saveFile: jest.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock GASSpreadsheetService
|
||||||
|
jest.mock("./services/GASSpreadsheetService", () => {
|
||||||
|
return {
|
||||||
|
GASSpreadsheetService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
getCellValueByColumnName: jest.fn().mockReturnValue("TEST-SKU")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock Global GAS services
|
||||||
|
const mockFile = {
|
||||||
|
getId: jest.fn().mockReturnValue("new_file_id"),
|
||||||
|
getName: jest.fn().mockReturnValue("photo.jpg"),
|
||||||
|
moveTo: jest.fn(),
|
||||||
|
getThumbnail: jest.fn().mockReturnValue({ getBytes: () => [] }),
|
||||||
|
getMimeType: jest.fn().mockReturnValue("image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockFolder = {
|
||||||
|
getId: jest.fn().mockReturnValue("target_folder_id"),
|
||||||
|
getName: jest.fn().mockReturnValue("SKU-FOLDER"),
|
||||||
|
getUrl: jest.fn().mockReturnValue("http://drive/folder")
|
||||||
|
} // This is returned by getOrCreateFolder
|
||||||
|
|
||||||
|
// DriveApp
|
||||||
|
global.DriveApp = {
|
||||||
|
getFileById: jest.fn(),
|
||||||
|
createFile: jest.fn(),
|
||||||
|
getFolderById: jest.fn(),
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// SpreadsheetApp
|
||||||
|
global.SpreadsheetApp = {
|
||||||
|
getUi: jest.fn(),
|
||||||
|
getActiveSheet: jest.fn().mockReturnValue({
|
||||||
|
getName: jest.fn().mockReturnValue("product_inventory"),
|
||||||
|
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 })
|
||||||
|
}),
|
||||||
|
getActive: jest.fn()
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// UrlFetchApp
|
||||||
|
global.UrlFetchApp = {
|
||||||
|
fetch: jest.fn(),
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// ScriptApp
|
||||||
|
global.ScriptApp = {
|
||||||
|
getOAuthToken: jest.fn().mockReturnValue("mock_token"),
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
global.Utilities = {
|
||||||
|
newBlob: jest.fn().mockImplementation((bytes, mime, name) => ({
|
||||||
|
getBytes: () => bytes,
|
||||||
|
getContentType: () => mime,
|
||||||
|
setName: jest.fn(),
|
||||||
|
getName: () => name,
|
||||||
|
copyBlob: jest.fn()
|
||||||
|
})),
|
||||||
|
base64Decode: jest.fn().mockReturnValue([]),
|
||||||
|
base64Encode: jest.fn().mockReturnValue("encoded_thumb"),
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// Advanced Drive Service
|
||||||
|
global.Drive = {
|
||||||
|
Files: {
|
||||||
|
insert: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
}
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// Session
|
||||||
|
global.Session = {
|
||||||
|
getActiveUser: () => ({ getEmail: () => "user@test.com" }),
|
||||||
|
getEffectiveUser: () => ({ getEmail: () => "le@test.com" })
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// HtmlService
|
||||||
|
global.HtmlService = {
|
||||||
|
createHtmlOutputFromFile: jest.fn().mockReturnValue({
|
||||||
|
setTitle: jest.fn().mockReturnThis(),
|
||||||
|
setWidth: jest.fn().mockReturnThis()
|
||||||
|
})
|
||||||
|
} as any
|
||||||
|
|
||||||
|
|
||||||
|
describe("mediaHandlers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
// Default Mock Behaviors
|
||||||
|
mockGetOrCreateFolder.mockReturnValue(mockFolder)
|
||||||
|
|
||||||
|
// DriveApp defaults
|
||||||
|
;(DriveApp.getFileById as jest.Mock).mockReturnValue({
|
||||||
|
makeCopy: jest.fn().mockReturnValue(mockFile),
|
||||||
|
getName: () => "File",
|
||||||
|
getMimeType: () => "image/jpeg"
|
||||||
|
})
|
||||||
|
;(DriveApp.createFile as jest.Mock).mockReturnValue(mockFile)
|
||||||
|
;(DriveApp.getFolderById as jest.Mock).mockReturnValue(mockFolder)
|
||||||
|
|
||||||
|
// UrlFetchApp defaults
|
||||||
|
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
||||||
|
getResponseCode: () => 200,
|
||||||
|
getBlob: () => ({
|
||||||
|
setName: jest.fn(),
|
||||||
|
getContentType: () => "image/jpeg",
|
||||||
|
getBytes: () => [1, 2, 3]
|
||||||
|
}),
|
||||||
|
getContentText: () => ""
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset mockFile.moveTo implementation
|
||||||
|
mockFile.moveTo.mockReset()
|
||||||
|
mockFile.moveTo.mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("importFromPicker", () => {
|
||||||
|
test("should import from Drive File ID (Copy)", () => {
|
||||||
|
importFromPicker("SKU123", "source_file_id", "image/jpeg", "myphoto.jpg", null)
|
||||||
|
expect(DriveApp.getFileById).toHaveBeenCalledWith("source_file_id")
|
||||||
|
expect(mockGetOrCreateFolder).toHaveBeenCalledWith("SKU123", "root_photos_folder")
|
||||||
|
expect(mockFile.moveTo).toHaveBeenCalledWith(mockFolder)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should import from URL (Download) - Happy Path", () => {
|
||||||
|
importFromPicker("SKU123", null, "image/jpeg", "download.jpg", "https://photos.google.com/img")
|
||||||
|
expect(UrlFetchApp.fetch).toHaveBeenCalled()
|
||||||
|
expect(DriveApp.createFile).toHaveBeenCalled()
|
||||||
|
expect(mockGetOrCreateFolder).toHaveBeenCalled()
|
||||||
|
expect(mockFile.moveTo).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should handle 403 Forbidden on Download", () => {
|
||||||
|
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
||||||
|
getResponseCode: () => 403,
|
||||||
|
getContentText: () => "Forbidden"
|
||||||
|
})
|
||||||
|
expect(() => {
|
||||||
|
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://bad.url")
|
||||||
|
}).toThrow("returned code 403")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should fallback to Advanced Drive API if DriveApp.createFile fails", () => {
|
||||||
|
;(DriveApp.createFile as jest.Mock).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Server Error")
|
||||||
|
})
|
||||||
|
;(Drive.Files.create as jest.Mock).mockReturnValue({ id: "adv_file_id" })
|
||||||
|
;(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile)
|
||||||
|
|
||||||
|
importFromPicker("SKU123", null, "image/jpeg", "fallback.jpg", "https://url")
|
||||||
|
|
||||||
|
expect(DriveApp.createFile).toHaveBeenCalled()
|
||||||
|
expect(Drive.Files.create).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should throw if folder access fails (Step 2)", () => {
|
||||||
|
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Folder Access Error") })
|
||||||
|
expect(() => {
|
||||||
|
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://url")
|
||||||
|
}).toThrow(/failed to put in SKU folder/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should throw if move fails (Step 3)", () => {
|
||||||
|
;(DriveApp.createFile as jest.Mock).mockReturnValue(mockFile)
|
||||||
|
mockFile.moveTo.mockImplementation(() => { throw new Error("Move Error") })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://url")
|
||||||
|
}).toThrow(/failed to move to folder/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getMediaForSku", () => {
|
||||||
|
test("should return mapped files", () => {
|
||||||
|
mockGetFiles.mockReturnValue([mockFile])
|
||||||
|
const result = getMediaForSku("SKU123")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("new_file_id")
|
||||||
|
expect(result[0].thumbnailLink).toContain("data:image/png;base64,encoded_thumb")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should handle thumbnail error", () => {
|
||||||
|
const badFile = {
|
||||||
|
getId: () => "bad_id",
|
||||||
|
getName: () => "bad.jpg",
|
||||||
|
getThumbnail: jest.fn().mockImplementation(() => { throw new Error("Thumb error") }),
|
||||||
|
getMimeType: () => "image/jpeg"
|
||||||
|
}
|
||||||
|
mockGetFiles.mockReturnValue([badFile])
|
||||||
|
|
||||||
|
const result = getMediaForSku("SKU123")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].thumbnailLink).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should return empty array on fatal error", () => {
|
||||||
|
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Fatal config") })
|
||||||
|
const result = getMediaForSku("SKU123")
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Photo Session API", () => {
|
||||||
|
const mockSessionId = "sess_123"
|
||||||
|
const mockPickerUri = "https://photos.google.com/picker"
|
||||||
|
|
||||||
|
test("createPhotoSession should return session data", () => {
|
||||||
|
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
||||||
|
getContentText: () => JSON.stringify({ id: mockSessionId, pickerUri: mockPickerUri })
|
||||||
|
})
|
||||||
|
const result = createPhotoSession()
|
||||||
|
expect(result).toEqual({ id: mockSessionId, pickerUri: mockPickerUri })
|
||||||
|
expect(UrlFetchApp.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("sessions"),
|
||||||
|
expect.objectContaining({ method: "post" })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("checkPhotoSession should return media items", () => {
|
||||||
|
const mockItems = [{ id: "photo1" }]
|
||||||
|
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
||||||
|
getContentText: () => JSON.stringify({ mediaItems: mockItems }),
|
||||||
|
getResponseCode: () => 200
|
||||||
|
})
|
||||||
|
const result = checkPhotoSession(mockSessionId)
|
||||||
|
expect(result).toEqual({ status: 'complete', mediaItems: mockItems })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("checkPhotoSession should return waiting if empty", () => {
|
||||||
|
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
||||||
|
getContentText: () => JSON.stringify({}),
|
||||||
|
getResponseCode: () => 200
|
||||||
|
})
|
||||||
|
const result = checkPhotoSession(mockSessionId)
|
||||||
|
expect(result).toEqual({ status: 'waiting' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("checkPhotoSession should return waiting if 400ish", () => {
|
||||||
|
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
||||||
|
getContentText: () => "Not ready",
|
||||||
|
getResponseCode: () => 400
|
||||||
|
})
|
||||||
|
const result = checkPhotoSession(mockSessionId)
|
||||||
|
expect(result).toEqual({ status: 'waiting' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("checkPhotoSession should return error state on exception", () => {
|
||||||
|
;(UrlFetchApp.fetch as jest.Mock).mockImplementation(() => { throw new Error("Network fail") })
|
||||||
|
const result = checkPhotoSession(mockSessionId)
|
||||||
|
expect(result.status).toBe("error")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("debugFolderAccess", () => {
|
||||||
|
test("should work with valid config", () => {
|
||||||
|
const mockUi = { alert: jest.fn(), ButtonSet: { OK: "OK" } }
|
||||||
|
;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi)
|
||||||
|
|
||||||
|
debugFolderAccess()
|
||||||
|
|
||||||
|
expect(mockUi.alert).toHaveBeenCalledWith("Folder Access Debug", expect.stringContaining("Success!"), expect.anything())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Utility Functions", () => {
|
||||||
|
test("showMediaSidebar should render template", () => {
|
||||||
|
const mockUi = { showSidebar: jest.fn() }
|
||||||
|
;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi)
|
||||||
|
|
||||||
|
showMediaSidebar()
|
||||||
|
|
||||||
|
expect(global.HtmlService.createHtmlOutputFromFile).toHaveBeenCalledWith("MediaSidebar")
|
||||||
|
expect(mockUi.showSidebar).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("getSelectedSku should return sku from sheet", () => {
|
||||||
|
const sku = getSelectedSku()
|
||||||
|
expect(sku).toBe("TEST-SKU")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("getPickerConfig should return config", () => {
|
||||||
|
const conf = getPickerConfig()
|
||||||
|
expect(conf.apiKey).toBe("key123")
|
||||||
|
expect(conf.token).toBe("mock_token")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("saveFileToDrive should save blob", () => {
|
||||||
|
saveFileToDrive("SKU", "name.jpg", "image/jpeg", "base64data")
|
||||||
|
|
||||||
|
expect(Utilities.base64Decode).toHaveBeenCalled()
|
||||||
|
expect(Utilities.newBlob).toHaveBeenCalled()
|
||||||
|
expect(mockGetOrCreateFolder).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("syncMediaForSku should trigger media service sync", () => {
|
||||||
|
syncMediaForSku("SKU123")
|
||||||
|
// Expect MediaService to be called
|
||||||
|
// how to access mock?
|
||||||
|
const { MediaService } = require("./services/MediaService")
|
||||||
|
const mockInstance = MediaService.mock.results[0].value
|
||||||
|
expect(mockInstance.syncMediaForSku).toHaveBeenCalledWith("SKU123", "123")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("syncMediaForSku should try to match product if id missing", () => {
|
||||||
|
// Override Product mock for this test
|
||||||
|
const { Product } = require("./Product")
|
||||||
|
const mockMatch = jest.fn()
|
||||||
|
Product.mockImplementationOnce(() => ({
|
||||||
|
shopify_id: null,
|
||||||
|
MatchToShopifyProduct: mockMatch
|
||||||
|
}))
|
||||||
|
|
||||||
|
// It will throw "Product not found" because we didn't update the ID (unless we simulate side effect)
|
||||||
|
// But we can check if MatchToShopifyProduct was called
|
||||||
|
try {
|
||||||
|
syncMediaForSku("SKU_NEW")
|
||||||
|
} catch (e) {
|
||||||
|
// Expected because shopify_id is still null
|
||||||
|
}
|
||||||
|
expect(mockMatch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("debugScopes should log token", () => {
|
||||||
|
debugScopes()
|
||||||
|
expect(ScriptApp.getOAuthToken).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user