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:
30
src/services/DriveService.test.ts
Normal file
30
src/services/DriveService.test.ts
Normal 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())
|
||||
})
|
||||
})
|
||||
32
src/services/GASDriveService.ts
Normal file
32
src/services/GASDriveService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
7
src/services/GASNetworkService.ts
Normal file
7
src/services/GASNetworkService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
55
src/services/MediaService.test.ts
Normal file
55
src/services/MediaService.test.ts
Normal 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("")
|
||||
})
|
||||
})
|
||||
103
src/services/MediaService.ts
Normal file
103
src/services/MediaService.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
55
src/services/MockDriveService.ts
Normal file
55
src/services/MockDriveService.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
29
src/services/MockShopifyMediaService.ts
Normal file
29
src/services/MockShopifyMediaService.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/services/ShopifyMediaService.ts
Normal file
71
src/services/ShopifyMediaService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user