diff --git a/src/MediaManager.html b/src/MediaManager.html
index 6eec733..8e920a2 100644
--- a/src/MediaManager.html
+++ b/src/MediaManager.html
@@ -1793,6 +1793,26 @@
if (diagnostics.shopify.status === 'ok') {
ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) Open Admin ↗`, 'success');
ui.setShopifyLink(diagnostics.shopify.adminUrl);
+
+ // BooLouMud Link
+ if (diagnostics.shopify.onlineStoreUrl) {
+ var quickLinks = document.getElementById('quick-links');
+ // Check if link already exists (via ID?) or just append if empty.
+ // Since loadMedia clears/resets UI state usually, but quick-links might persist?
+ // Actually quick-links is empty on load? No, let's clear it first to be safe or check.
+ // But ui.setShopifyLink might use it?
+ // Let's perform a direct safe inject.
+ if (quickLinks && !document.getElementById('booloumud-link')) {
+ var link = document.createElement('a');
+ link.id = 'booloumud-link';
+ link.href = diagnostics.shopify.onlineStoreUrl;
+ link.target = '_blank';
+ link.style.textDecoration = 'none';
+ link.style.color = 'var(--primary)';
+ link.innerHTML = 'BooLouMud ↗';
+ quickLinks.appendChild(link);
+ }
+ }
} else if (diagnostics.shopify.status === 'skipped') {
ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info');
} else {
diff --git a/src/interfaces/IShopifyMediaService.ts b/src/interfaces/IShopifyMediaService.ts
index e8436fa..f5a5f00 100644
--- a/src/interfaces/IShopifyMediaService.ts
+++ b/src/interfaces/IShopifyMediaService.ts
@@ -4,5 +4,7 @@ export interface IShopifyMediaService {
getProductMedia(productId: string): any[]
productDeleteMedia(productId: string, mediaId: string): any
productReorderMedia(productId: string, moves: any[]): any
+ getProduct(productId: string): any
+ getProductWithMedia(productId: string): any
getShopDomain(): string
}
diff --git a/src/mediaManager.integration.test.ts b/src/mediaManager.integration.test.ts
index 27fb663..dfde656 100644
--- a/src/mediaManager.integration.test.ts
+++ b/src/mediaManager.integration.test.ts
@@ -20,7 +20,15 @@ const mockShopify = {
productCreateMedia: jest.fn(),
productDeleteMedia: jest.fn(),
productReorderMedia: jest.fn(),
- stagedUploadsCreate: jest.fn()
+ stagedUploadsCreate: jest.fn(),
+ getProductWithMedia: jest.fn().mockImplementation(() => {
+ // Delegate to specific mocks if set, otherwise default
+ const media = mockShopify.getProductMedia() || [];
+ return {
+ product: { id: "gid://shopify/Product/123", title: "Mock Product", handle: "mock-product", onlineStoreUrl: "" },
+ media: media
+ }
+ })
}
const mockNetwork = {
fetch: jest.fn(),
diff --git a/src/services/MediaService.ts b/src/services/MediaService.ts
index 8e7e027..886ee5f 100644
--- a/src/services/MediaService.ts
+++ b/src/services/MediaService.ts
@@ -42,7 +42,7 @@ export class MediaService {
private fetchRawData(sku: string, shopifyProductId: string) {
const result = {
drive: { folder: null, files: [], error: null, folderUrl: null },
- shopify: { media: [], error: null }
+ shopify: { media: [], product: null, error: null }
};
// 1. Unsafe Drive Check
@@ -59,7 +59,11 @@ export class MediaService {
// 2. Unsafe Shopify Check
if (shopifyProductId) {
try {
- result.shopify.media = this.shopifyMediaService.getProductMedia(shopifyProductId);
+ const combined = this.shopifyMediaService.getProductWithMedia(shopifyProductId);
+ if (combined) {
+ result.shopify.media = combined.media;
+ result.shopify.product = combined.product;
+ }
} catch (e) {
result.shopify.error = e;
}
@@ -71,7 +75,7 @@ export class MediaService {
getDiagnostics(sku: string, shopifyProductId: string, rawData?: any) {
const results = {
drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null },
- shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null },
+ shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, onlineStoreUrl: null, error: null },
matching: { status: 'pending', error: null },
activeJobId: null
}
@@ -111,6 +115,14 @@ export class MediaService {
// Admin URL construction (Best effort)
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()}`;
+
+ // Online Store URL logic
+ if (data.shopify.product && data.shopify.product.onlineStoreUrl) {
+ results.shopify.onlineStoreUrl = data.shopify.product.onlineStoreUrl;
+ } else if (data.shopify.product && data.shopify.product.handle) {
+ results.shopify.onlineStoreUrl = `https://${domain}/products/${data.shopify.product.handle}`;
+ }
+
results.shopify.status = 'ok';
}
} else {
@@ -120,7 +132,7 @@ export class MediaService {
return results;
}
- getUnifiedMediaState(sku: string, shopifyProductId: string, rawData?: any): any[] {
+ getUnifiedMediaState(sku: string, shopifyProductId: string, rawData?: any, skipThumbnails: boolean = false): any[] {
console.log(`MediaService: Getting unified state for SKU ${sku}`);
const data = rawData || this.fetchRawData(sku, shopifyProductId);
@@ -164,26 +176,6 @@ export class MediaService {
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) => {
@@ -193,156 +185,201 @@ export class MediaService {
return a.file.getName().localeCompare(b.file.getName());
});
-
- // Batch Status Check for Videos with Sidecars
- const videoStatusMap = new Map();
- // Identify videos that MIGHT be ready (have sidecar)
- const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId()));
-
- if (videosToCheck.length > 0 && typeof Drive !== 'undefined') {
- try {
- // Check status for ALL videos in folder. Easier than filtering by specific IDs in 'q' which has length limits.
- // We assume the folder ID is valid.
- const folderId = data.drive.folder ? data.drive.folder.getId() : null;
- if (folderId) {
- // @ts-ignore
- const response = Drive.Files.list({
- q: `'${folderId}' in parents and mimeType contains 'video/' and trashed = false`,
- fields: 'files(id, hasThumbnail, thumbnailLink, videoMediaMetadata)'
- });
- if (response.files) {
- response.files.forEach((f: any) => videoStatusMap.set(f.id, f));
+ if (!skipThumbnails) {
+ // 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}`);
}
}
- } catch (e) {
- console.warn("[MediaService] Batch video status check failed", e);
- }
- }
+ });
- // Match Logic (Strict ID Match Only)
- driveFileStats.forEach(d => {
- // Skip Sidecar Files in main list
- if (sidecarFileIds.has(d.file.getId())) return;
+ // Batch Status Check for Videos with Sidecars
+ const videoStatusMap = new Map();
+ // Identify videos that MIGHT be ready (have sidecar)
+ const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId()));
- 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)
+ if (videosToCheck.length > 0 && typeof Drive !== 'undefined') {
+ try {
+ // Check status for ALL videos in folder. Easier than filtering by specific IDs in 'q' which has length limits.
+ // We assume the folder ID is valid.
+ const folderId = data.drive.folder ? data.drive.folder.getId() : null;
+ if (folderId) {
+ // @ts-ignore
+ const response = Drive.Files.list({
+ q: `'${folderId}' in parents and mimeType contains 'video/' and trashed = false`,
+ fields: 'files(id, hasThumbnail, thumbnailLink, videoMediaMetadata)'
+ });
+ if (response.files) {
+ response.files.forEach((f: any) => videoStatusMap.set(f.id, f));
+ }
+ }
+ } catch (e) {
+ console.warn("[MediaService] Batch video status check failed", e);
+ }
}
- // 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 = "";
+ // Match Logic (Strict ID Match Only)
+ driveFileStats.forEach(d => {
+ // Skip Sidecar Files in main list
+ if (sidecarFileIds.has(d.file.getId())) return;
- 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.
- // Batch Optimized Check
- if (videoStatusMap.has(d.file.getId())) {
- const meta = videoStatusMap.get(d.file.getId());
- // Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid..
- // Check `videoMediaMetadata.width` to ensure processing is complete (width is often missing during processing)
- if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
- // SUCCESS: Drive has finished processing.
- nativeThumbReady = true;
- console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`);
+ let match = null
+ let isProcessing = false
+ let thumbnail = "";
- // Cleanup Sidecar
- const sidecarId = d.customThumbnailId; // Direct lookup from properties
- if (sidecarId) {
- try {
- this.driveService.trashFile(sidecarId);
- sidecarFileIds.delete(sidecarId);
- sidecarThumbMap.delete(d.file.getId());
- console.log(`[MediaService] Trashed sidecar ${sidecarId}`);
- } catch (trashErr) {
- console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr);
- }
+ // 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;
+
+ 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.
+ // Batch Optimized Check
+ if (videoStatusMap.has(d.file.getId())) {
+ const meta = videoStatusMap.get(d.file.getId());
+ // Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid..
+ // Check `videoMediaMetadata.width` to ensure processing is complete (width is often missing during processing)
+ if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
+ // SUCCESS: Drive has finished processing.
+ nativeThumbReady = true;
+ console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`);
+
+ // Cleanup Sidecar
+ const sidecarId = d.customThumbnailId; // Direct lookup from properties
+ if (sidecarId) {
+ try {
+ this.driveService.trashFile(sidecarId);
+ sidecarFileIds.delete(sidecarId);
+ 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 individual file errors
- }
-
- // 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) { /* ignore thumbnail generation error */ }
- }
- } 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=";
+ // Ignore individual file errors
}
- }
- }
- 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
+ // 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) { /* ignore thumbnail generation error */ }
+ }
+ } 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
+ })
})
- })
+ } else {
+ // Skip Thumbnails Logic (Fast Path)
+ driveFileStats.forEach(d => {
+ // Minimal State for Planning
+ let match = null
+ if (d.shopifyId) {
+ match = shopifyMedia.find(m => m.id === d.shopifyId)
+ if (match) matchedShopifyIds.add(match.id)
+ }
+
+ unifiedState.push({
+ id: d.file.getId(),
+ driveId: d.file.getId(),
+ shopifyId: match ? match.id : null,
+ filename: d.file.getName(),
+ source: match ? 'synced' : 'drive_only',
+ thumbnail: "", // Skipped
+ status: 'active',
+ galleryOrder: d.galleryOrder,
+ mimeType: d.file.getMimeType(),
+ contentUrl: "", // Skipped
+ isProcessing: false
+ })
+ });
+ }
// Find Shopify Orphans
shopifyMedia.forEach(m => {
@@ -403,7 +440,7 @@ export class MediaService {
calculatePlan(sku: string, finalState: any[], shopifyProductId: string) {
// 1. Get Current State
- const currentState = this.getUnifiedMediaState(sku, shopifyProductId);
+ const currentState = this.getUnifiedMediaState(sku, shopifyProductId, undefined, true);
const finalIds = new Set(finalState.map(f => f.id));
// 2. Identify Deletions
diff --git a/src/services/MockShopifyMediaService.ts b/src/services/MockShopifyMediaService.ts
index 24a84dc..91eb84b 100644
--- a/src/services/MockShopifyMediaService.ts
+++ b/src/services/MockShopifyMediaService.ts
@@ -33,6 +33,22 @@ export class MockShopifyMediaService implements IShopifyMediaService {
return []
}
+ getProduct(productId: string): any {
+ return {
+ id: productId,
+ title: "Mock Product",
+ handle: "mock-product",
+ onlineStoreUrl: "https://mock-shop.myshopify.com/products/mock-product"
+ }
+ }
+
+ getProductWithMedia(productId: string): any {
+ return {
+ product: this.getProduct(productId),
+ media: this.getProductMedia(productId)
+ };
+ }
+
productDeleteMedia(productId: string, mediaId: string): any {
return {
productDeleteMedia: {
diff --git a/src/services/ShopifyMediaService.ts b/src/services/ShopifyMediaService.ts
index 0867c44..2604a39 100644
--- a/src/services/ShopifyMediaService.ts
+++ b/src/services/ShopifyMediaService.ts
@@ -106,6 +106,80 @@ export class ShopifyMediaService implements IShopifyMediaService {
return response.content.data.product.media.edges.map((edge: any) => edge.node)
}
+ getProduct(productId: string): any {
+ const query = /* GraphQL */ `
+ query getProduct($productId: ID!) {
+ product(id: $productId) {
+ id
+ title
+ handle
+ onlineStoreUrl
+ }
+ }
+ `
+ const variables = { productId }
+ const payload = buildGqlQuery(query, variables)
+ const response = this.shop.shopifyGraphQLAPI(payload)
+ if (!response || !response.content || !response.content.data || !response.content.data.product) {
+ console.warn("getProduct: Product not found or access denied for ID:", productId);
+ return null;
+ }
+ return response.content.data.product
+ }
+
+ getProductWithMedia(productId: string): any {
+ const query = /* GraphQL */ `
+ query getProductWithMedia($productId: ID!) {
+ product(id: $productId) {
+ id
+ title
+ handle
+ onlineStoreUrl
+ media(first: 250) {
+ edges {
+ node {
+ id
+ alt
+ mediaContentType
+ status
+ preview {
+ image {
+ originalSrc
+ }
+ }
+ ... on Video {
+ sources {
+ url
+ mimeType
+ }
+ }
+ ... on MediaImage {
+ image {
+ url
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ `
+ const variables = { productId }
+ const payload = buildGqlQuery(query, variables)
+ const response = this.shop.shopifyGraphQLAPI(payload)
+ if (!response || !response.content || !response.content.data || !response.content.data.product) {
+ console.warn("getProductWithMedia: Product not found or access denied for ID:", productId);
+ return null;
+ }
+
+ // Normalize return structure to match expectations
+ const p = response.content.data.product;
+ return {
+ product: { id: p.id, title: p.title, handle: p.handle, onlineStoreUrl: p.onlineStoreUrl },
+ media: p.media.edges.map((edge: any) => edge.node)
+ };
+ }
+
productDeleteMedia(productId: string, mediaId: string): any {
const query = /* GraphQL */ `
mutation productDeleteMedia($mediaIds: [ID!]!, $productId: ID!) {