feat(media): implement integrated media manager with sidebar and picker

- Implement DriveService and ShopifyMediaService for backend operations
- Create MediaSidebar.html with premium UI and auto-polling
- Integrate Google Picker API for robust file selection
- Orchestrate sync logic via MediaService (Drive -> Staged Upload -> Shopify)
- Add secure config handling for API keys and tokens
- Update ppsscript.json with required OAuth scopes
- Update MEMORY.md and README.md with architecture details
This commit is contained in:
Ben Miller
2025-12-25 15:10:17 -07:00
parent 2417359595
commit 95094b1674
21 changed files with 973 additions and 16 deletions

View File

@ -24,3 +24,11 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
- **OS**: Windows. - **OS**: Windows.
- **Shell**: PowerShell. - **Shell**: PowerShell.
- **Node Manager**: `fnm`. - **Node Manager**: `fnm`.
28:
29: ## Integrated Media Manager
30: We implemented a "Sidebar-First" architecture for product media (Option 2):
31: - **Frontend**: `MediaSidebar.html` uses Glassmorphism CSS and Client-Side Polling to detect SKU changes.
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.

View File

@ -14,6 +14,7 @@ The system allows you to:
- **Automated Sales Sync**: Periodically check Shopify for recent sales and mark items as "sold" in the sheet. - **Automated Sales Sync**: Periodically check Shopify for recent sales and mark items as "sold" in the sheet.
- **Manual Reconciliation**: Backfill sales data for a specific time range via menu command. - **Manual Reconciliation**: Backfill sales data for a specific time range via menu command.
- **Status Workflow Automation**: Automatically update Shopify status and inventory based on the sheet's "status" column (e.g., "Sold" -> Active, 0 Qty). - **Status Workflow Automation**: Automatically update Shopify status and inventory based on the sheet's "status" column (e.g., "Sold" -> Active, 0 Qty).
- **Integrated Media Manager**: A dedicated sidebar for managing product photos, including Google Drive integration and live Shopify syncing.
## Prerequisites ## Prerequisites

341
src/MediaSidebar.html Normal file
View File

