diff --git a/src/MediaManager.html b/src/MediaManager.html index 63c21db..8699982 100644 --- a/src/MediaManager.html +++ b/src/MediaManager.html @@ -4,15 +4,20 @@ + + + +
+
+
+

Gallery (0)

+ +
+
+ + +
+
+ + + -
-
-

Current Media

- -
- -
+ +
+ +
+ + +
+
+ + + +
+
+
Connecting...
+
+ + + -
-
-
Scanning Sheet...
+ + - - \ No newline at end of file diff --git a/src/global.ts b/src/global.ts index 17b45f7..a22cb29 100644 --- a/src/global.ts +++ b/src/global.ts @@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate" import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar" import { checkRecentSales, reconcileSalesHandler } from "./salesSync" import { installSalesSyncTrigger } from "./triggers" -import { showMediaManager, getSelectedSku, getMediaForSku, saveFileToDrive, syncMediaForSku, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers" +import { showMediaManager, getSelectedSku, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers" import { runSystemDiagnostics } from "./verificationSuite" // prettier-ignore @@ -55,7 +55,8 @@ import { runSystemDiagnostics } from "./verificationSuite" ;(global as any).getSelectedSku = getSelectedSku ;(global as any).getMediaForSku = getMediaForSku ;(global as any).saveFileToDrive = saveFileToDrive -;(global as any).syncMediaForSku = syncMediaForSku +;(global as any).saveMediaChanges = saveMediaChanges +;(global as any).getMediaDiagnostics = getMediaDiagnostics ;(global as any).getPickerConfig = getPickerConfig ;(global as any).importFromPicker = importFromPicker ;(global as any).runSystemDiagnostics = runSystemDiagnostics diff --git a/src/interfaces/IDriveService.ts b/src/interfaces/IDriveService.ts index d62fe81..22ffa28 100644 --- a/src/interfaces/IDriveService.ts +++ b/src/interfaces/IDriveService.ts @@ -7,4 +7,5 @@ export interface IDriveService { trashFile(fileId: string): void updateFileProperties(fileId: string, properties: {[key: string]: string}): void createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File + getFileProperties(fileId: string): {[key: string]: string} } diff --git a/src/interfaces/IShop.ts b/src/interfaces/IShop.ts index 6764da7..ff43f0d 100644 --- a/src/interfaces/IShop.ts +++ b/src/interfaces/IShop.ts @@ -10,4 +10,5 @@ export interface IShop { SetInventoryItemDefaults(item: shopify.InventoryItem, config: Config): shopify.InventoryItem; SetInventoryItemWeight(item: shopify.InventoryItem, config: Config, weight: number, weight_unit: shopify.WeightUnit): shopify.InventoryItem; shopifyGraphQLAPI(payload: any): any; + getShopDomain(): string; } diff --git a/src/interfaces/IShopifyMediaService.ts b/src/interfaces/IShopifyMediaService.ts index 46973ef..e8436fa 100644 --- a/src/interfaces/IShopifyMediaService.ts +++ b/src/interfaces/IShopifyMediaService.ts @@ -4,4 +4,5 @@ export interface IShopifyMediaService { getProductMedia(productId: string): any[] productDeleteMedia(productId: string, mediaId: string): any productReorderMedia(productId: string, moves: any[]): any + getShopDomain(): string } diff --git a/src/mediaHandlers.ts b/src/mediaHandlers.ts index 9883d33..0647246 100644 --- a/src/mediaHandlers.ts +++ b/src/mediaHandlers.ts @@ -47,6 +47,13 @@ export function getMediaForSku(sku: string): any[] { // Resolve Product ID (Best Effort) const product = new Product(sku) + // Ensure we have the latest correct ID from Shopify, repairing the sheet if needed + try { + product.MatchToShopifyProduct(shop); + } catch (e) { + console.warn("MatchToShopifyProduct failed", e); + } + const shopifyId = product.shopify_id || "" return mediaService.getUnifiedMediaState(sku, shopifyId) @@ -61,6 +68,13 @@ export function saveMediaChanges(sku: string, finalState: any[]) { const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config) const product = new Product(sku) + // Ensure we have the latest correct ID from Shopify + try { + product.MatchToShopifyProduct(shop); + } catch (e) { + console.warn("MatchToShopifyProduct failed", e); + } + if (!product.shopify_id) { // Allow saving Drive-only changes? No, we need Shopify context for "Staging" usually. // But if we just rename drive files, we could? @@ -68,7 +82,30 @@ export function saveMediaChanges(sku: string, finalState: any[]) { throw new Error("Product must be synced to Shopify before saving media changes.") } - mediaService.processMediaChanges(sku, finalState, product.shopify_id) + return mediaService.processMediaChanges(sku, finalState, product.shopify_id) +} + + +export function getMediaDiagnostics(sku: string) { + const config = new Config() + const driveService = new GASDriveService() + const shop = new Shop() + const shopifyMediaService = new ShopifyMediaService(shop) + const networkService = new GASNetworkService() + const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config) + + // Resolve Product ID + const product = new Product(sku) + // Ensure we have the latest correct ID from Shopify + try { + product.MatchToShopifyProduct(shop); + } catch (e) { + console.warn("MatchToShopifyProduct failed", e); + } + + const shopifyId = product.shopify_id || "" + + return mediaService.getDiagnostics(sku, shopifyId) } export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) { diff --git a/src/services/GASDriveService.ts b/src/services/GASDriveService.ts index a3f08a2..f9d25f5 100644 --- a/src/services/GASDriveService.ts +++ b/src/services/GASDriveService.ts @@ -40,42 +40,63 @@ export class GASDriveService implements IDriveService { } 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. + if (typeof Drive === 'undefined') { + console.warn("Advanced Drive Service not enabled. Cannot update file properties.") + return + } + 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 + // Check version heuristic: v3 has 'create', v2 has 'insert' + const isV3 = !!drive.Files.create - // 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) + if (isV3) { + // v3: appProperties { key: value } + drive.Files.update({ appProperties: properties }, fileId) } else { - console.warn("Drive Global found but no update method?") + // v2: properties [{ key: ..., value: ..., visibility: 'PRIVATE' }] + // Note: 'PRIVATE' maps to appProperties behavior usually. 'PUBLIC' is default? + // Actually in v2 'properties' are array. + const v2Props = Object.keys(properties).map(k => ({ + key: k, + value: properties[k], + visibility: 'PRIVATE' + })) + drive.Files.update({ properties: v2Props }, fileId) } } catch (e) { - console.error("Failed to update file properties", e) - // Fallback: Description hacking? No, let's fail or log. + console.error(`Failed to update file properties for ${fileId}`, e) } } createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File { - return DriveApp.createFile(blob) + return DriveApp.createFile(blob) + } + + getFileProperties(fileId: string): {[key: string]: string} { + if (typeof Drive === 'undefined') return {} + + try { + const drive = Drive as any + const isV3 = !!drive.Files.create + + if (isV3) { + const file = drive.Files.get(fileId, { fields: 'appProperties' }) + return file.appProperties || {} + } else { + // v2: fields='properties' + const file = drive.Files.get(fileId, { fields: 'properties' }) + const propsList = file.properties || [] + // Convert list [{key, value}] to map + const propsMap: {[key: string]: string} = {} + propsList.forEach((p: any) => { + propsMap[p.key] = p.value + }) + return propsMap + } + } catch (e) { + console.warn(`Failed to get properties for file ${fileId}: ${e}`) + return {} + } } } diff --git a/src/services/MediaService.test.ts b/src/services/MediaService.test.ts index dc33489..62601c7 100644 --- a/src/services/MediaService.test.ts +++ b/src/services/MediaService.test.ts @@ -7,19 +7,22 @@ import { Config } from "../config" class MockNetworkService implements INetworkService { lastUrl: string = "" - lastPayload: any = {} - - fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse { + fetch(url: string, params: any): GoogleAppsScript.URL_Fetch.HTTPResponse { this.lastUrl = url - this.lastPayload = params.payload + let blobName = "mock_blob" return { getResponseCode: () => 200, - getBlob: () => ({ getBytes: () => [], getContentType: () => "image/jpeg", setName: () => {} }) + getBlob: () => ({ + getBytes: () => [], + getContentType: () => "image/jpeg", + getName: () => blobName, + setName: (n) => { blobName = n } + } as any) } as unknown as GoogleAppsScript.URL_Fetch.HTTPResponse } } -describe("MediaService", () => { +describe("MediaService Robust Sync", () => { let mediaService: MediaService let driveService: MockDriveService let shopifyService: MockShopifyMediaService @@ -34,85 +37,130 @@ describe("MediaService", () => { 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 + // Clear Drive global mock since we are not using it (to ensure isolation) + global.Drive = undefined as any + // Mock DriveApp for removeFile + global.DriveApp = { + getRootFolder: () => ({ + removeFile: (f) => {} }) } as any - global.Drive = { Files: { get: () => ({ appProperties: {} }) } } as any - global.UrlFetchApp = networkService as unknown as GoogleAppsScript.URL_Fetch.UrlFetchApp }) - test("getUnifiedMediaState should match files", () => { + test("Strict Matching: Only matches via property, ignores filename", () => { const folder = driveService.getOrCreateFolder("SKU123", "root") - const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as unknown as GoogleAppsScript.Base.Blob - driveService.saveFile(blob1, folder.getId()) + + // File 1: Has ID property -> Should Match + const blob1 = { getName: () => "img1.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any + const f1 = driveService.saveFile(blob1, folder.getId()) + driveService.updateFileProperties(f1.getId(), { shopify_media_id: "gid://shopify/Media/123" }) + + // File 2: No property, Same Name as Shopify Media -> Should NOT Match (Strict) + const blob2 = { getName: () => "img2.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any + const f2 = driveService.saveFile(blob2, folder.getId()) + // No property set for f2 + + // Shopify Side + shopifyService.getProductMedia = jest.fn().mockReturnValue([ + { id: "gid://shopify/Media/123", filename: "img1.jpg" }, + { id: "gid://shopify/Media/456", filename: "img2.jpg" } // Exists in Shopify, but f2 shouldn't link to it + ]) const state = mediaService.getUnifiedMediaState("SKU123", "pid") - expect(state).toHaveLength(1) - expect(state[0].filename).toBe("01.jpg") + + // Expect 3 items: + // 1. Linked File (f1 <-> 123) + // 2. Unlinked File (f2) + // 3. Orphaned Shopify Media (456) + + expect(state).toHaveLength(3) + + const linked = state.find(s => s.id === f1.getId()) + expect(linked.source).toBe("synced") + expect(linked.shopifyId).toBe("gid://shopify/Media/123") + + const unlinked = state.find(s => s.id === f2.getId()) + expect(unlinked.source).toBe("drive_only") + expect(unlinked.shopifyId).toBeNull() + + const orphan = state.find(s => s.id === "gid://shopify/Media/456") + expect(orphan.source).toBe("shopify_only") }) - 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()) + test("Sorting: Respects gallery_order then filename", () => { + const folder = driveService.getOrCreateFolder("SKU123", "root") - // Update Shopify Mock to return this media - shopifyService.getProductMedia = jest.fn().mockReturnValue([{ - id: "gid://shopify/Media/media_1", - alt: "delete_me.jpg" - }]) + const fA = driveService.saveFile({ getName: () => "A.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) + const fB = driveService.saveFile({ getName: () => "B.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) + const fC = driveService.saveFile({ getName: () => "C.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) - // Update global Drive to return synced ID - global.Drive = { Files: { get: () => ({ appProperties: { shopify_media_id: "gid://shopify/Media/media_1" } }) } } as any + // Order: C (0), A (1), B (No Order -> 9999) + driveService.updateFileProperties(fC.getId(), { gallery_order: "0" }) + driveService.updateFileProperties(fA.getId(), { gallery_order: "1" }) - const finalState = [] + const state = mediaService.getUnifiedMediaState("SKU123", "pid") - const deleteSpy = jest.spyOn(shopifyService, 'productDeleteMedia') - const trashSpy = jest.spyOn(driveService, 'trashFile') - - mediaService.processMediaChanges("SKU123", finalState, "pid") - - expect(deleteSpy).toHaveBeenCalled() - expect(trashSpy).toHaveBeenCalled() + expect(state[0].id).toBe(fC.getId()) // 0 + expect(state[1].id).toBe(fA.getId()) // 1 + expect(state[2].id).toBe(fB.getId()) // 9999 }) - test("processMediaChanges should handle backfills (Shopify -> Drive)", () => { - // Current state: Empty Drive, 1 Shopify Media + test("Adoption: Orphan in finalState is downloaded and linked", () => { shopifyService.getProductMedia = jest.fn().mockReturnValue([{ - id: "gid://shopify/Media/media_2", - alt: "backfill.jpg", - preview: { image: { originalSrc: "http://shopify.com/img.jpg" } } + id: "gid://shopify/Media/orphan", + preview: { image: { originalSrc: "http://img.com/orphan.jpg" } } }]) - // Final state: 1 item (the backfilled one) + // Final state keeps the orphan (triggering adoption) const finalState = [{ - id: "gid://shopify/Media/media_2", - filename: "backfill.jpg", - status: "synced" + id: "gid://shopify/Media/orphan", + shopifyId: "gid://shopify/Media/orphan", + source: "shopify_only", + filename: "orphan", + thumbnail: "http://img.com/orphan.jpg" }] - // Mock network fetch for download - jest.spyOn(networkService, 'fetch') - mediaService.processMediaChanges("SKU123", finalState, "pid") - // Should create file in Drive + // Verify file created const folder = driveService.getOrCreateFolder("SKU123", "root") const files = driveService.getFiles(folder.getId()) expect(files).toHaveLength(1) - expect(files[0].getName()).toBe("backfill.jpg") + + const file = files[0] + expect(file.getName()).toMatch(/^SKU123_adopted_/) // Safety rename check + + // Verify properties set + const props = driveService.getFileProperties(file.getId()) + expect(props['shopify_media_id']).toBe("gid://shopify/Media/orphan") + }) + + test("Sequential Reordering & Renaming on Save", () => { + const folder = driveService.getOrCreateFolder("SKU123", "root") + // Create 2 files with bad names and no order + const f1 = driveService.saveFile({ getName: () => "bad_name_1.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) + const f2 = driveService.saveFile({ getName: () => "SKU123_good.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) + + // Simulate Final State: swapped order + const finalState = [ + { id: f2.getId(), driveId: f2.getId(), filename: "SKU123_good.jpg" }, // Index 0 + { id: f1.getId(), driveId: f1.getId(), filename: "bad_name_1.jpg" } // Index 1 + ] + + const spyRename = jest.spyOn(driveService, 'renameFile') + const spyUpdate = jest.spyOn(driveService, 'updateFileProperties') + + mediaService.processMediaChanges("SKU123", finalState, "pid") + + // 1. Verify Order Updates + expect(spyUpdate).toHaveBeenCalledWith(f2.getId(), expect.objectContaining({ gallery_order: "0" })) + expect(spyUpdate).toHaveBeenCalledWith(f1.getId(), expect.objectContaining({ gallery_order: "1" })) + + // 2. Verify Renaming (Only f1 should be renamed) + expect(spyRename).toHaveBeenCalledWith(f1.getId(), expect.stringMatching(/^SKU123_\d+\.jpg$/)) + expect(spyRename).not.toHaveBeenCalledWith(f2.getId(), expect.anything()) }) }) diff --git a/src/services/MediaService.ts b/src/services/MediaService.ts index 47d40ac..35ad249 100644 --- a/src/services/MediaService.ts +++ b/src/services/MediaService.ts @@ -23,6 +23,48 @@ export class MediaService { + + 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}`) @@ -41,30 +83,33 @@ export class MediaService { const matchedShopifyIds = new Set() // Map of Drive Files - const driveMap = new Map() - - driveFiles.forEach(f => { + const driveFileStats = driveFiles.map(f => { let shopifyId = null + let galleryOrder = 9999 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'] - } + 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']) } } catch (e) { console.warn(`Failed to get properties for ${f.getName()}`) } - - driveMap.set(f.getId(), { file: f, shopifyId }) + return { file: f, shopifyId, galleryOrder } }) - // Match Logic - driveFiles.forEach(f => { - const d = driveMap.get(f.getId()) - if (!d) return + // 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 => { let match = null // 1. ID Match @@ -73,23 +118,17 @@ export class MediaService { 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) - } + // NO Filename Fallback matching per new design "Strict Linkage" unifiedState.push({ - id: f.getId(), // Use Drive ID as primary key for "Synced" or "Drive" items - driveId: f.getId(), + id: d.file.getId(), // Use Drive ID as primary key + driveId: d.file.getId(), shopifyId: match ? match.id : null, - filename: f.getName(), + filename: d.file.getName(), source: match ? 'synced' : 'drive_only', - thumbnail: `data:image/jpeg;base64,${Utilities.base64Encode(f.getThumbnail().getBytes())}`, // Expensive? - status: 'active' + thumbnail: `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`, + status: 'active', + galleryOrder: d.galleryOrder }) }) @@ -100,10 +139,12 @@ export class MediaService { id: m.id, // Use Shopify ID keys for orphans driveId: null, shopifyId: m.id, - filename: "Shopify Media", // TODO: extract real name + filename: "Orphaned Media", // Shopify doesn't always expose filename cleanly in same way + // Try to get filename if possible or fallback source: 'shopify_only', thumbnail: m.preview?.image?.originalSrc || "", - status: 'active' + status: 'active', + galleryOrder: 10000 // End of list }) } }) @@ -111,79 +152,106 @@ export class MediaService { return unifiedState } - processMediaChanges(sku: string, finalState: any[], shopifyProductId: string) { + 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 + // 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 => { - console.log(`Deleting item: ${item.filename}`) + const msg = `Deleting item: ${item.filename}` + logs.push(msg) + console.log(msg) if (item.shopifyId) { - this.shopifyMediaService.productDeleteMedia(shopifyProductId, item.shopifyId) + shopifySvc.productDeleteMedia(shopifyProductId, item.shopifyId) + logs.push(`- Deleted from Shopify (${item.shopifyId})`) } if (item.driveId) { - this.driveService.trashFile(item.driveId) + driveSvc.trashFile(item.driveId) + logs.push(`- Trashed in Drive (${item.driveId})`) } }) - // 3. Process Backfills (Shopify Only -> Drive) + // 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) { - 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) + const msg = `Adopting Orphan: ${item.filename}` + logs.push(msg) + console.log(msg) - // 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) + 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) - this.driveService.updateFileProperties(file.getId(), { shopify_media_id: item.shopifyId }) + // 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) - // Update item refs for subsequent steps - item.driveId = file.getId() - item.source = 'synced' + + 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) { - console.log(`Uploading ${toUpload.length} new items from Drive`) - + const msg = `Uploading ${toUpload.length} new items from Drive` + logs.push(msg) const uploads = toUpload.map(item => { - const f = this.driveService.getFileById(item.driveId) + const f = driveSvc.getFileById(item.driveId) return { filename: f.getName(), mimeType: f.getMimeType(), resource: "IMAGE", httpMethod: "POST", - file: f + 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, httpMethod: u.httpMethod })) - const stagedResp = this.shopifyMediaService.stagedUploadsCreate(stagedInput) + const stagedResp = shopifySvc.stagedUploadsCreate(stagedInput) const targets = stagedResp.stagedTargets const mediaToCreate = [] @@ -192,9 +260,7 @@ export class MediaService { 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, @@ -202,49 +268,73 @@ export class MediaService { }) }) - // Create Media (Updated to return IDs) - const createdMedia = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate) + const createdMedia = shopifySvc.productCreateMedia(shopifyProductId, mediaToCreate) if (createdMedia && createdMedia.media) { createdMedia.media.forEach((m: any, i: number) => { - const originalItem = toUpload[i] + const originalItem = uploads[i].originalItem if (m.status === 'FAILED') { - console.error("Media create failed", m) + logs.push(`- Failed to create media for ${originalItem.filename}: ${m.message}`) return } if (m.id) { - this.driveService.updateFileProperties(originalItem.driveId, { shopify_media_id: 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. Process Reordering - const moves: any[] = [] + // 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.shopifyId) { - moves.push({ id: item.shopifyId, newPosition: index.toString() }) + 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}`) } }) - if (moves.length > 0) { - this.shopifyMediaService.productReorderMedia(shopifyProductId, moves) + + // 6. Execute Shopify Reorder + if (reorderMoves.length > 0) { + shopifySvc.productReorderMedia(shopifyProductId, reorderMoves) + logs.push("Reordered media in Shopify.") } - // 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) - } - } - }) + logs.push("Processing Complete.") + return logs } } diff --git a/src/services/MockDriveService.ts b/src/services/MockDriveService.ts index f13a001..6a2a6c5 100644 --- a/src/services/MockDriveService.ts +++ b/src/services/MockDriveService.ts @@ -12,11 +12,29 @@ export class MockDriveService implements IDriveService { // Mock implementation finding by name "under" parent const key = `${parentFolderId}/${folderName}` if (!this.folders.has(key)) { + const id = `mock_folder_${folderName}_id` const newFolder = { - getId: () => `mock_folder_${folderName}_id`, + getId: () => id, getName: () => folderName, getUrl: () => `https://mock.drive/folders/${folderName}`, - createFile: (blob) => this.saveFile(blob, `mock_folder_${folderName}_id`) + createFile: (blob) => this.saveFile(blob, id), + addFile: (file) => { + console.log(`[MockDrive] addFile: Adding ${file.getId()} to ${id}`) + // Remove from all other folders (simplification) or just 'root' + for (const [fId, files] of this.files.entries()) { + const idx = files.findIndex(f => f.getId() === file.getId()) + if (idx !== -1) { + console.log(`[MockDrive] Removed ${file.getId()} from ${fId}`) + files.splice(idx, 1) + } + } + // Add to this folder + if (!this.files.has(id)) { + this.files.set(id, []) + } + this.files.get(id).push(file) + return newFolder + } } as unknown as GoogleAppsScript.Drive.Folder; this.folders.set(key, newFolder) } @@ -24,20 +42,26 @@ export class MockDriveService implements IDriveService { } saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File { + const id = `mock_file_${Date.now()}_${Math.floor(Math.random() * 1000)}` const newFile = { - getId: () => `mock_file_${Date.now()}`, + getId: () => id, getName: () => blob.getName(), getBlob: () => blob, getUrl: () => `https://mock.drive/files/${blob.getName()}`, getLastUpdated: () => new Date(), - getThumbnail: () => ({ getBytes: () => [] }) + getThumbnail: () => ({ getBytes: () => [] }), + getAppProperty: (key) => { + return (newFile as any)._properties?.[key] + } } as unknown as GoogleAppsScript.Drive.File + // Initialize properties container + ;(newFile as any)._properties = {} + 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 } @@ -69,10 +93,25 @@ export class MockDriveService implements IDriveService { updateFileProperties(fileId: string, properties: any): void { console.log(`[MockDrive] Updating properties for ${fileId}`, properties) + const file = this.getFileById(fileId) + const mockFile = file as any + if (!mockFile._properties) { + mockFile._properties = {} + } + Object.assign(mockFile._properties, properties) } createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File { // Create in "root" or similar return this.saveFile(blob, "root") } + + getFileProperties(fileId: string): {[key: string]: string} { + try { + const file = this.getFileById(fileId) + return (file as any)._properties || {} + } catch (e) { + return {} + } + } } diff --git a/src/services/MockShopifyMediaService.ts b/src/services/MockShopifyMediaService.ts index 6a58cd3..24a84dc 100644 --- a/src/services/MockShopifyMediaService.ts +++ b/src/services/MockShopifyMediaService.ts @@ -50,4 +50,8 @@ export class MockShopifyMediaService implements IShopifyMediaService { } } } + + getShopDomain(): string { + return 'mock-shop.myshopify.com'; + } } diff --git a/src/services/ShopifyMediaService.ts b/src/services/ShopifyMediaService.ts index fe00670..e939223 100644 --- a/src/services/ShopifyMediaService.ts +++ b/src/services/ShopifyMediaService.ts @@ -1,6 +1,6 @@ import { IShopifyMediaService } from "../interfaces/IShopifyMediaService" import { IShop } from "../interfaces/IShop" -import { formatGqlForJSON } from "../shopifyApi" +import { formatGqlForJSON, buildGqlQuery } from "../shopifyApi" export class ShopifyMediaService implements IShopifyMediaService { private shop: IShop @@ -29,10 +29,7 @@ export class ShopifyMediaService implements IShopifyMediaService { } ` const variables = { input } - const payload = { - query: formatGqlForJSON(query), - variables: variables - } + const payload = buildGqlQuery(query, variables) const response = this.shop.shopifyGraphQLAPI(payload) return response.content.data.stagedUploadsCreate } @@ -62,10 +59,7 @@ export class ShopifyMediaService implements IShopifyMediaService { productId, media } - const payload = { - query: formatGqlForJSON(query), - variables: variables - } + const payload = buildGqlQuery(query, variables) const response = this.shop.shopifyGraphQLAPI(payload) return response.content.data.productCreateMedia } @@ -91,33 +85,37 @@ export class ShopifyMediaService implements IShopifyMediaService { } ` const variables = { productId } - const payload = { - query: formatGqlForJSON(query), - variables: variables - } + const payload = buildGqlQuery(query, variables) const response = this.shop.shopifyGraphQLAPI(payload) - if (!response.content.data.product) return [] + if (!response || !response.content || !response.content.data || !response.content.data.product) { + console.error("getProductMedia: Invalid response or product not found. Raw Response:", JSON.stringify(response)); + throw new Error(`Product not found or access denied for ID: ${productId}. See Logs for details.`); + } 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 { + mutation productDeleteMedia($mediaIds: [ID!]!, $productId: ID!) { + productDeleteMedia(mediaIds: $mediaIds, productId: $productId) { + deletedMediaIds + mediaUserErrors { field message } } } ` - const variables = { productId, mediaId } - const payload = { - query: formatGqlForJSON(query), - variables: variables - } + const variables = { productId, mediaIds: [mediaId] } + const payload = buildGqlQuery(query, variables) const response = this.shop.shopifyGraphQLAPI(payload) + if (!response || !response.content || !response.content.data) { + console.error("productDeleteMedia failed. Response:", JSON.stringify(response)) + if (response && response.content && response.content.errors) { + console.error("GraphQL Errors:", JSON.stringify(response.content.errors)) + } + throw new Error(`Shopify API failed for productDeleteMedia: ${response ? 'Invalid Response' : 'No Response'}`) + } return response.content.data.productDeleteMedia } @@ -137,11 +135,13 @@ export class ShopifyMediaService implements IShopifyMediaService { } ` const variables = { id: productId, moves } - const payload = { - query: formatGqlForJSON(query), - variables: variables - } + const payload = buildGqlQuery(query, variables) const response = this.shop.shopifyGraphQLAPI(payload) return response.content.data.productReorderMedia + return response.content.data.productReorderMedia + } + + getShopDomain(): string { + return this.shop.getShopDomain() } } diff --git a/src/shopifyApi.ts b/src/shopifyApi.ts index 191421b..fa2de50 100644 --- a/src/shopifyApi.ts +++ b/src/shopifyApi.ts @@ -889,6 +889,11 @@ export class Shop implements IShop { } return url } + + getShopDomain(): string { + // Extract from https://{shop}.myshopify.com + return this.shopifyApiURI.replace('https://', '').replace(/\/$/, ''); + } } export class Order { diff --git a/src/test/MockShop.ts b/src/test/MockShop.ts index 7a43910..4456680 100644 --- a/src/test/MockShop.ts +++ b/src/test/MockShop.ts @@ -50,4 +50,8 @@ export class MockShop implements IShop { SetInventoryItemQuantity(item: shopify.InventoryItem, quantity: number, config: Config): any { return {}; } SetInventoryItemDefaults(item: shopify.InventoryItem, config: Config): shopify.InventoryItem { return {} as any; } SetInventoryItemWeight(item: shopify.InventoryItem, config: Config, weight: number, weight_unit: shopify.WeightUnit): shopify.InventoryItem { return {} as any; } + + getShopDomain(): string { + return "mock-shop.myshopify.com"; + } }