feat: backend implementation for media manager v2 (WIP - Undeployed)
This commit is contained in:
@ -29,4 +29,53 @@ export class GASDriveService implements IDriveService {
|
||||
getFileById(id: string): GoogleAppsScript.Drive.File {
|
||||
return DriveApp.getFileById(id)
|
||||
}
|
||||
renameFile(fileId: string, newName: string): void {
|
||||
const file = DriveApp.getFileById(fileId)
|
||||
file.setName(newName)
|
||||
}
|
||||
|
||||
trashFile(fileId: string): void {
|
||||
const file = DriveApp.getFileById(fileId)
|
||||
file.setTrashed(true)
|
||||
}
|
||||
|
||||
updateFileProperties(fileId: string, properties: {[key: string]: string}): void {
|
||||
// Requires Advanced Drive Service (Drive API v2 or v3)
|
||||
// We assume v2 is default or v3. Let's try v2 style 'properties' or v3 'appProperties'.
|
||||
// Plan said 'appProperties'. v3 uses 'appProperties'.
|
||||
// If we are uncertain, we can try to detect or just assume v2/v3 enabled.
|
||||
// Standard DriveApp doesn't support this.
|
||||
try {
|
||||
if (typeof Drive === 'undefined') {
|
||||
throw new Error("Advanced Drive Service not enabled")
|
||||
}
|
||||
// Using 'any' cast to bypass TS strict check if 'Drive' global isn't typed
|
||||
const drive = Drive as any
|
||||
|
||||
// Drive v2 uses 'properties' list. v3 uses 'appProperties' map.
|
||||
// Let's assume v2 for GAS usually? Or check?
|
||||
// Most modern scripts use v2 default but v3 is option.
|
||||
// Let's check `mediaHandlers.ts` importFromPicker logic: it checked for `Drive.Files.create` (v3) vs `insert` (v2).
|
||||
// Let's do the same check.
|
||||
if (drive.Files.update) {
|
||||
// v3? v2 has update too.
|
||||
// v2: update(resource, fileId). v3: update(resource, fileId).
|
||||
// Properties format differs.
|
||||
// v2: { properties: [{key:.., value:..}] }
|
||||
// v3: { appProperties: { key: value } }
|
||||
|
||||
// We'll try v3 format first, it's cleaner.
|
||||
drive.Files.update({ appProperties: properties }, fileId)
|
||||
} else {
|
||||
console.warn("Drive Global found but no update method?")
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update file properties", e)
|
||||
// Fallback: Description hacking? No, let's fail or log.
|
||||
}
|
||||
}
|
||||
|
||||
createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File {
|
||||
return DriveApp.createFile(blob)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
|
||||
import { MediaService } from "./MediaService"
|
||||
import { MockDriveService } from "./MockDriveService"
|
||||
import { MockShopifyMediaService } from "./MockShopifyMediaService"
|
||||
@ -12,8 +13,9 @@ class MockNetworkService implements INetworkService {
|
||||
this.lastUrl = url
|
||||
this.lastPayload = params.payload
|
||||
return {
|
||||
getResponseCode: () => 200
|
||||
} as GoogleAppsScript.URL_Fetch.HTTPResponse
|
||||
getResponseCode: () => 200,
|
||||
getBlob: () => ({ getBytes: () => [], getContentType: () => "image/jpeg", setName: () => {} })
|
||||
} as unknown as GoogleAppsScript.URL_Fetch.HTTPResponse
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,28 +30,89 @@ describe("MediaService", () => {
|
||||
driveService = new MockDriveService()
|
||||
shopifyService = new MockShopifyMediaService()
|
||||
networkService = new MockNetworkService()
|
||||
config = { productPhotosFolderId: "root" } as Config // Mock config
|
||||
config = { productPhotosFolderId: "root" } as Config
|
||||
|
||||
mediaService = new MediaService(driveService, shopifyService, networkService, config)
|
||||
|
||||
// Global Mocks
|
||||
global.Utilities = {
|
||||
base64Encode: (b) => "base64",
|
||||
newBlob: (b, m, n) => ({
|
||||
getBytes: () => b,
|
||||
getContentType: () => m,
|
||||
getName: () => n,
|
||||
setName: () => {}
|
||||
})
|
||||
} as any
|
||||
global.Drive = { Files: { get: () => ({ appProperties: {} }) } } as any
|
||||
global.UrlFetchApp = networkService as unknown as GoogleAppsScript.URL_Fetch.UrlFetchApp
|
||||
})
|
||||
|
||||
test("syncMediaForSku uploads files from Drive to Shopify", () => {
|
||||
// Setup Drive State
|
||||
test("getUnifiedMediaState should match files", () => {
|
||||
const folder = driveService.getOrCreateFolder("SKU123", "root")
|
||||
const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [] } as unknown as GoogleAppsScript.Base.Blob
|
||||
const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [], getThumbnail: () => ({ 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")
|
||||
const state = mediaService.getUnifiedMediaState("SKU123", "pid")
|
||||
expect(state).toHaveLength(1)
|
||||
expect(state[0].filename).toBe("01.jpg")
|
||||
})
|
||||
|
||||
test("syncMediaForSku does nothing if no files", () => {
|
||||
mediaService.syncMediaForSku("SKU_EMPTY", "pid")
|
||||
expect(networkService.lastUrl).toBe("")
|
||||
test("processMediaChanges should handle deletions", () => {
|
||||
const folder = driveService.getOrCreateFolder("SKU123", "root")
|
||||
const blob1 = {
|
||||
getName: () => "delete_me.jpg",
|
||||
getId: () => "file_id_1",
|
||||
getMimeType: () => "image/jpeg",
|
||||
getBytes: () => [],
|
||||
getThumbnail: () => ({ getBytes: () => [] })
|
||||
} as unknown as GoogleAppsScript.Base.Blob
|
||||
driveService.saveFile(blob1, folder.getId())
|
||||
|
||||
// Update Shopify Mock to return this media
|
||||
shopifyService.getProductMedia = jest.fn().mockReturnValue([{
|
||||
id: "gid://shopify/Media/media_1",
|
||||
alt: "delete_me.jpg"
|
||||
}])
|
||||
|
||||
// Update global Drive to return synced ID
|
||||
global.Drive = { Files: { get: () => ({ appProperties: { shopify_media_id: "gid://shopify/Media/media_1" } }) } } as any
|
||||
|
||||
const finalState = []
|
||||
|
||||
const deleteSpy = jest.spyOn(shopifyService, 'productDeleteMedia')
|
||||
const trashSpy = jest.spyOn(driveService, 'trashFile')
|
||||
|
||||
mediaService.processMediaChanges("SKU123", finalState, "pid")
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalled()
|
||||
expect(trashSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("processMediaChanges should handle backfills (Shopify -> Drive)", () => {
|
||||
// Current state: Empty Drive, 1 Shopify Media
|
||||
shopifyService.getProductMedia = jest.fn().mockReturnValue([{
|
||||
id: "gid://shopify/Media/media_2",
|
||||
alt: "backfill.jpg",
|
||||
preview: { image: { originalSrc: "http://shopify.com/img.jpg" } }
|
||||
}])
|
||||
|
||||
// Final state: 1 item (the backfilled one)
|
||||
const finalState = [{
|
||||
id: "gid://shopify/Media/media_2",
|
||||
filename: "backfill.jpg",
|
||||
status: "synced"
|
||||
}]
|
||||
|
||||
// Mock network fetch for download
|
||||
jest.spyOn(networkService, 'fetch')
|
||||
|
||||
mediaService.processMediaChanges("SKU123", finalState, "pid")
|
||||
|
||||
// Should create file in Drive
|
||||
const folder = driveService.getOrCreateFolder("SKU123", "root")
|
||||
const files = driveService.getFiles(folder.getId())
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0].getName()).toBe("backfill.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
@ -21,83 +21,230 @@ export class MediaService {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
syncMediaForSku(sku: string, shopifyProductId: string) {
|
||||
console.log(`MediaService: Syncing media for SKU ${sku}`)
|
||||
|
||||
// 1. Get files from Drive
|
||||
|
||||
getUnifiedMediaState(sku: string, shopifyProductId: string): any[] {
|
||||
console.log(`MediaService: Getting unified state for SKU ${sku}`)
|
||||
|
||||
// 1. Get Drive Files
|
||||
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
|
||||
const files = this.driveService.getFiles(folder.getId())
|
||||
const driveFiles = 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")
|
||||
// 2. Get Shopify Media
|
||||
let shopifyMedia: any[] = []
|
||||
if (shopifyProductId) {
|
||||
shopifyMedia = this.shopifyMediaService.getProductMedia(shopifyProductId)
|
||||
}
|
||||
|
||||
const stagedTargets = response.stagedTargets
|
||||
// 3. Match
|
||||
const unifiedState: any[] = []
|
||||
const matchedShopifyIds = new Set<string>()
|
||||
|
||||
if (!stagedTargets || stagedTargets.length !== filesToUpload.length) {
|
||||
throw new Error("Failed to create staged upload targets")
|
||||
// Map of Drive Files
|
||||
const driveMap = new Map<string, {file: GoogleAppsScript.Drive.File, shopifyId: string | null}>()
|
||||
|
||||
driveFiles.forEach(f => {
|
||||
let shopifyId = null
|
||||
try {
|
||||
// Expensive lookup for properties:
|
||||
if (typeof Drive !== 'undefined') {
|
||||
const advFile = (Drive as any).Files.get(f.getId(), { fields: 'appProperties' })
|
||||
if (advFile.appProperties && advFile.appProperties['shopify_media_id']) {
|
||||
shopifyId = advFile.appProperties['shopify_media_id']
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to get properties for ${f.getName()}`)
|
||||
}
|
||||
|
||||
driveMap.set(f.getId(), { file: f, shopifyId })
|
||||
})
|
||||
|
||||
// Match Logic
|
||||
driveFiles.forEach(f => {
|
||||
const d = driveMap.get(f.getId())
|
||||
if (!d) return
|
||||
|
||||
let match = null
|
||||
|
||||
// 1. ID Match
|
||||
if (d.shopifyId) {
|
||||
match = shopifyMedia.find(m => m.id === d.shopifyId)
|
||||
if (match) matchedShopifyIds.add(match.id)
|
||||
}
|
||||
|
||||
// 2. Filename Match (if no ID match)
|
||||
if (!match) {
|
||||
match = shopifyMedia.find(m =>
|
||||
!matchedShopifyIds.has(m.id) &&
|
||||
(m.filename === f.getName() || (m.preview && m.preview.image && m.preview.image.originalSrc && m.preview.image.originalSrc.includes(f.getName())))
|
||||
)
|
||||
if (match) matchedShopifyIds.add(match.id)
|
||||
}
|
||||
|
||||
unifiedState.push({
|
||||
id: f.getId(), // Use Drive ID as primary key for "Synced" or "Drive" items
|
||||
driveId: f.getId(),
|
||||
shopifyId: match ? match.id : null,
|
||||
filename: f.getName(),
|
||||
source: match ? 'synced' : 'drive_only',
|
||||
thumbnail: `data:image/jpeg;base64,${Utilities.base64Encode(f.getThumbnail().getBytes())}`, // Expensive?
|
||||
status: 'active'
|
||||
})
|
||||
})
|
||||
|
||||
// Find Shopify Orphans
|
||||
shopifyMedia.forEach(m => {
|
||||
if (!matchedShopifyIds.has(m.id)) {
|
||||
unifiedState.push({
|
||||
id: m.id, // Use Shopify ID keys for orphans
|
||||
driveId: null,
|
||||
shopifyId: m.id,
|
||||
filename: "Shopify Media", // TODO: extract real name
|
||||
source: 'shopify_only',
|
||||
thumbnail: m.preview?.image?.originalSrc || "",
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return unifiedState
|
||||
}
|
||||
|
||||
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string) {
|
||||
console.log(`MediaService: Processing changes for SKU ${sku}`)
|
||||
|
||||
// 1. Get Current State (for diffing deletions)
|
||||
const currentState = this.getUnifiedMediaState(sku, shopifyProductId)
|
||||
const finalIds = new Set(finalState.map(f => f.id))
|
||||
|
||||
// 2. Process Deletions
|
||||
const toDelete = currentState.filter(c => !finalIds.has(c.id))
|
||||
toDelete.forEach(item => {
|
||||
console.log(`Deleting item: ${item.filename}`)
|
||||
if (item.shopifyId) {
|
||||
this.shopifyMediaService.productDeleteMedia(shopifyProductId, item.shopifyId)
|
||||
}
|
||||
if (item.driveId) {
|
||||
this.driveService.trashFile(item.driveId)
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Process Backfills (Shopify Only -> Drive)
|
||||
finalState.forEach(item => {
|
||||
if (item.source === 'shopify_only' && item.shopifyId) {
|
||||
console.log(`Backfilling item: ${item.filename}`)
|
||||
// Download using global UrlFetchApp for blob access if generic interface is limited?
|
||||
// Actually implementation of INetworkService returns HTTPResponse which has getBlob().
|
||||
// But item.thumbnail usually is a URL.
|
||||
const resp = this.networkService.fetch(item.thumbnail, { method: 'get' })
|
||||
const blob = resp.getBlob()
|
||||
const file = this.driveService.createFile(blob)
|
||||
|
||||
// Move to correct folder
|
||||
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
|
||||
const driveFile = this.driveService.getFileById(file.getId())
|
||||
// GASDriveService must handle move? Standard File has moveTo?
|
||||
// "moveTo" is standard GAS.
|
||||
// But we used interface `IDriveService` which returns `GoogleAppsScript.Drive.File`.
|
||||
// So we can assume `driveFile.moveTo(folder)` works if it's a real GAS object.
|
||||
// TypeScript might complain if `IDriveService` returns a wrapper?
|
||||
// Interface says `GoogleAppsScript.Drive.File`. That is the native type.
|
||||
// Native type has `moveTo(destination: Folder)`.
|
||||
driveFile.moveTo(folder)
|
||||
|
||||
this.driveService.updateFileProperties(file.getId(), { shopify_media_id: item.shopifyId })
|
||||
|
||||
// Update item refs for subsequent steps
|
||||
item.driveId = file.getId()
|
||||
item.source = 'synced'
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Process Uploads (Drive Only -> Shopify)
|
||||
const toUpload = finalState.filter(item => item.source === 'drive_only' && item.driveId)
|
||||
if (toUpload.length > 0) {
|
||||
console.log(`Uploading ${toUpload.length} new items from Drive`)
|
||||
|
||||
const uploads = toUpload.map(item => {
|
||||
const f = this.driveService.getFileById(item.driveId)
|
||||
return {
|
||||
filename: f.getName(),
|
||||
mimeType: f.getMimeType(),
|
||||
resource: "IMAGE",
|
||||
httpMethod: "POST",
|
||||
file: f
|
||||
}
|
||||
})
|
||||
|
||||
const stagedInput = uploads.map(u => ({
|
||||
filename: u.filename,
|
||||
mimeType: u.mimeType,
|
||||
resource: u.resource,
|
||||
httpMethod: u.httpMethod
|
||||
}))
|
||||
const stagedResp = this.shopifyMediaService.stagedUploadsCreate(stagedInput)
|
||||
const targets = stagedResp.stagedTargets
|
||||
|
||||
const mediaToCreate = []
|
||||
uploads.forEach((u, i) => {
|
||||
const target = targets[i]
|
||||
const payload = {}
|
||||
target.parameters.forEach((p: any) => payload[p.name] = p.value)
|
||||
payload['file'] = u.file.getBlob()
|
||||
|
||||
this.networkService.fetch(target.url, { method: "post", payload: payload })
|
||||
|
||||
mediaToCreate.push({
|
||||
originalSource: target.resourceUrl,
|
||||
alt: u.filename,
|
||||
mediaContentType: "IMAGE"
|
||||
})
|
||||
})
|
||||
|
||||
// Create Media (Updated to return IDs)
|
||||
const createdMedia = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate)
|
||||
if (createdMedia && createdMedia.media) {
|
||||
createdMedia.media.forEach((m: any, i: number) => {
|
||||
const originalItem = toUpload[i]
|
||||
if (m.status === 'FAILED') {
|
||||
console.error("Media create failed", m)
|
||||
return
|
||||
}
|
||||
if (m.id) {
|
||||
this.driveService.updateFileProperties(originalItem.driveId, { shopify_media_id: m.id })
|
||||
originalItem.shopifyId = m.id
|
||||
originalItem.source = 'synced'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
// 5. Process Reordering
|
||||
const moves: any[] = []
|
||||
finalState.forEach((item, index) => {
|
||||
if (item.shopifyId) {
|
||||
moves.push({ id: item.shopifyId, newPosition: index.toString() })
|
||||
}
|
||||
})
|
||||
if (moves.length > 0) {
|
||||
this.shopifyMediaService.productReorderMedia(shopifyProductId, moves)
|
||||
}
|
||||
|
||||
// 4. Create Media on Shopify
|
||||
console.log("Creating media on Shopify...")
|
||||
const result = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate)
|
||||
console.log("Media created successfully")
|
||||
// 6. Rename Drive Files
|
||||
finalState.forEach((item, index) => {
|
||||
if (item.driveId) {
|
||||
const paddedIndex = (index + 1).toString().padStart(4, '0')
|
||||
const ext = item.filename.includes('.') ? item.filename.split('.').pop() : 'jpg'
|
||||
const newName = `${sku}_${paddedIndex}.${ext}`
|
||||
|
||||
// Avoid unnecessary rename validation calls if possible, but renameFile is fast
|
||||
const startName = this.driveService.getFileById(item.driveId).getName()
|
||||
if (startName !== newName) {
|
||||
this.driveService.renameFile(item.driveId, newName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,8 @@ export class MockDriveService implements IDriveService {
|
||||
getName: () => blob.getName(),
|
||||
getBlob: () => blob,
|
||||
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
|
||||
getLastUpdated: () => new Date()
|
||||
getLastUpdated: () => new Date(),
|
||||
getThumbnail: () => ({ getBytes: () => [] })
|
||||
} as unknown as GoogleAppsScript.Drive.File
|
||||
|
||||
if (!this.files.has(folderId)) {
|
||||
@ -52,4 +53,26 @@ export class MockDriveService implements IDriveService {
|
||||
}
|
||||
throw new Error("File not found in mock")
|
||||
}
|
||||
|
||||
renameFile(fileId: string, newName: string): void {
|
||||
const file = this.getFileById(fileId)
|
||||
// Mock setName
|
||||
// We can't easily mutate the mock object created in saveFile without refactoring
|
||||
// But for type satisfaction it's void.
|
||||
console.log(`[MockDrive] Renaming ${fileId} to ${newName}`)
|
||||
// Assuming we can mutate if we kept ref?
|
||||
}
|
||||
|
||||
trashFile(fileId: string): void {
|
||||
console.log(`[MockDrive] Trashing ${fileId}`)
|
||||
}
|
||||
|
||||
updateFileProperties(fileId: string, properties: any): void {
|
||||
console.log(`[MockDrive] Updating properties for ${fileId}`, properties)
|
||||
}
|
||||
|
||||
createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File {
|
||||
// Create in "root" or similar
|
||||
return this.saveFile(blob, "root")
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ export class MockShopifyMediaService implements IShopifyMediaService {
|
||||
productCreateMedia(productId: string, media: any[]): any {
|
||||
return {
|
||||
media: media.map(m => ({
|
||||
id: `gid://shopify/Media/${Math.random()}`,
|
||||
alt: m.alt,
|
||||
mediaContentType: m.mediaContentType,
|
||||
status: "PROCESSING"
|
||||
@ -26,4 +27,27 @@ export class MockShopifyMediaService implements IShopifyMediaService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getProductMedia(productId: string): any[] {
|
||||
// Return empty or mock list
|
||||
return []
|
||||
}
|
||||
|
||||
productDeleteMedia(productId: string, mediaId: string): any {
|
||||
return {
|
||||
productDeleteMedia: {
|
||||
deletedMediaId: mediaId,
|
||||
userErrors: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
productReorderMedia(productId: string, moves: any[]): any {
|
||||
return {
|
||||
productReorderMedia: {
|
||||
job: { id: "job_123" },
|
||||
userErrors: []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
||||
mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) {
|
||||
productCreateMedia(media: $media, productId: $productId) {
|
||||
media {
|
||||
id
|
||||
alt
|
||||
mediaContentType
|
||||
status
|
||||
@ -68,4 +69,79 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||
return response.content.data.productCreateMedia
|
||||
}
|
||||
getProductMedia(productId: string): any[] {
|
||||
const query = /* GraphQL */ `
|
||||
query getProductMedia($productId: ID!) {
|
||||
product(id: $productId) {
|
||||
media(first: 250) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
alt
|
||||
mediaContentType
|
||||
preview {
|
||||
image {
|
||||
originalSrc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const variables = { productId }
|
||||
const payload = {
|
||||
query: formatGqlForJSON(query),
|
||||
variables: variables
|
||||
}
|
||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||
if (!response.content.data.product) return []
|
||||
return response.content.data.product.media.edges.map((edge: any) => edge.node)
|
||||
}
|
||||
|
||||
productDeleteMedia(productId: string, mediaId: string): any {
|
||||
const query = /* GraphQL */ `
|
||||
mutation productDeleteMedia($mediaId: ID!, $productId: ID!) {
|
||||
productDeleteMedia(mediaId: $mediaId, productId: $productId) {
|
||||
deletedMediaId
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const variables = { productId, mediaId }
|
||||
const payload = {
|
||||
query: formatGqlForJSON(query),
|
||||
variables: variables
|
||||
}
|
||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||
return response.content.data.productDeleteMedia
|
||||
}
|
||||
|
||||
productReorderMedia(productId: string, moves: any[]): any {
|
||||
const query = /* GraphQL */ `
|
||||
mutation productReorderMedia($id: ID!, $moves: [MoveInput!]!) {
|
||||
productReorderMedia(id: $id, moves: $moves) {
|
||||
job {
|
||||
id
|
||||
done
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const variables = { id: productId, moves }
|
||||
const payload = {
|
||||
query: formatGqlForJSON(query),
|
||||
variables: variables
|
||||
}
|
||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||
return response.content.data.productReorderMedia
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user