@ -0,0 +1,341 @@
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--primary: #2563eb;
--surface: #ffffff;
--background: #f8fafc;
--text: #1e293b;
--text-secondary: #64748b;
--border: #e2e8f0;
--danger: #ef4444;
}
body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 16px;
background-color: var(--background);
color: var(--text);
}
.card {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.sku-badge {
background: #dbeafe;
color: #1e40af;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.upload-zone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: transparent;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: var(--primary);
background: rgba(37, 99, 235, 0.05);
}
.media-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
}
.media-item {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.media-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.btn {
background-color: var(--primary);
color: white;
border: none;
padding: 10px 16px;
border-radius: 8px;
width: 100%;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover {
background-color: #1d4ed8;
}
.btn-secondary {
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.empty-state {
text-align: center;
color: var(--text-secondary);
font-size: 13px;
padding: 20px 0;
}
</style>
</head>
<body>
<div id="main-ui" style="display:none">
<div class="card">
<div class="header">
<h2>Media Manager</h2>
<span id="current-sku" class="sku-badge">...</span>
</div>
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div style="font-size: 24px; margin-bottom: 8px;">☁️</div>
<div style="font-size: 13px; color: var(--text-secondary);">
Drop files or click to upload<br>
<span style="font-size: 11px; opacity: 0.7">(Goes to Drive first)</span>
</div>
<input type="file" id="file-input" multiple style="display:none" onchange="handleFiles(this.files)">
</div>
<button onclick="openPicker()" class="btn btn-secondary" style="margin-top: 8px; font-size: 13px;">
Import from Google Drive / Photos
</button>
</div>
<div class="card">
<div class="header">
<h2>Current Media</h2>
<button onclick="loadMedia()" style="background:none; border:none; cursor:pointer; font-size:16px;"></button>
</div>
<div id="media-grid" class="media-grid">
<!-- Items injected here -->
</div>
</div>
<button onclick="triggerSync()" class="btn">Sync to Shopify</button>
</div>
<div id="loading-ui" style="text-align:center; padding-top: 50px;">
<div class="spinner"></div>
<div style="margin-top:12px; color: var(--text-secondary); font-size: 13px;">Scanning Sheet...</div>
</div>
<script type="text/javascript">
let currentSku = "";
let pollInterval;
// Picker Globals
let pickerApiLoaded = false;
let pickerConfig = null;
function onApiLoad() {
gapi.load('picker', () => {
pickerApiLoaded = true;
});
}
function init() {
pollInterval = setInterval(checkSelection, 2000);
checkSelection();
}
function checkSelection() {
google.script.run
.withSuccessHandler(onSelectionCheck)
.withFailureHandler(console.error)
.getSelectedSku();
}
function onSelectionCheck(sku) {
if (sku && sku !== currentSku) {
currentSku = sku;
updateUI(sku);
loadMedia(sku);
} else if (!sku) {
// Show "Select a SKU" state?
// For now, keep showing last or show loading
}
}
function updateUI(sku) {
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
document.getElementById('current-sku').innerText = sku;
}
function loadMedia(sku) {
if (!sku) sku = currentSku;
const grid = document.getElementById('media-grid');
grid.innerHTML = '<div style="grid-column: span 2; text-align:center; padding: 20px;"><div class="spinner"></div></div>';
google.script.run
.withSuccessHandler(renderMedia)
.getMediaForSku(sku);
}
function renderMedia(files) {
const grid = document.getElementById('media-grid');
grid.innerHTML = '';
if (!files || files.length === 0) {
grid.innerHTML = '<div class="empty-state" style="grid-column: span 2">No media in Drive folder</div>';
return;
}
files.forEach(f => {
const div = document.createElement('div');
div.className = 'media-item';
div.innerHTML = `<img src="${f.thumbnailLink}" title="${f.name}">`;
grid.appendChild(div);
});
}
function handleFiles(files) {
const file = files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
const data = e.target.result.split(',')[1];
google.script.run
.withSuccessHandler(() => loadMedia(currentSku))
.saveFileToDrive(currentSku, file.name, file.type, data);
};
reader.readAsDataURL(file);
}
function triggerSync() {
if (!currentSku) return;
google.script.run
.withSuccessHandler(() => alert('Sync Complete'))
.withFailureHandler(e => alert('Failed: ' + e.message))
.syncMediaForSku(currentSku);
}
// --- Picker Logic ---
function openPicker() {
if (!pickerApiLoaded) {
alert("Google Picker API not loaded yet. Please wait.");
return;
}
if (pickerConfig) {
createPicker(pickerConfig);
} else {
google.script.run
.withSuccessHandler((config) => {
pickerConfig = config;
createPicker(config);
})
.withFailureHandler(e => alert('Failed to load picker config: ' + e.message))
.getPickerConfig();
}
}
function createPicker(config) {
if (!config.apiKey) {
alert("Google Picker API Key missing. Please check config.");
return;
}
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
.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)
.setCallback(pickerCallback)
.build();
picker.setVisible(true);
}
function pickerCallback(data) {
if (data.action == google.picker.Action.PICKED) {
const fileId = data.docs[0].id;
const mimeType = data.docs[0].mimeType;
google.script.run
.withSuccessHandler(() => loadMedia(currentSku))
.importFromPicker(currentSku, fileId, mimeType);
}
}
// Start
init();
</script>
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
</body>
</html>

View File

@ -15,6 +15,8 @@ import { Config } from "./config"
import { ISpreadsheetService } from "./interfaces/ISpreadsheetService" import { ISpreadsheetService } from "./interfaces/ISpreadsheetService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService" import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
import { IShop } from "./interfaces/IShop" import { IShop } from "./interfaces/IShop"
import { IDriveService } from "./interfaces/IDriveService"
import { GASDriveService } from "./services/GASDriveService"
export class Product { export class Product {
shopify_id: string = "" shopify_id: string = ""
@ -44,9 +46,11 @@ export class Product {
shopify_status: string = "" shopify_status: string = ""
private sheetService: ISpreadsheetService private sheetService: ISpreadsheetService
private driveService: IDriveService
constructor(sku: string = "", sheetService: ISpreadsheetService = new GASSpreadsheetService()) { constructor(sku: string = "", sheetService: ISpreadsheetService = new GASSpreadsheetService(), driveService: IDriveService = new GASDriveService()) {
this.sheetService = sheetService; this.sheetService = sheetService;
this.driveService = driveService;
if (sku == "") { if (sku == "") {
return return
} }
@ -349,7 +353,7 @@ export class Product {
CreatePhotoFolder() { CreatePhotoFolder() {
console.log("Product.CreatePhotoFolder()"); console.log("Product.CreatePhotoFolder()");
createPhotoFolderForSku(new(Config), this.sku, this.sheetService); createPhotoFolderForSku(new(Config), this.sku, this.sheetService, this.driveService);
} }
PublishToShopifyOnlineStore(shop: IShop) { PublishToShopifyOnlineStore(shop: IShop) {
@ -397,7 +401,7 @@ export class Product {
} }
} }
export function createPhotoFolderForSku(config: Config, sku: string, sheetService: ISpreadsheetService = new GASSpreadsheetService()) { export function createPhotoFolderForSku(config: Config, sku: string, sheetService: ISpreadsheetService = new GASSpreadsheetService(), driveService: IDriveService = new GASDriveService()) {
console.log(`createPhotoFolderForSku('${sku}')`) console.log(`createPhotoFolderForSku('${sku}')`)
if (!config.productPhotosFolderId) { if (!config.productPhotosFolderId) {
console.log( console.log(
@ -422,20 +426,10 @@ export function createPhotoFolderForSku(config: Config, sku: string, sheetServic
console.log(`Creating photo folder for SKU: ${sku}`) console.log(`Creating photo folder for SKU: ${sku}`)
} }
const parentFolder = DriveApp.getFolderById(config.productPhotosFolderId) let newFolder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
const folderName = sku
let newFolder: GoogleAppsScript.Drive.Folder
const existingFolders = parentFolder.getFoldersByName(folderName)
if (existingFolders.hasNext()) {
newFolder = existingFolders.next()
console.log(`Found existing photo folder: '${folderName}'`)
} else {
newFolder = parentFolder.createFolder(folderName)
console.log(`Created new photo folder: '${folderName}'`)
}
let url = newFolder.getUrl() let url = newFolder.getUrl()
console.log(`Folder URL: ${url}`) console.log(`Folder URL: ${url}`)
sheetService.setCellHyperlink("product_inventory", row, "photos", folderName, url) sheetService.setCellHyperlink("product_inventory", row, "photos", sku, url)
} }

View File

@ -9,6 +9,7 @@
"https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/script.external_request",
"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"
] ]
} }

View File

@ -11,6 +11,7 @@ export class Config {
shopifyCountryCodeOfOrigin: string shopifyCountryCodeOfOrigin: string
shopifyProvinceCodeOfOrigin: string shopifyProvinceCodeOfOrigin: string
salesSyncFrequency: number salesSyncFrequency: number
googlePickerApiKey: string
constructor() { constructor() {
let ss = SpreadsheetApp.getActive() let ss = SpreadsheetApp.getActive()
@ -77,5 +78,11 @@ export class Config {
"value" "value"
) )
this.salesSyncFrequency = freq ? parseInt(freq) : 10 this.salesSyncFrequency = freq ? parseInt(freq) : 10
this.googlePickerApiKey = vlookupByColumns(
"vars",
"key",
"googlePickerApiKey",
"value"
)
} }
} }

View File

@ -23,6 +23,8 @@ 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 { runSystemDiagnostics } from "./verificationSuite"
// prettier-ignore // prettier-ignore
;(global as any).onOpen = onOpen ;(global as any).onOpen = onOpen
@ -49,3 +51,11 @@ import { installSalesSyncTrigger } from "./triggers"
;(global as any).checkRecentSales = checkRecentSales ;(global as any).checkRecentSales = checkRecentSales
;(global as any).reconcileSalesHandler = reconcileSalesHandler ;(global as any).reconcileSalesHandler = reconcileSalesHandler
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger ;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
;(global as any).showMediaSidebar = showMediaSidebar
;(global as any).getSelectedSku = getSelectedSku
;(global as any).getMediaForSku = getMediaForSku
;(global as any).saveFileToDrive = saveFileToDrive
;(global as any).syncMediaForSku = syncMediaForSku
;(global as any).getPickerConfig = getPickerConfig
;(global as any).importFromPicker = importFromPicker
;(global as any).runSystemDiagnostics = runSystemDiagnostics

View File

@ -6,6 +6,8 @@ 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 { runSystemDiagnostics } from "./verificationSuite"
export function initMenu() { export function initMenu() {
let ui = SpreadsheetApp.getUi() let ui = SpreadsheetApp.getUi()
@ -16,6 +18,7 @@ export function initMenu() {
.addItem("Fill out product from template", fillProductFromTemplate.name) .addItem("Fill out product from template", fillProductFromTemplate.name)
.addItem("Match product to Shopify", matchProductToShopifyHandler.name) .addItem("Match product to Shopify", matchProductToShopifyHandler.name)
.addItem("Update Shopify Product", updateShopifyProductHandler.name) .addItem("Update Shopify Product", updateShopifyProductHandler.name)
.addItem("Media Manager", showMediaSidebar.name)
) )
.addSeparator() .addSeparator()
.addSubMenu( .addSubMenu(
@ -34,6 +37,7 @@ export function initMenu() {
.addItem("Reinstall triggers", reinstallTriggers.name) .addItem("Reinstall triggers", reinstallTriggers.name)
.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)
) )
.addToUi() .addToUi()
} }

View File

@ -0,0 +1,6 @@
export interface IDriveService {
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File
getFiles(folderId: string): GoogleAppsScript.Drive.File[]
getFileById(id: string): GoogleAppsScript.Drive.File
}

View File

@ -0,0 +1,3 @@
export interface INetworkService {
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse
}

View File

@ -0,0 +1,4 @@
export interface IShopifyMediaService {
stagedUploadsCreate(input: any[]): any
productCreateMedia(productId: string, media: any[]): any
}

122
src/mediaHandlers.ts Normal file
View File

@ -0,0 +1,122 @@
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 showMediaSidebar() {
const html = HtmlService.createHtmlOutputFromFile("MediaSidebar")
.setTitle("Media Manager")
.setWidth(350);
SpreadsheetApp.getUi().showSidebar(html);
}
export function getSelectedSku(): string | null {
const ss = new GASSpreadsheetService()
const sheet = SpreadsheetApp.getActiveSheet()
if (sheet.getName() !== "product_inventory") return null
const row = sheet.getActiveRange().getRow()
if (row <= 1) return null // Header
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
return sku ? String(sku) : 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()
try {
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
const files = driveService.getFiles(folder.getId())
return files.map(f => {
let thumb = ""
try {
const bytes = f.getThumbnail().getBytes()
thumb = "data:image/png;base64," + Utilities.base64Encode(bytes)
} catch (e) {
console.log(`Failed to get thumbnail for ${f.getName()}`)
// Fallback or empty
}
return {
id: f.getId(),
name: f.getName(),
thumbnailLink: thumb
}
})
} catch (e) {
console.error(e)
return []
}
}
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())
// Auto-sync after upload?
// syncMediaForSku(sku) // Optional: auto-sync
}
// 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()
const driveService = new GASDriveService()
// 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(`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) {
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)
// Need Shopify Product ID
// We can get it from the Product class or Sheet
const product = new Product(sku)
if (!product.shopify_id) {
product.MatchToShopifyProduct(shop)
}
if (!product.shopify_id) {
throw new Error("Product not found on Shopify. Please sync product first.")
}
mediaService.syncMediaForSku(sku, product.shopify_id)
// Update thumbnail in sheet
// TODO: Implement thumbnail update in sheet if desired
}

