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

@ -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
}
}