Updates logic to detect processing state (including READY-but-no-sources race condition) and propagates contentUrl updates to the frontend immediately.
568 lines
26 KiB
TypeScript
568 lines
26 KiB
TypeScript
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
|
|
}
|
|
|
|
|
|
|
|
|
|
getDiagnostics(sku: string, shopifyProductId: string) {
|
|
const results = {
|
|
drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null },
|
|
shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null },
|
|
matching: { status: 'pending', error: null }
|
|
}
|
|
|
|
// 1. Unsafe Drive Check
|
|
try {
|
|
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
|
|
results.drive.folderId = folder.getId()
|
|
results.drive.folderUrl = folder.getUrl()
|
|
const files = this.driveService.getFiles(folder.getId())
|
|
results.drive.fileCount = files.length
|
|
results.drive.status = 'ok'
|
|
} catch (e) {
|
|
results.drive.status = 'error'
|
|
results.drive.error = e.toString()
|
|
}
|
|
|
|
// 2. Unsafe Shopify Check
|
|
try {
|
|
if (shopifyProductId) {
|
|
const media = this.shopifyMediaService.getProductMedia(shopifyProductId)
|
|
results.shopify.mediaCount = media.length
|
|
// Admin URL construction (Best effort)
|
|
// Assuming standard Shopify admin pattern
|
|
const domain = this.shopifyMediaService.getShopDomain? this.shopifyMediaService.getShopDomain() : 'admin.shopify.com';
|
|
results.shopify.adminUrl = `https://${domain.replace('.myshopify.com','')}.myshopify.com/admin/products/${shopifyProductId.split('/').pop()}`
|
|
results.shopify.status = 'ok'
|
|
} else {
|
|
results.shopify.status = 'skipped' // Not linked yet
|
|
}
|
|
} catch (e) {
|
|
results.shopify.status = 'error'
|
|
results.shopify.error = e.toString()
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
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)
|
|
// We need strict file list.
|
|
// Optimization: getFiles() usually returns limited info.
|
|
// We might need to iterate and pull props if getFiles() doesn't include appProperties (DriveApp doesn't).
|
|
const driveFiles = this.driveService.getFiles(folder.getId())
|
|
|
|
// 2. Get Shopify Media
|
|
let shopifyMedia: any[] = []
|
|
if (shopifyProductId) {
|
|
shopifyMedia = this.shopifyMediaService.getProductMedia(shopifyProductId)
|
|
}
|
|
|
|
// 3. Match
|
|
const unifiedState: any[] = []
|
|
const matchedShopifyIds = new Set<string>()
|
|
|
|
// PRE-PASS: Identify Sidecar Thumbnails
|
|
// Map<VideoId, ThumbnailLink>
|
|
const sidecarThumbMap = new Map<string, string>();
|
|
const sidecarFileIds = new Set<string>();
|
|
|
|
// Map of Drive Files (Enriched)
|
|
const driveFileStats = driveFiles.map(f => {
|
|
let shopifyId = null
|
|
let galleryOrder = 9999
|
|
let type = 'media';
|
|
let customThumbnailId = null;
|
|
let parentVideoId = null;
|
|
|
|
try {
|
|
const props = this.driveService.getFileProperties(f.getId())
|
|
if (props['shopify_media_id']) shopifyId = props['shopify_media_id']
|
|
if (props['gallery_order']) galleryOrder = parseInt(props['gallery_order'])
|
|
if (props['type']) type = props['type'];
|
|
if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id'];
|
|
if (props['parent_video_id']) parentVideoId = props['parent_video_id'];
|
|
|
|
console.log(`[DEBUG] File ${f.getName()} Props:`, JSON.stringify(props));
|
|
|
|
} catch (e) {
|
|
console.warn(`Failed to get properties for ${f.getName()}`)
|
|
}
|
|
return { file: f, shopifyId, galleryOrder, type, customThumbnailId, parentVideoId }
|
|
})
|
|
|
|
// Populate Sidecar Map
|
|
driveFileStats.forEach(stat => {
|
|
if (stat.type === 'thumbnail' && stat.parentVideoId) {
|
|
sidecarFileIds.add(stat.file.getId());
|
|
// URL-based approach failed (CORS/Auth).
|
|
// Switch to Server-Side Base64 encoding (Robust).
|
|
try {
|
|
// Fetch the bytes of the JPEG sidecar
|
|
// We use getThumbnail() here because identical to getBlob().getBytes() for images,
|
|
// but getThumbnail() is sometimes optimized/cached by DriveApp?
|
|
// actually getBlob() is safer for the "original" sidecar content.
|
|
const bytes = stat.file.getBlob().getBytes();
|
|
const b64 = Utilities.base64Encode(bytes);
|
|
const dataUrl = `data:image/jpeg;base64,${b64}`;
|
|
sidecarThumbMap.set(stat.parentVideoId, dataUrl);
|
|
} catch (e) {
|
|
console.warn(`[MediaService] Failed to read sidecar file ${stat.file.getName()}: ${e}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Sort: Gallery Order ASC, then Filename ASC
|
|
driveFileStats.sort((a, b) => {
|
|
if (a.galleryOrder !== b.galleryOrder) {
|
|
return a.galleryOrder - b.galleryOrder
|
|
}
|
|
return a.file.getName().localeCompare(b.file.getName())
|
|
})
|
|
|
|
|
|
// Match Logic (Strict ID Match Only)
|
|
driveFileStats.forEach(d => {
|
|
// Skip Sidecar Files in main list
|
|
if (sidecarFileIds.has(d.file.getId())) return;
|
|
|
|
let match = null
|
|
let isProcessing = false
|
|
let thumbnail = "";
|
|
|
|
// 1. ID Match
|
|
if (d.shopifyId) {
|
|
match = shopifyMedia.find(m => m.id === d.shopifyId)
|
|
if (match) matchedShopifyIds.add(match.id)
|
|
}
|
|
|
|
// Thumbnail Logic
|
|
if (match && match.preview && match.preview.image && match.preview.image.originalSrc) {
|
|
thumbnail = match.preview.image.originalSrc;
|
|
} else {
|
|
// Drive Thumbnail Strategy
|
|
// Determine if Native Drive Thumbnail is ready/valid
|
|
let nativeThumbReady = false;
|
|
let nativeThumbUrl = "";
|
|
|
|
try {
|
|
// We assume if getThumbnail() succeeds and returns "substantial" data, it's ready.
|
|
// Or check availability of thumbnailLink if we had used Advanced API.
|
|
// Standard DriveApp doesn't expose "thumbnailLink" directly, but getThumbnail().
|
|
// However, for Large Videos, getThumbnail() might fail or return the generic icon.
|
|
// The most reliable check for "Is Processing Done" is usually if we can get a standard thumbnail that ISN'T the generic one?
|
|
// Hard to tell generic from bytes.
|
|
// Alternative: If we have a Sidecar, WE ARE IN CHARGE.
|
|
// We only switch if we are SURE.
|
|
// Let's us try to fetch the thumbnail bytes.
|
|
const thumbBlob = d.file.getThumbnail();
|
|
if (thumbBlob && thumbBlob.getContentType() !== 'application/vnd.google-apps.folder') {
|
|
// Check size? Generic icons are small?
|
|
// Actually, let's trust the existence of the Sidecar implies "Not Ready" unless we prove otherwise.
|
|
// But we want to CLEANUP.
|
|
// Let's use Advanced Drive API to check `thumbnailLink` existence for this specific file, if it has a sidecar.
|
|
// This minimizes API calls to ONLY when we have a sidecar candidate.
|
|
if (sidecarThumbMap.has(d.file.getId())) {
|
|
const fileId = d.file.getId();
|
|
// @ts-ignore
|
|
const drive = Drive;
|
|
const meta = drive.Files.get(fileId, { fields: 'thumbnailLink, hasThumbnail, videoMediaMetadata' });
|
|
|
|
// Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid..
|
|
// Note: Drive sets hasThumbnail=true even for generic icons sometimes?
|
|
// But `thumbnailLink` definitely exists.
|
|
// For videos, `videoMediaMetadata` might NOT have 'width' while processing?
|
|
// Let's check `videoMediaMetadata.width`.
|
|
if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
|
|
// SUCCESS: Drive has finished processing (we have dimensions).
|
|
nativeThumbReady = true;
|
|
// We don't construct the URL here, we let the standard logic below handle it?
|
|
// No, we need the bytes for the frontend or a link.
|
|
// `thumbnailLink` is short lived.
|
|
// Let's use the native generation below.
|
|
console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`);
|
|
|
|
// Cleanup Sidecar Loop
|
|
// TRASH the sidecar file.
|
|
// We need the sidecar ID. We have to map IDs or iterate.
|
|
// Optimization: We didn't store Sidecar ID in the simpler Map.
|
|
// Let's find it.
|
|
const sidecarId = Array.from(sidecarFileIds).find(id => {
|
|
// This is slow: O(N) lookup.
|
|
// But we only do this ONCE per file lifecycle.
|
|
// Actually better to store ID in map?
|
|
// Let's just find the file in `driveFiles` that corresponds.
|
|
// We have `d.customThumbnailId`!
|
|
return id === d.customThumbnailId;
|
|
});
|
|
|
|
if (sidecarId) {
|
|
try {
|
|
this.driveService.trashFile(sidecarId);
|
|
sidecarFileIds.delete(sidecarId); // Remove from set so we don't trip later
|
|
sidecarThumbMap.delete(d.file.getId());
|
|
console.log(`[MediaService] Trashed sidecar ${sidecarId}`);
|
|
} catch (trashErr) {
|
|
console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignore
|
|
}
|
|
|
|
// 1. Check Sidecar (If it still exists after potential cleanup)
|
|
if (sidecarThumbMap.has(d.file.getId())) {
|
|
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
|
|
thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
|
|
isProcessing = true; // SHOW HOURGLASS (Request #3)
|
|
} else if (match && (
|
|
match.status === 'PROCESSING' ||
|
|
match.status === 'UPLOADED' ||
|
|
(match.mediaContentType === 'VIDEO' && (!match.sources || match.sources.length === 0) && match.status !== 'FAILED')
|
|
)) {
|
|
// Shopify Processing (Explicit Status OR Ready-but-missing-sources)
|
|
console.log(`[MediaService] Shopify Media is Processing: ${d.file.getName()} (Status: ${match.status}, Sources: ${match.sources ? match.sources.length : 0})`);
|
|
isProcessing = true;
|
|
// Use Drive thumb as fallback if Shopify preview not ready
|
|
if (!thumbnail) {
|
|
try {
|
|
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
|
|
if (nativeThumb.length > 100) thumbnail = nativeThumb;
|
|
} catch(e) {}
|
|
}
|
|
} else {
|
|
// 2. Native / Fallback
|
|
try {
|
|
// Try to get Drive thumbnail
|
|
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
|
|
if (nativeThumb.length > 100) { // Check if valid (sometimes returns empty?)
|
|
thumbnail = nativeThumb;
|
|
}
|
|
} catch (e) {
|
|
// Processing / Error
|
|
console.warn(`Failed to get native thumbnail for ${d.file.getName()}: ${e}`);
|
|
isProcessing = true; // Assume processing
|
|
thumbnail = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNDgiIHdpZHRoPSI0OCIgdmlld0JveD0iMCAwIDQ4IDQ4Ij48cGF0aCBmaWxsPSIjNDI4NUY0IiBkPSJNMzYgOEgxMmMtMi4yMSAwLTQgMS43OS00IDR2MjRjMCAyLjIxIDEuNzkgNCA0IDRoMjRjMi4yMSAwIDQtMS43OSA0LTRWMTJjMC0yLjIxLTEuNzktNC00LTR6TTIwIDMxVjE3bDEyIDctMTIgN3oiLz48L3N2Zz4=";
|
|
}
|
|
}
|
|
}
|
|
|
|
unifiedState.push({
|
|
id: d.file.getId(), // Use Drive ID as primary key
|
|
driveId: d.file.getId(),
|
|
shopifyId: match ? match.id : null,
|
|
filename: d.file.getName(),
|
|
source: match ? 'synced' : 'drive_only',
|
|
thumbnail: thumbnail,
|
|
status: 'active',
|
|
galleryOrder: d.galleryOrder,
|
|
mimeType: d.file.getMimeType(),
|
|
// Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL
|
|
contentUrl: (match && match.sources)
|
|
? (match.sources.find((s: any) => s.mimeType === 'video/mp4')?.url || match.sources[0]?.url)
|
|
: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`,
|
|
isProcessing: isProcessing
|
|
})
|
|
})
|
|
|
|
// Find Shopify Orphans
|
|
shopifyMedia.forEach(m => {
|
|
if (!matchedShopifyIds.has(m.id)) {
|
|
let mimeType = 'image/jpeg'; // Default
|
|
let contentUrl = "";
|
|
|
|
if (m.mediaContentType === 'VIDEO' && m.sources) {
|
|
// Find MP4
|
|
const mp4 = m.sources.find((s: any) => s.mimeType === 'video/mp4')
|
|
if (mp4) {
|
|
mimeType = mp4.mimeType
|
|
contentUrl = mp4.url
|
|
}
|
|
} else if (m.mediaContentType === 'IMAGE' && m.image) {
|
|
contentUrl = m.image.url
|
|
}
|
|
|
|
// Extract filename from URL (Shopify URLs usually contain the filename)
|
|
let filename = "Orphaned Media";
|
|
try {
|
|
if (contentUrl) {
|
|
// Clean query params and get last segment
|
|
const cleanUrl = contentUrl.split('?')[0];
|
|
const parts = cleanUrl.split('/');
|
|
const candidate = parts.pop();
|
|
if (candidate) filename = candidate;
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to extract filename from URL", e);
|
|
}
|
|
|
|
|
|
unifiedState.push({
|
|
id: m.id, // Use Shopify ID keys for orphans
|
|
driveId: null,
|
|
shopifyId: m.id,
|
|
filename: filename,
|
|
source: 'shopify_only',
|
|
thumbnail: m.preview?.image?.originalSrc || "",
|
|
status: 'active',
|
|
galleryOrder: 10000, // End of list
|
|
mimeType: mimeType,
|
|
contentUrl: contentUrl
|
|
})
|
|
}
|
|
})
|
|
|
|
return unifiedState
|
|
}
|
|
|
|
linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
|
|
console.log(`MediaService: Linking Drive File ${driveId} to Shopify Media ${shopifyId}`);
|
|
// Verify ownership? Maybe later. For now, trust the ID.
|
|
this.driveService.updateFileProperties(driveId, { shopify_media_id: shopifyId });
|
|
return { success: true };
|
|
}
|
|
|
|
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string): string[] {
|
|
const logs: string[] = []
|
|
logs.push(`Starting processing for SKU ${sku}`)
|
|
console.log(`MediaService: Processing changes for SKU ${sku}`)
|
|
|
|
// 0. Service Availability Check & Local Capture (Fixing 'undefined' context issues)
|
|
const shopifySvc = this.shopifyMediaService
|
|
const driveSvc = this.driveService
|
|
|
|
if (!shopifySvc) throw new Error("MediaService Error: shopifyMediaService is undefined")
|
|
if (!driveSvc) throw new Error("MediaService Error: driveService is undefined")
|
|
|
|
// 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 (Orphans not in final state are removed from Shopify)
|
|
const toDelete = currentState.filter(c => !finalIds.has(c.id))
|
|
if (toDelete.length === 0) logs.push("No deletions found.")
|
|
|
|
toDelete.forEach(item => {
|
|
const msg = `Deleting item: ${item.filename}`
|
|
logs.push(msg)
|
|
console.log(msg)
|
|
if (item.shopifyId) {
|
|
shopifySvc.productDeleteMedia(shopifyProductId, item.shopifyId)
|
|
logs.push(`- Deleted from Shopify (${item.shopifyId})`)
|
|
}
|
|
if (item.driveId) {
|
|
// Check for Associated Sidecar Thumbs (Request #2)
|
|
try {
|
|
const f = driveSvc.getFileById(item.driveId);
|
|
// We could inspect properties, or just try to find based on convention if we don't have props handy.
|
|
// But `getUnifiedMediaState` logic shows we store `custom_thumbnail_id`.
|
|
// However, `item` here comes from `getUnifiedMediaState`, but DOES IT include the custom prop?
|
|
// Currently `unifiedState` items don't return `customThumbnailId` property explicitly in the Object.
|
|
// We should probably fetch it or have included it.
|
|
// Re-fetch props to be safe/clean.
|
|
const props = driveSvc.getFileProperties(item.driveId);
|
|
if (props && props['custom_thumbnail_id']) {
|
|
driveSvc.trashFile(props['custom_thumbnail_id']);
|
|
logs.push(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`);
|
|
}
|
|
} catch (ignore) {
|
|
// If file already gone or other error
|
|
}
|
|
|
|
driveSvc.trashFile(item.driveId)
|
|
logs.push(`- Trashed in Drive (${item.driveId})`)
|
|
}
|
|
})
|
|
|
|
// 3. Process Adoptions (Shopify Orphans -> Drive)
|
|
// Identify items that are source='shopify_only' but are KEPT in the final state.
|
|
// These need to be downloaded to become the source of truth in Drive.
|
|
finalState.forEach(item => {
|
|
if (item.source === 'shopify_only' && item.shopifyId) {
|
|
const msg = `Adopting Orphan: ${item.filename}`
|
|
logs.push(msg)
|
|
console.log(msg)
|
|
|
|
try {
|
|
// Download
|
|
const resp = this.networkService.fetch(item.thumbnail, { method: 'get' })
|
|
const blob = resp.getBlob()
|
|
blob.setName(`${sku}_adopted_${Date.now()}.jpg`) // Safety rename
|
|
const file = driveSvc.createFile(blob)
|
|
|
|
// Move to correct folder
|
|
const folder = driveSvc.getOrCreateFolder(sku, this.config.productPhotosFolderId)
|
|
const driveFile = driveSvc.getFileById(file.getId())
|
|
// driveFile.moveTo(folder) // GAS Hack: make sure to add parents/remove parents if needed, or create in place
|
|
// Mock/GAS adapter should handle folder placement correctly if possible, or we assume create puts in root and we move.
|
|
// For this refactor, let's assume `createFile` puts it where it needs to be or we accept root for now.
|
|
// ACTUALLY: The GASDriveService implementation uses DriveApp.createFile which puts in root.
|
|
// We should move it strictly.
|
|
folder.addFile(driveFile)
|
|
DriveApp.getRootFolder().removeFile(driveFile)
|
|
|
|
|
|
driveSvc.updateFileProperties(file.getId(), { shopify_media_id: item.shopifyId })
|
|
|
|
// Update item refs for subsequent steps
|
|
item.driveId = file.getId()
|
|
item.source = 'synced'
|
|
logs.push(`- Adopted to Drive (${file.getId()})`)
|
|
} catch (e) {
|
|
logs.push(`- Failed to adopt ${item.filename}: ${e}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
// 4. Process Uploads (Drive Only -> Shopify)
|
|
const toUpload = finalState.filter(item => item.source === 'drive_only' && item.driveId)
|
|
if (toUpload.length > 0) {
|
|
const msg = `Uploading ${toUpload.length} new items from Drive`
|
|
logs.push(msg)
|
|
const uploads = toUpload.map(item => {
|
|
const f = driveSvc.getFileById(item.driveId)
|
|
return {
|
|
filename: f.getName(),
|
|
mimeType: f.getMimeType(),
|
|
resource: f.getMimeType().startsWith('video/') ? "VIDEO" : "IMAGE",
|
|
fileSize: f.getSize().toString(),
|
|
httpMethod: "POST",
|
|
file: f,
|
|
originalItem: item
|
|
}
|
|
})
|
|
|
|
// ... (Existing upload logic logic, simplified for brevity in plan, but fully implemented here)
|
|
// Batch Staged Uploads
|
|
const stagedInput = uploads.map(u => ({
|
|
filename: u.filename,
|
|
mimeType: u.mimeType,
|
|
resource: u.resource,
|
|
fileSize: u.fileSize,
|
|
httpMethod: u.httpMethod
|
|
}))
|
|
const stagedResp = shopifySvc.stagedUploadsCreate(stagedInput)
|
|
|
|
if (stagedResp.userErrors && stagedResp.userErrors.length > 0) {
|
|
console.error("[MediaService] stagedUploadsCreate Errors:", JSON.stringify(stagedResp.userErrors))
|
|
logs.push(`- Upload preparation failed: ${stagedResp.userErrors.map(e => e.message).join(', ')}`)
|
|
}
|
|
|
|
const targets = stagedResp.stagedTargets
|
|
|
|
const mediaToCreate = []
|
|
uploads.forEach((u, i) => {
|
|
const target = targets[i]
|
|
if (!target || !target.url) {
|
|
logs.push(`- Failed to get upload target for ${u.filename}: Invalid target`)
|
|
console.warn(`[MediaService] Missing target URL for ${u.filename}. Target:`, JSON.stringify(target))
|
|
return
|
|
}
|
|
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: u.resource
|
|
})
|
|
})
|
|
|
|
const createdMedia = shopifySvc.productCreateMedia(shopifyProductId, mediaToCreate)
|
|
if (createdMedia && createdMedia.media) {
|
|
createdMedia.media.forEach((m: any, i: number) => {
|
|
const originalItem = uploads[i].originalItem
|
|
if (m.status === 'FAILED') {
|
|
logs.push(`- Failed to create media for ${originalItem.filename}: ${m.message}`)
|
|
return
|
|
}
|
|
if (m.id) {
|
|
driveSvc.updateFileProperties(originalItem.driveId, { shopify_media_id: m.id })
|
|
originalItem.shopifyId = m.id
|
|
originalItem.source = 'synced'
|
|
logs.push(`- Created in Shopify (${m.id}) and linked`)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 5. Sequential Reordering & Renaming
|
|
// Now that we have Drive IDs and Shopify IDs for everything (orphans adopted, new files uploaded)
|
|
// We update the gallery_order on ALL Drive files to match the finalState order (0-indexed).
|
|
// And we check filenames.
|
|
|
|
const reorderMoves: any[] = []
|
|
|
|
finalState.forEach((item, index) => {
|
|
if (!item.driveId) return // Should not happen if adoption worked, but safety check
|
|
|
|
try {
|
|
const file = driveSvc.getFileById(item.driveId)
|
|
|
|
// A. Update Gallery Order
|
|
driveSvc.updateFileProperties(item.driveId, { gallery_order: index.toString() })
|
|
|
|
// B. Conditional Renaming
|
|
const currentName = file.getName()
|
|
const expectedPrefix = `${sku}_`
|
|
// If name doesn't start with SKU_ or looks like "SKU_timestamp.ext" pattern enforcement
|
|
// The requirement: "Files will only be renamed if they do not conform to the expected pattern"
|
|
// Pattern: startWith sku + "_"
|
|
if (!currentName.startsWith(expectedPrefix)) {
|
|
const ext = currentName.includes('.') ? currentName.split('.').pop() : 'jpg'
|
|
// Use file creation time or now for unique suffix
|
|
const timestamp = new Date().getTime()
|
|
const newName = `${sku}_${timestamp}.${ext}`
|
|
driveSvc.renameFile(item.driveId, newName)
|
|
logs.push(`- Renamed ${currentName} -> ${newName} (Non-conforming)`)
|
|
}
|
|
|
|
// C. Prepare Shopify Reorder
|
|
if (item.shopifyId) {
|
|
reorderMoves.push({ id: item.shopifyId, newPosition: index.toString() })
|
|
}
|
|
|
|
} catch (e) {
|
|
logs.push(`- Error updating ${item.filename}: ${e}`)
|
|
}
|
|
})
|
|
|
|
// 6. Execute Shopify Reorder
|
|
if (reorderMoves.length > 0) {
|
|
shopifySvc.productReorderMedia(shopifyProductId, reorderMoves)
|
|
logs.push("Reordered media in Shopify.")
|
|
}
|
|
|
|
logs.push("Processing Complete.")
|
|
return logs
|
|
}
|
|
}
|
|
|