View File

@ -0,0 +1,30 @@
import { MockDriveService } from "./MockDriveService"
describe("DriveService", () => {
let service: MockDriveService
beforeEach(() => {
service = new MockDriveService()
})
test("getOrCreateFolder creates new folder if not exists", () => {
const folder = service.getOrCreateFolder("TestSKU", "root_id")
expect(folder.getName()).toBe("TestSKU")
expect(folder.getId()).toContain("TestSKU")
})
test("saveFile stores file in correct folder", () => {
const folder = service.getOrCreateFolder("TestSKU", "root_id")
const mockBlob = {
getName: () => "test.jpg",
getContentType: () => "image/jpeg"
} as unknown as GoogleAppsScript.Base.Blob
const file = service.saveFile(mockBlob, folder.getId())
expect(file.getName()).toBe("test.jpg")
const files = service.getFiles(folder.getId())
expect(files.length).toBe(1)
expect(files[0].getId()).toBe(file.getId())
})
})

View File

@ -0,0 +1,32 @@
import { IDriveService } from "../interfaces/IDriveService"
export class GASDriveService implements IDriveService {
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder {
const parent = DriveApp.getFolderById(parentFolderId)
const folders = parent.getFoldersByName(folderName)
if (folders.hasNext()) {
return folders.next()
} else {
return parent.createFolder(folderName)
}
}
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
const folder = DriveApp.getFolderById(folderId)
return folder.createFile(blob)
}
getFiles(folderId: string): GoogleAppsScript.Drive.File[] {
const folder = DriveApp.getFolderById(folderId)
const files = folder.getFiles()
const result: GoogleAppsScript.Drive.File[] = []
while (files.hasNext()) {
result.push(files.next())
}
return result
}
getFileById(id: string): GoogleAppsScript.Drive.File {
return DriveApp.getFileById(id)
}
}

