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.
|
||||
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,13 +334,103 @@
|
||||
|
||||
function pickerCallback(data) {
|
||||
if (data.action == google.picker.Action.PICKED) {
|
||||
const fileId = data.docs[0].id;
|
||||
const mimeType = data.docs[0].mimeType;
|
||||
const doc = data.docs[0];
|
||||
const fileId = doc.id;
|
||||
const mimeType = doc.mimeType;
|
||||
const name = doc.name;
|
||||
const url = doc.url; // Often the link to the file in Drive/Photos
|
||||
|
||||
// For Photos, we might need the direct image URL, which is often in thumbnails or requires specific handling
|
||||
// doc.thumbnails contains 's75-c' style URLs. We can strip the size to get full size?
|
||||
// Actually, for Photos API items, 'url' might be the user-facing URL.
|
||||
// Let's pass the 'thumbnails' closest to original if possible, or just pass the whole doc object to backend?
|
||||
// Simpler: pass specific fields.
|
||||
|
||||
const imageUrl = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(() => loadMedia(currentSku))
|
||||
.importFromPicker(currentSku, fileId, mimeType);
|
||||
}
|
||||
.importFromPicker(currentSku, fileId, mimeType, name, imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Photos Session Logic (New API) ---
|
||||
|
||||
let pollingTimer = null;
|
||||
|
||||
function startPhotoSession() {
|
||||
// Reset UI
|
||||
document.getElementById('photos-session-ui').style.display = 'block';
|
||||
document.getElementById('photos-session-status').innerText = "Creating session...";
|
||||
document.getElementById('photos-session-link').style.display = 'none';
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(onSessionCreated)
|
||||
.withFailureHandler(e => {
|
||||
alert('Failed to start session: ' + e.message);
|
||||
document.getElementById('photos-session-ui').style.display = 'none';
|
||||
})
|
||||
.createPhotoSession();
|
||||
}
|
||||
|
||||
function onSessionCreated(session) {
|
||||
if (!session || !session.pickerUri) {
|
||||
alert("Failed to get picker URI");
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.getElementById('photos-session-link');
|
||||
link.href = session.pickerUri;
|
||||
link.style.display = 'block';
|
||||
link.innerText = "Click here to pick photos ↗";
|
||||
|
||||
document.getElementById('photos-session-status').innerText = "Waiting for you to pick photos...";
|
||||
|
||||
// Open automatically? Browsers block it. User must click.
|
||||
// Start polling
|
||||
if (pollingTimer) clearInterval(pollingTimer);
|
||||
pollingTimer = setInterval(() => pollSession(session.id), 2000); // Poll every 2s
|
||||
}
|
||||
|
||||
function pollSession(sessionId) {
|
||||
google.script.run
|
||||
.withSuccessHandler(result => {
|
||||
console.log("Poll result:", result);
|
||||
if (result.status === 'complete') {
|
||||
clearInterval(pollingTimer);
|
||||
document.getElementById('photos-session-status').innerText = "Importing photos...";
|
||||
processPickedPhotos(result.mediaItems);
|
||||
} else if (result.status === 'error') {
|
||||
document.getElementById('photos-session-status').innerText = "Error: " + result.message;
|
||||
}
|
||||
})
|
||||
.checkPhotoSession(sessionId);
|
||||
}
|
||||
|
||||
function processPickedPhotos(items) {
|
||||
// Reuse importFromPicker logic logic?
|
||||
// We can call importFromPicker for each item.
|
||||
let processed = 0;
|
||||
|
||||
items.forEach(item => {
|
||||
// console.log("Processing item:", item);
|
||||
// The new Picker API returns baseUrl nested inside mediaFile
|
||||
const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(() => {
|
||||
processed++;
|
||||
if (processed === items.length) {
|
||||
document.getElementById('photos-session-status').innerText = "Done!";
|
||||
loadMedia(currentSku);
|
||||
setTimeout(() => {
|
||||
document.getElementById('photos-session-ui').style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
.importFromPicker(currentSku, null, item.mimeType, item.filename, imageUrl);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Start
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
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[] {
|
||||
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