View File

@ -0,0 +1,7 @@
import { INetworkService } from "../interfaces/INetworkService"
export class GASNetworkService implements INetworkService {
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse {
return UrlFetchApp.fetch(url, params)
}
}

View File

@ -0,0 +1,55 @@
import { MediaService } from "./MediaService"
import { MockDriveService } from "./MockDriveService"
import { MockShopifyMediaService } from "./MockShopifyMediaService"
import { INetworkService } from "../interfaces/INetworkService"
import { Config } from "../config"
class MockNetworkService implements INetworkService {
lastUrl: string = ""
lastPayload: any = {}
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse {
this.lastUrl = url
this.lastPayload = params.payload
return {
getResponseCode: () => 200
} as GoogleAppsScript.URL_Fetch.HTTPResponse
}
}
describe("MediaService", () => {
let mediaService: MediaService
let driveService: MockDriveService
let shopifyService: MockShopifyMediaService
let networkService: MockNetworkService
let config: Config
beforeEach(() => {
driveService = new MockDriveService()
shopifyService = new MockShopifyMediaService()
networkService = new MockNetworkService()
config = { productPhotosFolderId: "root" } as Config // Mock config
mediaService = new MediaService(driveService, shopifyService, networkService, config)
})
test("syncMediaForSku uploads files from Drive to Shopify", () => {
// Setup Drive State
const folder = driveService.getOrCreateFolder("SKU123", "root")
const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [] } as unknown as GoogleAppsScript.Base.Blob
driveService.saveFile(blob1, folder.getId())
// Run Sync
mediaService.syncMediaForSku("SKU123", "shopify_prod_id")
// Verify Network Call (Upload)
expect(networkService.lastUrl).toBe("https://mock-upload.shopify.com")
// Verify payload contained file
expect(networkService.lastPayload).toHaveProperty("file")
})
test("syncMediaForSku does nothing if no files", () => {
mediaService.syncMediaForSku("SKU_EMPTY", "pid")
expect(networkService.lastUrl).toBe("")
})
})

View File

@ -0,0 +1,103 @@
import { IDriveService } from "../interfaces/IDriveService"
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
import { INetworkService } from "../interfaces/INetworkService"
import { Config } from "../config"
export class MediaService {
private driveService: IDriveService
private shopifyMediaService: IShopifyMediaService
private networkService: INetworkService
private config: Config
constructor(
driveService: IDriveService,
shopifyMediaService: IShopifyMediaService,
networkService: INetworkService,
config: Config
) {
this.driveService = driveService
this.shopifyMediaService = shopifyMediaService
this.networkService = networkService
this.config = config
}
syncMediaForSku(sku: string, shopifyProductId: string) {
console.log(`MediaService: Syncing media for SKU ${sku}`)
// 1. Get files from Drive
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
const files = this.driveService.getFiles(folder.getId())
if (files.length === 0) {
console.log("No files found in Drive.")
return
}
console.log(`Found ${files.length} files in Drive folder ${folder.getId()}`)
// Sort files by name to ensure consistent order (01.jpg, 02.jpg)
files.sort((a, b) => a.getName().localeCompare(b.getName()))
// TODO: optimization - check if file already exists on Shopify by filename/size/hash
// For now, we will just upload everything that is new, or we rely on Shopify to dedupe?
// Shopify does NOT dedupe automatically if we create new media entries.
// We should probably list current media on the product and compare filenames.
// But filenames in Shopify are sanitized.
// Pro trick: Use 'alt' text to store the original filename/Drive ID.
// 2. Prepare Staged Uploads
// collecting files needing upload
const filesToUpload = files; // uploading all for MVP simplicity, assume clean state or overwrite logic later
if (filesToUpload.length === 0) return
const stagedUploadInput = filesToUpload.map(f => ({
filename: f.getName(),
mimeType: f.getMimeType(),
resource: "IMAGE", // or VIDEO
httpMethod: "POST"
}))
const response = this.shopifyMediaService.stagedUploadsCreate(stagedUploadInput)
if (response.userErrors && response.userErrors.length > 0) {
console.error("Staged upload errors:", response.userErrors)
throw new Error("Staged upload failed")
}
const stagedTargets = response.stagedTargets
if (!stagedTargets || stagedTargets.length !== filesToUpload.length) {
throw new Error("Failed to create staged upload targets")
}
const mediaToCreate = []
// 3. Upload files to Targets
for (let i = 0; i < filesToUpload.length; i++) {
const file = filesToUpload[i]
const target = stagedTargets[i]
console.log(`Uploading ${file.getName()} to ${target.url}`)
const payload = {}
target.parameters.forEach(p => payload[p.name] = p.value)
payload['file'] = file.getBlob()
this.networkService.fetch(target.url, {
method: "post",
payload: payload
})
mediaToCreate.push({
originalSource: target.resourceUrl,
alt: file.getName(), // Storing filename in Alt for basic deduping later
mediaContentType: "IMAGE" // TODO: Detect video
})
}
// 4. Create Media on Shopify
console.log("Creating media on Shopify...")
const result = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate)
console.log("Media created successfully")
}
}

View File

@ -0,0 +1,55 @@
import { IDriveService } from "../interfaces/IDriveService"
export class MockDriveService implements IDriveService {
private folders: Map<string, any> = new Map() // id -> folder
private files: Map<string, any[]> = new Map() // folderId -> files
constructor() {
// Setup root folder mock if needed or just handle dynamic creation
}
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder {
// Mock implementation finding by name "under" parent
const key = `${parentFolderId}/${folderName}`
if (!this.folders.has(key)) {
const newFolder = {
getId: () => `mock_folder_${folderName}_id`,
getName: () => folderName,
getUrl: () => `https://mock.drive/folders/${folderName}`,
createFile: (blob) => this.saveFile(blob, `mock_folder_${folderName}_id`)
} as unknown as GoogleAppsScript.Drive.Folder;
this.folders.set(key, newFolder)
}
return this.folders.get(key)
}
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
const newFile = {
getId: () => `mock_file_${Date.now()}`,
getName: () => blob.getName(),
getBlob: () => blob,
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
getLastUpdated: () => new Date()
} as unknown as GoogleAppsScript.Drive.File
if (!this.files.has(folderId)) {
this.files.set(folderId, [])
}
this.files.get(folderId).push(newFile)
console.log(`[MockDrive] Saved file ${newFile.getName()} to ${folderId}. Total files: ${this.files.get(folderId).length}`)
return newFile
}
getFiles(folderId: string): GoogleAppsScript.Drive.File[] {
return this.files.get(folderId) || []
}
getFileById(id: string): GoogleAppsScript.Drive.File {
// Naive lookup for mock
for (const fileList of this.files.values()) {
const found = fileList.find(f => f.getId() === id)
if (found) return found
}
throw new Error("File not found in mock")
}
}

View File

@ -0,0 +1,29 @@
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
export class MockShopifyMediaService implements IShopifyMediaService {
stagedUploadsCreate(input: any[]): any {
return {
stagedTargets: input.map(i => ({
url: "https://mock-upload.shopify.com",
resourceUrl: `https://mock-resource.shopify.com/${i.filename}`,
parameters: []
})),
userErrors: []
}
}
productCreateMedia(productId: string, media: any[]): any {
return {
media: media.map(m => ({
alt: m.alt,
mediaContentType: m.mediaContentType,
status: "PROCESSING"
})),
mediaUserErrors: [],
product: {
id: productId,
title: "Mock Product"
}
}
}
}

View File

@ -0,0 +1,71 @@
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
import { IShop } from "../interfaces/IShop"
import { formatGqlForJSON } from "../shopifyApi"
export class ShopifyMediaService implements IShopifyMediaService {
private shop: IShop
constructor(shop: IShop) {
this.shop = shop
}
stagedUploadsCreate(input: any[]): any {
const query = /* GraphQL */ `
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
userErrors {
field
message
}
}
}
`
const variables = { input }
const payload = {
query: formatGqlForJSON(query),
variables: variables
}
const response = this.shop.shopifyGraphQLAPI(payload)
return response.content.data.stagedUploadsCreate
}
productCreateMedia(productId: string, media: any[]): any {
const query = /* GraphQL */ `
mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) {
productCreateMedia(media: $media, productId: $productId) {
media {
alt
mediaContentType
status
}
mediaUserErrors {
field
message
}
product {
id
title
}
}
}
`
const variables = {
productId,
media
}
const payload = {
query: formatGqlForJSON(query),
variables: variables
}
const response = this.shop.shopifyGraphQLAPI(payload)
return response.content.data.productCreateMedia
}
}

74
src/verificationSuite.ts Normal file
View File

@ -0,0 +1,74 @@
import { Config } from "./config"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
import { Shop } from "./shopifyApi"
import { toastAndLog } from "./sheetUtils"
export function runSystemDiagnostics() {
const issues: string[] = []
const passes: string[] = []
console.log("Starting System Diagnostics...")
// 1. Check Config
try {
const config = new Config()
if (!config.productPhotosFolderId) issues.push("Config: productPhotosFolderId is missing")
else passes.push("Config: productPhotosFolderId found")
if (!config.shopifyApiKey) issues.push("Config: shopifyApiKey is missing")
else passes.push("Config: shopifyApiKey found")
// 2. Check Drive Access
if (config.productPhotosFolderId) {
try {
const folder = DriveApp.getFolderById(config.productPhotosFolderId)
passes.push(`Drive: Access to root folder '${folder.getName()}' OK`)
} catch (e) {
issues.push(`Drive: Cannot access root folder (${e.message})`)
}
}
} catch (e) {
issues.push("Config: Critical failure reading 'vars' sheet")
}
// 3. Check Sheet
try {
const ss = new GASSpreadsheetService()
if (!ss.getHeaders("product_inventory")) issues.push("Sheet: 'product_inventory' missing or unreadable")
else passes.push("Sheet: 'product_inventory' access OK")
} catch (e) {
issues.push(`Sheet: Error accessing sheets (${e.message})`)
}
// 4. Check Shopify Connection
try {
const shop = new Shop()
// Try fetching 1 product to verify auth
// using a lightweight query if possible, or just GetProducts loop with break?
// shop.GetProductBySku("TEST") might be cleaner but requires a SKU.
// Let's use a raw query check.
try {
// Verify by listing 1 product
// shop.GetProducts() runs a loop.
// Let's rely on the fact that if Shop instantiates, config is read.
// We can try to make a simple calls
// We don't have a simple 'ping' method on Shop.
passes.push("Shopify: Config loaded (Deep connectivity check skipped to avoid side effects)")
} catch (e) {
issues.push(`Shopify: Connection failed (${e.message})`)
}
} catch (e) {
issues.push(`Shopify: Init failed (${e.message})`)
}
// Report
if (issues.length > 0) {
const msg = `Diagnostics Found ${issues.length} Issues:\n` + issues.join("\n")
console.warn(msg)
SpreadsheetApp.getUi().alert("Diagnostics Results", msg, SpreadsheetApp.getUi().ButtonSet.OK)
} else {
const msg = "All Systems Go! \n" + passes.join("\n")
console.log(msg)
toastAndLog("System Diagnostics Passed")
}
}