Compare commits
2 Commits
8554ae9610
...
6e1222cec9
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e1222cec9 | |||
| a9cb63fd67 |
111
docs/MEDIA_MANAGER_DESIGN.md
Normal file
111
docs/MEDIA_MANAGER_DESIGN.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# Media Manager V2 Design & Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The **Media Manager V2** transforms the product image management experience from a simple upload utility to a full-featured "WYSIWYG" editor. It introduces a persistent "Draft" state, drag-and-drop reordering, and a robust generic synchronization engine that reconciles state between Google Drive (Source of Truth) and Shopify.
|
||||||
|
|
||||||
|
## UI UX Design
|
||||||
|
|
||||||
|
### Launch Logic
|
||||||
|
To work around Google Apps Script limitations (triggers cannot open modals):
|
||||||
|
1. **Watcher Sidebar**: A lightweight sidebar remains open, polling for selection changes.
|
||||||
|
2. **Context Action**: When a user selects a cell in **Column A** (Product Image), the sidebar presents a large **"Edit Media"** button.
|
||||||
|
3. **Modal**: Clicking the button launches the full Media Manager Modal.
|
||||||
|
|
||||||
|
### Interface Features
|
||||||
|
- **Grid Layout**: Drag-and-drop sortable grid.
|
||||||
|
- **Badges**:
|
||||||
|
- ☁️ **Drive Only**: New uploads or files not yet synced.
|
||||||
|
- 🛍️ **Shopify Only**: Media found on Shopify but missing from Drive (will be backfilled).
|
||||||
|
- ✅ **Synced**: Verifiable link between Drive and Shopify.
|
||||||
|
- **Video Support**:
|
||||||
|
- Grid: Videos play silently loop (`muted autoplay`).
|
||||||
|
- Preview: Full modal with controls.
|
||||||
|
- **Details Mode**: A togglable text view listing pending operations (e.g., "Deleting 2 files, Reordering 3...").
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Data Architecture
|
||||||
|
|
||||||
|
### 1. Naming Convention
|
||||||
|
Files in Drive function as the source of truth for order.
|
||||||
|
- **Pattern**: `[SKU]_[Index].[Extension]`
|
||||||
|
- **Example**: `TSHIRT-001_0001.jpg`, `TSHIRT-001_0002.mp4`
|
||||||
|
- **Padding**: 4 digits to support >10 items cleanly.
|
||||||
|
|
||||||
|
### 2. Session Recovery (Draft State)
|
||||||
|
To prevent data loss during browser refreshes or crashes, the edit state is persisted immediately to `UserProperties`.
|
||||||
|
|
||||||
|
- **Storage**: `PropertiesService.getUserProperties()`
|
||||||
|
- **Key**: `MEDIA_SESSION_[SKU]`
|
||||||
|
- **Schema**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"timestamp": 1234567890,
|
||||||
|
"sku": "SKU-123",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "drive_file_id_or_shopify_id",
|
||||||
|
"source": "drive|shopify|new",
|
||||||
|
"filename": "original_name.jpg",
|
||||||
|
"status": "active|deleted|staged",
|
||||||
|
"thumbnail": "data:image..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Synchronization Logic (Two-Way Reconcile)
|
||||||
|
|
||||||
|
#### Phase A: Load & Match (Read-Only)
|
||||||
|
Executed when opening the manager.
|
||||||
|
1. **Fetch**: Get all Drive Files in SKU folder and all Shopify Media via GraphQL.
|
||||||
|
2. **Match**:
|
||||||
|
- **Strong Verification**: `Drive.appProperties.shopify_media_id === Shopify.media.id`
|
||||||
|
- **Legacy Fallback**: `Drive.name === Shopify.filename` (Only if no ID match)
|
||||||
|
3. **Conflict Resolution**: If duplicates found, prefer high-res/latest.
|
||||||
|
|
||||||
|
#### Phase B: Save (Transactional)
|
||||||
|
Executed when user clicks "Save".
|
||||||
|
1. **Delete**: Process items marked `deleted` (Remove from Shopify & Trash in Drive).
|
||||||
|
2. **Backfill**: Download "Shopify Only" items to Drive -> Set `appProperties`.
|
||||||
|
3. **Upload**: Upload "Drive Only" items -> Create Media -> Set `appProperties`.
|
||||||
|
4. **Reorder**: Execute `productReorderMedia` GraphQL mutation with final ID list.
|
||||||
|
5. **Finalize**:
|
||||||
|
- Rename all Drive files to `SKU_{index}` sequence.
|
||||||
|
- Clear `MEDIA_SESSION_[SKU]` property.
|
||||||
|
|
||||||
|
## Technical Components
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **HTML/CSS**: Glassmorphism aesthetic (Inter font, backdrop-filter).
|
||||||
|
- **JS**: Vanilla JS with HTML5 Drag & Drop API.
|
||||||
|
|
||||||
|
### Backend Services
|
||||||
|
- **`MediaService`**: Orchestrates the Phase A/B logic.
|
||||||
|
- **`ShopifyMediaService`**: Handles GraphQL mutations (`productCreateMedia`, `productReorderMedia`).
|
||||||
|
- **`GASDriveService`**: Manages File renaming and `appProperties` metadata.
|
||||||
|
|
||||||
|
## Future Proofing
|
||||||
|
- **Metadata**: We avoid relying on file hashes/sizes due to Shopify's aggressive image compression. We rely strictly on stored IDs (`appProperties`) where possible.
|
||||||
|
- **Scale**: Pagination may be needed if SKUs usually exceed 50 images (current limit 250 in GraphQL).
|
||||||
|
|
||||||
|
## Development Roadmap
|
||||||
|
- [ ] **Backend Implementation**
|
||||||
|
- [ ] Update `getMediaForSku` to return combined state (Drive + Shopify + Session)
|
||||||
|
- [ ] Implement `saveMediaChanges(sku, changes)` transaction logic
|
||||||
|
- [ ] Renaming files (`SKU_####.ext`)
|
||||||
|
- [ ] Deleting/Trashing files
|
||||||
|
- [ ] Uploading/Backfilling
|
||||||
|
- [ ] Implement Session Recovery (Read/Write `UserProperties`)
|
||||||
|
- [ ] **Frontend Implementation**
|
||||||
|
- [ ] **Watcher Sidebar**: Create `MediaSidebar.html` to poll for selection.
|
||||||
|
- [ ] **Manager Modal**: Refactor `MediaManager.html`.
|
||||||
|
- [ ] State Management (Staging)
|
||||||
|
- [ ] Drag-and-Drop Grid
|
||||||
|
- [ ] Preview Modal (Image + Video)
|
||||||
|
- [ ] "Details..." View
|
||||||
|
- [ ] **Verification**
|
||||||
|
- [ ] Manual Test: Drag & Drop ordering
|
||||||
|
- [ ] Manual Test: Save & Sync
|
||||||
|
- [ ] Manual Test: Session Recovery (Reload browser mid-edit)
|
||||||
BIN
docs/images/media_manager_mockup.png
Normal file
BIN
docs/images/media_manager_mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 536 KiB |
@ -3,4 +3,8 @@ export interface IDriveService {
|
|||||||
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File
|
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File
|
||||||
getFiles(folderId: string): GoogleAppsScript.Drive.File[]
|
getFiles(folderId: string): GoogleAppsScript.Drive.File[]
|
||||||
getFileById(id: string): GoogleAppsScript.Drive.File
|
getFileById(id: string): GoogleAppsScript.Drive.File
|
||||||
|
renameFile(fileId: string, newName: string): void
|
||||||
|
trashFile(fileId: string): void
|
||||||
|
updateFileProperties(fileId: string, properties: {[key: string]: string}): void
|
||||||
|
createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
export interface IShopifyMediaService {
|
export interface IShopifyMediaService {
|
||||||
stagedUploadsCreate(input: any[]): any
|
stagedUploadsCreate(input: any[]): any
|
||||||
productCreateMedia(productId: string, media: any[]): any
|
productCreateMedia(productId: string, media: any[]): any
|
||||||
|
getProductMedia(productId: string): any[]
|
||||||
|
productDeleteMedia(productId: string, mediaId: string): any
|
||||||
|
productReorderMedia(productId: string, moves: any[]): any
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedSku, getPickerConfig, saveFileToDrive, syncMediaForSku, debugScopes } from "./mediaHandlers"
|
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedSku, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { GASDriveService } from "./services/GASDriveService"
|
import { GASDriveService } from "./services/GASDriveService"
|
||||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||||
@ -23,7 +23,7 @@ jest.mock("./config", () => {
|
|||||||
jest.mock("./services/GASNetworkService")
|
jest.mock("./services/GASNetworkService")
|
||||||
jest.mock("./services/ShopifyMediaService")
|
jest.mock("./services/ShopifyMediaService")
|
||||||
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
|
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
|
||||||
jest.mock("./services/MediaService", () => ({ MediaService: jest.fn().mockReturnValue({ syncMediaForSku: jest.fn() }) }))
|
jest.mock("./services/MediaService", () => ({ MediaService: jest.fn().mockReturnValue({ getUnifiedMediaState: jest.fn(), processMediaChanges: jest.fn() }) }))
|
||||||
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) }))
|
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) }))
|
||||||
|
|
||||||
|
|
||||||
@ -220,35 +220,41 @@ describe("mediaHandlers", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("getMediaForSku", () => {
|
describe("getMediaForSku", () => {
|
||||||
test("should return mapped files", () => {
|
test("should delegate to MediaService.getUnifiedMediaState", () => {
|
||||||
mockGetFiles.mockReturnValue([mockFile])
|
const { MediaService } = require("./services/MediaService")
|
||||||
const result = getMediaForSku("SKU123")
|
// We need to ensure new instance is used
|
||||||
expect(result).toHaveLength(1)
|
const mockState = [{ id: "1", filename: "foo.jpg" }]
|
||||||
expect(result[0].id).toBe("new_file_id")
|
|
||||||
expect(result[0].thumbnailLink).toContain("data:image/png;base64,encoded_thumb")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should handle thumbnail error", () => {
|
// Execute
|
||||||
const badFile = {
|
getMediaForSku("SKU123")
|
||||||
getId: () => "bad_id",
|
|
||||||
getName: () => "bad.jpg",
|
|
||||||
getThumbnail: jest.fn().mockImplementation(() => { throw new Error("Thumb error") }),
|
|
||||||
getMimeType: () => "image/jpeg"
|
|
||||||
}
|
|
||||||
mockGetFiles.mockReturnValue([badFile])
|
|
||||||
|
|
||||||
const result = getMediaForSku("SKU123")
|
// Get the instance that was created
|
||||||
expect(result).toHaveLength(1)
|
const mockInstance = MediaService.mock.instances[MediaService.mock.instances.length - 1]
|
||||||
expect(result[0].thumbnailLink).toBe("")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return empty array on fatal error", () => {
|
// Checking delegation
|
||||||
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Fatal config") })
|
expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", "123")
|
||||||
const result = getMediaForSku("SKU123")
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("saveMediaChanges", () => {
|
||||||
|
test("should delegate to MediaService.processMediaChanges", () => {
|
||||||
|
const { MediaService } = require("./services/MediaService")
|
||||||
|
const finalState = [{ id: "1" }]
|
||||||
|
|
||||||
|
saveMediaChanges("SKU123", finalState)
|
||||||
|
|
||||||
|
const mockInstance = MediaService.mock.instances[MediaService.mock.instances.length - 1]
|
||||||
|
expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, "123")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should throw if product not synced", () => {
|
||||||
|
const { Product } = require("./Product")
|
||||||
|
Product.mockImplementationOnce(() => ({ shopify_id: null }))
|
||||||
|
|
||||||
|
expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("Photo Session API", () => {
|
describe("Photo Session API", () => {
|
||||||
const mockSessionId = "sess_123"
|
const mockSessionId = "sess_123"
|
||||||
const mockPickerUri = "https://photos.google.com/picker"
|
const mockPickerUri = "https://photos.google.com/picker"
|
||||||
@ -352,34 +358,6 @@ describe("mediaHandlers", () => {
|
|||||||
expect(mockGetOrCreateFolder).toHaveBeenCalled()
|
expect(mockGetOrCreateFolder).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("syncMediaForSku should trigger media service sync", () => {
|
|
||||||
syncMediaForSku("SKU123")
|
|
||||||
// Expect MediaService to be called
|
|
||||||
// how to access mock?
|
|
||||||
const { MediaService } = require("./services/MediaService")
|
|
||||||
const mockInstance = MediaService.mock.results[0].value
|
|
||||||
expect(mockInstance.syncMediaForSku).toHaveBeenCalledWith("SKU123", "123")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("syncMediaForSku should try to match product if id missing", () => {
|
|
||||||
// Override Product mock for this test
|
|
||||||
const { Product } = require("./Product")
|
|
||||||
const mockMatch = jest.fn()
|
|
||||||
Product.mockImplementationOnce(() => ({
|
|
||||||
shopify_id: null,
|
|
||||||
MatchToShopifyProduct: mockMatch
|
|
||||||
}))
|
|
||||||
|
|
||||||
// It will throw "Product not found" because we didn't update the ID (unless we simulate side effect)
|
|
||||||
// But we can check if MatchToShopifyProduct was called
|
|
||||||
try {
|
|
||||||
syncMediaForSku("SKU_NEW")
|
|
||||||
} catch (e) {
|
|
||||||
// Expected because shopify_id is still null
|
|
||||||
}
|
|
||||||
expect(mockMatch).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("debugScopes should log token", () => {
|
test("debugScopes should log token", () => {
|
||||||
debugScopes()
|
debugScopes()
|
||||||
expect(ScriptApp.getOAuthToken).toHaveBeenCalled()
|
expect(ScriptApp.getOAuthToken).toHaveBeenCalled()
|
||||||
|
|||||||
@ -38,45 +38,45 @@ export function getPickerConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getMediaForSku(sku: string): any[] {
|
export function getMediaForSku(sku: string): any[] {
|
||||||
|
const config = new Config()
|
||||||
const driveService = new GASDriveService()
|
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)
|
||||||
|
|
||||||
try {
|
// Resolve Product ID (Best Effort)
|
||||||
const config = new Config() // Moved inside try block to catch init errors
|
const product = new Product(sku)
|
||||||
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
const shopifyId = product.shopify_id || ""
|
||||||
const files = driveService.getFiles(folder.getId())
|
|
||||||
|
|
||||||
return files.map(f => {
|
return mediaService.getUnifiedMediaState(sku, shopifyId)
|
||||||
let thumb = ""
|
}
|
||||||
try {
|
|
||||||
const bytes = f.getThumbnail().getBytes()
|
export function saveMediaChanges(sku: string, finalState: any[]) {
|
||||||
thumb = "data:image/png;base64," + Utilities.base64Encode(bytes)
|
const config = new Config()
|
||||||
} catch (e) {
|
const driveService = new GASDriveService()
|
||||||
console.log(`Failed to get thumbnail for ${f.getName()}`)
|
const shop = new Shop()
|
||||||
// Fallback or empty
|
const shopifyMediaService = new ShopifyMediaService(shop)
|
||||||
}
|
const networkService = new GASNetworkService()
|
||||||
return {
|
const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config)
|
||||||
id: f.getId(),
|
|
||||||
name: f.getName(),
|
const product = new Product(sku)
|
||||||
thumbnailLink: thumb
|
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?
|
||||||
} catch (e) {
|
// For now, fail safe.
|
||||||
console.error(e)
|
throw new Error("Product must be synced to Shopify before saving media changes.")
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mediaService.processMediaChanges(sku, finalState, product.shopify_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
||||||
const config = new Config()
|
const config = new Config()
|
||||||
const driveService = new GASDriveService()
|
const driveService = new GASDriveService()
|
||||||
|
|
||||||
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||||
const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, filename)
|
const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, filename)
|
||||||
|
|
||||||
driveService.saveFile(blob, folder.getId())
|
driveService.saveFile(blob, folder.getId())
|
||||||
|
|
||||||
// Auto-sync after upload?
|
|
||||||
// syncMediaForSku(sku) // Optional: auto-sync
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picker Callback specific handler if needed, or we just rely on frontend passing back file ID
|
// Picker Callback specific handler if needed, or we just rely on frontend passing back file ID
|
||||||
@ -205,31 +205,7 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncMediaForSku(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)
|
|
||||||
|
|
||||||
// Need Shopify Product ID
|
|
||||||
// We can get it from the Product class or Sheet
|
|
||||||
const product = new Product(sku)
|
|
||||||
if (!product.shopify_id) {
|
|
||||||
product.MatchToShopifyProduct(shop)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!product.shopify_id) {
|
|
||||||
throw new Error("Product not found on Shopify. Please sync product first.")
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaService.syncMediaForSku(sku, product.shopify_id)
|
|
||||||
|
|
||||||
// Update thumbnail in sheet
|
|
||||||
// TODO: Implement thumbnail update in sheet if desired
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugScopes() {
|
export function debugScopes() {
|
||||||
const token = ScriptApp.getOAuthToken();
|
const token = ScriptApp.getOAuthToken();
|
||||||
|
|||||||
231
src/mediaManager.integration.test.ts
Normal file
231
src/mediaManager.integration.test.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
|
||||||
|
import { MediaService } from "./services/MediaService"
|
||||||
|
// Unmock MediaService so we test the real class logic
|
||||||
|
jest.unmock("./services/MediaService")
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockDrive = {
|
||||||
|
getOrCreateFolder: jest.fn(),
|
||||||
|
getFiles: jest.fn(),
|
||||||
|
createFile: jest.fn(),
|
||||||
|
renameFile: jest.fn(),
|
||||||
|
trashFile: jest.fn(),
|
||||||
|
updateFileProperties: jest.fn(),
|
||||||
|
getFileById: jest.fn()
|
||||||
|
}
|
||||||
|
const mockShopify = {
|
||||||
|
getProductMedia: jest.fn(),
|
||||||
|
productCreateMedia: jest.fn(),
|
||||||
|
productDeleteMedia: jest.fn(),
|
||||||
|
productReorderMedia: jest.fn(),
|
||||||
|
stagedUploadsCreate: jest.fn()
|
||||||
|
}
|
||||||
|
const mockNetwork = { fetch: jest.fn() }
|
||||||
|
const mockConfig = { productPhotosFolderId: "root_folder" }
|
||||||
|
|
||||||
|
// Mock Utilities
|
||||||
|
global.Utilities = {
|
||||||
|
base64Encode: jest.fn().mockReturnValue("base64encoded"),
|
||||||
|
newBlob: jest.fn()
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// Mock Advanced Drive Service
|
||||||
|
global.Drive = {
|
||||||
|
Files: {
|
||||||
|
get: jest.fn().mockImplementation((id) => {
|
||||||
|
if (id === "drive_1") return { appProperties: { shopify_media_id: "gid://shopify/Media/100" } }
|
||||||
|
return { appProperties: {} }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} as any
|
||||||
|
|
||||||
|
describe("MediaService V2 Integration Logic", () => {
|
||||||
|
let service: MediaService
|
||||||
|
const dummyPid = "gid://shopify/Product/123"
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
// Instantiate the REAL service with MOCKED delegates
|
||||||
|
service = new MediaService(mockDrive as any, mockShopify as any, mockNetwork as any, mockConfig as any)
|
||||||
|
|
||||||
|
// Setup Network mock for Blob download
|
||||||
|
// MediaService calls networkService.fetch(...).getBlob()
|
||||||
|
// so fetch matches MUST return an object with getBlob
|
||||||
|
mockNetwork.fetch.mockReturnValue({
|
||||||
|
getBlob: jest.fn().mockReturnValue({
|
||||||
|
getDataAsString: () => "fake_blob_data",
|
||||||
|
getContentType: () => "image/jpeg",
|
||||||
|
getBytes: () => []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setup default File mock behaviors
|
||||||
|
mockDrive.getFileById.mockImplementation((id: string) => ({
|
||||||
|
setName: jest.fn(),
|
||||||
|
getName: () => "file_name.jpg",
|
||||||
|
moveTo: jest.fn(),
|
||||||
|
getMimeType: () => "image/jpeg",
|
||||||
|
getBlob: () => ({})
|
||||||
|
}))
|
||||||
|
|
||||||
|
mockDrive.createFile.mockReturnValue({
|
||||||
|
getId: () => "new_created_file_id"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getUnifiedMediaState (Phase A)", () => {
|
||||||
|
test("should match Drive and Shopify items by ID (Strong Link)", () => {
|
||||||
|
// Setup Drive
|
||||||
|
const driveFile = {
|
||||||
|
getId: () => "drive_1",
|
||||||
|
getName: () => "IMG_001.jpg",
|
||||||
|
getAppProperty: (k: string) => k === 'shopify_media_id' ? "gid://shopify/Media/100" : null,
|
||||||
|
getThumbnail: () => ({ getBytes: () => [] })
|
||||||
|
}
|
||||||
|
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" })
|
||||||
|
mockDrive.getFiles.mockReturnValue([driveFile])
|
||||||
|
|
||||||
|
// Setup Shopify
|
||||||
|
const shopMedia = {
|
||||||
|
id: "gid://shopify/Media/100",
|
||||||
|
mediaContentType: "IMAGE",
|
||||||
|
preview: { image: { originalSrc: "http://shopify.com/img.jpg" } }
|
||||||
|
}
|
||||||
|
mockShopify.getProductMedia.mockReturnValue([shopMedia])
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = service.getUnifiedMediaState("SKU-123", dummyPid)
|
||||||
|
|
||||||
|
// Expect
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].driveId).toBe("drive_1")
|
||||||
|
expect(result[0].shopifyId).toBe("gid://shopify/Media/100")
|
||||||
|
expect(result[0].source).toBe("synced")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should identify Drive-Only items (New Uploads)", () => {
|
||||||
|
const driveFile = {
|
||||||
|
getId: () => "drive_new",
|
||||||
|
getName: () => "new.jpg",
|
||||||
|
getAppProperty: () => null,
|
||||||
|
getThumbnail: () => ({ getBytes: () => [] })
|
||||||
|
}
|
||||||
|
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" })
|
||||||
|
mockDrive.getFiles.mockReturnValue([driveFile])
|
||||||
|
mockShopify.getProductMedia.mockReturnValue([])
|
||||||
|
|
||||||
|
const result = service.getUnifiedMediaState("SKU-123", dummyPid)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].source).toBe("drive_only")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should identify Shopify-Only items", () => {
|
||||||
|
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" })
|
||||||
|
mockDrive.getFiles.mockReturnValue([])
|
||||||
|
|
||||||
|
const shopMedia = {
|
||||||
|
id: "gid://shopify/Media/555",
|
||||||
|
mediaContentType: "IMAGE",
|
||||||
|
preview: { image: { originalSrc: "url" } }
|
||||||
|
}
|
||||||
|
mockShopify.getProductMedia.mockReturnValue([shopMedia])
|
||||||
|
|
||||||
|
const result = service.getUnifiedMediaState("SKU-123", dummyPid)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].source).toBe("shopify_only")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("processMediaChanges (Phase B)", () => {
|
||||||
|
test("should rename Drive files sequentially", () => {
|
||||||
|
const finalState = [
|
||||||
|
{ id: "1", driveId: "d1", shopifyId: "s1", source: "synced", filename: "foo.jpg" },
|
||||||
|
{ id: "2", driveId: "d2", shopifyId: "s2", source: "synced", filename: "bar.jpg" }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Mock getUnifiedMediaState to return empty to skip delete logic interference?
|
||||||
|
// Or return something consistent.
|
||||||
|
jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([])
|
||||||
|
|
||||||
|
// Act
|
||||||
|
service.processMediaChanges("SKU-123", finalState, dummyPid)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockDrive.renameFile).toHaveBeenCalledWith("d1", "SKU-123_0001.jpg")
|
||||||
|
expect(mockDrive.renameFile).toHaveBeenCalledWith("d2", "SKU-123_0002.jpg")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should call Shopify Reorder Mutation", () => {
|
||||||
|
const finalState = [
|
||||||
|
{ id: "1", shopifyId: "s10", sortOrder: 0 },
|
||||||
|
{ id: "2", shopifyId: "s20", sortOrder: 1 }
|
||||||
|
]
|
||||||
|
jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([])
|
||||||
|
|
||||||
|
service.processMediaChanges("SKU-123", finalState, dummyPid)
|
||||||
|
|
||||||
|
expect(mockShopify.productReorderMedia).toHaveBeenCalledWith(dummyPid, [
|
||||||
|
{ id: "s10", newPosition: "0" },
|
||||||
|
{ id: "s20", newPosition: "1" }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should backfill Shopify-Only items to Drive", () => {
|
||||||
|
const finalState = [
|
||||||
|
{ id: "3", driveId: null, shopifyId: "s99", source: "shopify_only", thumbnail: "http://url.jpg", filename: "backfill.jpg" }
|
||||||
|
]
|
||||||
|
jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([])
|
||||||
|
|
||||||
|
// Mock file creation
|
||||||
|
// We set default mockDrive.createFile above but we can specialize if needed
|
||||||
|
// Default returns "new_created_file_id"
|
||||||
|
|
||||||
|
// Act
|
||||||
|
service.processMediaChanges("SKU-123", finalState, dummyPid)
|
||||||
|
|
||||||
|
expect(mockDrive.createFile).toHaveBeenCalled()
|
||||||
|
expect(mockDrive.updateFileProperties).toHaveBeenCalledWith("new_created_file_id", { shopify_media_id: "s99" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should delete removed items", () => {
|
||||||
|
// Mock current state has items
|
||||||
|
const current = [
|
||||||
|
{ id: "del_1", driveId: "d_del", shopifyId: "s_del", filename: "delete_me.jpg" }
|
||||||
|
]
|
||||||
|
jest.spyOn(service, "getUnifiedMediaState").mockReturnValue(current)
|
||||||
|
|
||||||
|
// Final state empty
|
||||||
|
const finalState: any[] = []
|
||||||
|
|
||||||
|
service.processMediaChanges("SKU-123", finalState, dummyPid)
|
||||||
|
|
||||||
|
expect(mockShopify.productDeleteMedia).toHaveBeenCalledWith(dummyPid, "s_del")
|
||||||
|
expect(mockDrive.trashFile).toHaveBeenCalledWith("d_del")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should upload Drive-Only items", () => {
|
||||||
|
const finalState = [
|
||||||
|
{ id: "new_1", driveId: "d_new", shopifyId: null, source: "drive_only", filename: "new.jpg" }
|
||||||
|
]
|
||||||
|
jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([])
|
||||||
|
|
||||||
|
// Mock staged uploads flow
|
||||||
|
mockShopify.stagedUploadsCreate.mockReturnValue({
|
||||||
|
stagedTargets: [{ url: "http://upload", resourceUrl: "http://resource", parameters: [] }]
|
||||||
|
})
|
||||||
|
// Mock Create Media returning ID
|
||||||
|
mockShopify.productCreateMedia.mockReturnValue({
|
||||||
|
media: [{ id: "new_shopify_id", status: "READY" }]
|
||||||
|
})
|
||||||
|
|
||||||
|
service.processMediaChanges("SKU-123", finalState, dummyPid)
|
||||||
|
|
||||||
|
expect(mockShopify.stagedUploadsCreate).toHaveBeenCalled()
|
||||||
|
expect(mockShopify.productCreateMedia).toHaveBeenCalled()
|
||||||
|
// Check property update
|
||||||
|
expect(mockDrive.updateFileProperties).toHaveBeenCalledWith("d_new", { shopify_media_id: "new_shopify_id" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -29,4 +29,53 @@ export class GASDriveService implements IDriveService {
|
|||||||
getFileById(id: string): GoogleAppsScript.Drive.File {
|
getFileById(id: string): GoogleAppsScript.Drive.File {
|
||||||
return DriveApp.getFileById(id)
|
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 { MediaService } from "./MediaService"
|
||||||
import { MockDriveService } from "./MockDriveService"
|
import { MockDriveService } from "./MockDriveService"
|
||||||
import { MockShopifyMediaService } from "./MockShopifyMediaService"
|
import { MockShopifyMediaService } from "./MockShopifyMediaService"
|
||||||
@ -12,8 +13,9 @@ class MockNetworkService implements INetworkService {
|
|||||||
this.lastUrl = url
|
this.lastUrl = url
|
||||||
this.lastPayload = params.payload
|
this.lastPayload = params.payload
|
||||||
return {
|
return {
|
||||||
getResponseCode: () => 200
|
getResponseCode: () => 200,
|
||||||
} as GoogleAppsScript.URL_Fetch.HTTPResponse
|
getBlob: () => ({ getBytes: () => [], getContentType: () => "image/jpeg", setName: () => {} })
|
||||||
|
} as unknown as GoogleAppsScript.URL_Fetch.HTTPResponse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,28 +30,89 @@ describe("MediaService", () => {
|
|||||||
driveService = new MockDriveService()
|
driveService = new MockDriveService()
|
||||||
shopifyService = new MockShopifyMediaService()
|
shopifyService = new MockShopifyMediaService()
|
||||||
networkService = new MockNetworkService()
|
networkService = new MockNetworkService()
|
||||||
config = { productPhotosFolderId: "root" } as Config // Mock config
|
config = { productPhotosFolderId: "root" } as Config
|
||||||
|
|
||||||
mediaService = new MediaService(driveService, shopifyService, networkService, 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", () => {
|
test("getUnifiedMediaState should match files", () => {
|
||||||
// Setup Drive State
|
|
||||||
const folder = driveService.getOrCreateFolder("SKU123", "root")
|
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())
|
driveService.saveFile(blob1, folder.getId())
|
||||||
|
|
||||||
// Run Sync
|
const state = mediaService.getUnifiedMediaState("SKU123", "pid")
|
||||||
mediaService.syncMediaForSku("SKU123", "shopify_prod_id")
|
expect(state).toHaveLength(1)
|
||||||
|
expect(state[0].filename).toBe("01.jpg")
|
||||||
// Verify Network Call (Upload)
|
|
||||||
expect(networkService.lastUrl).toBe("https://mock-upload.shopify.com")
|
|
||||||
// Verify payload contained file
|
|
||||||
expect(networkService.lastPayload).toHaveProperty("file")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("syncMediaForSku does nothing if no files", () => {
|
test("processMediaChanges should handle deletions", () => {
|
||||||
mediaService.syncMediaForSku("SKU_EMPTY", "pid")
|
const folder = driveService.getOrCreateFolder("SKU123", "root")
|
||||||
expect(networkService.lastUrl).toBe("")
|
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
|
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 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) {
|
// 2. Get Shopify Media
|
||||||
console.log("No files found in Drive.")
|
let shopifyMedia: any[] = []
|
||||||
return
|
if (shopifyProductId) {
|
||||||
}
|
shopifyMedia = this.shopifyMediaService.getProductMedia(shopifyProductId)
|
||||||
console.log(`Found ${files.length} files in Drive folder ${folder.getId()}`)
|
|
||||||
|
|
||||||
// Sort files by name to ensure consistent order (01.jpg, 02.jpg)
|
|
||||||
files.sort((a, b) => a.getName().localeCompare(b.getName()))
|
|
||||||
|
|
||||||
// TODO: optimization - check if file already exists on Shopify by filename/size/hash
|
|
||||||
// For now, we will just upload everything that is new, or we rely on Shopify to dedupe?
|
|
||||||
// Shopify does NOT dedupe automatically if we create new media entries.
|
|
||||||
// We should probably list current media on the product and compare filenames.
|
|
||||||
// But filenames in Shopify are sanitized.
|
|
||||||
// Pro trick: Use 'alt' text to store the original filename/Drive ID.
|
|
||||||
|
|
||||||
// 2. Prepare Staged Uploads
|
|
||||||
// collecting files needing upload
|
|
||||||
const filesToUpload = files; // uploading all for MVP simplicity, assume clean state or overwrite logic later
|
|
||||||
|
|
||||||
if (filesToUpload.length === 0) return
|
|
||||||
|
|
||||||
const stagedUploadInput = filesToUpload.map(f => ({
|
|
||||||
filename: f.getName(),
|
|
||||||
mimeType: f.getMimeType(),
|
|
||||||
resource: "IMAGE", // or VIDEO
|
|
||||||
httpMethod: "POST"
|
|
||||||
}))
|
|
||||||
|
|
||||||
const response = this.shopifyMediaService.stagedUploadsCreate(stagedUploadInput)
|
|
||||||
|
|
||||||
if (response.userErrors && response.userErrors.length > 0) {
|
|
||||||
console.error("Staged upload errors:", response.userErrors)
|
|
||||||
throw new Error("Staged upload failed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stagedTargets = response.stagedTargets
|
// 3. Match
|
||||||
|
const unifiedState: any[] = []
|
||||||
|
const matchedShopifyIds = new Set<string>()
|
||||||
|
|
||||||
if (!stagedTargets || stagedTargets.length !== filesToUpload.length) {
|
// Map of Drive Files
|
||||||
throw new Error("Failed to create staged upload targets")
|
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 = []
|
// 5. Process Reordering
|
||||||
|
const moves: any[] = []
|
||||||
// 3. Upload files to Targets
|
finalState.forEach((item, index) => {
|
||||||
for (let i = 0; i < filesToUpload.length; i++) {
|
if (item.shopifyId) {
|
||||||
const file = filesToUpload[i]
|
moves.push({ id: item.shopifyId, newPosition: index.toString() })
|
||||||
const target = stagedTargets[i]
|
}
|
||||||
|
})
|
||||||
console.log(`Uploading ${file.getName()} to ${target.url}`)
|
if (moves.length > 0) {
|
||||||
|
this.shopifyMediaService.productReorderMedia(shopifyProductId, moves)
|
||||||
const payload = {}
|
|
||||||
target.parameters.forEach(p => payload[p.name] = p.value)
|
|
||||||
payload['file'] = file.getBlob()
|
|
||||||
|
|
||||||
this.networkService.fetch(target.url, {
|
|
||||||
method: "post",
|
|
||||||
payload: payload
|
|
||||||
})
|
|
||||||
|
|
||||||
mediaToCreate.push({
|
|
||||||
originalSource: target.resourceUrl,
|
|
||||||
alt: file.getName(), // Storing filename in Alt for basic deduping later
|
|
||||||
mediaContentType: "IMAGE" // TODO: Detect video
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Create Media on Shopify
|
// 6. Rename Drive Files
|
||||||
console.log("Creating media on Shopify...")
|
finalState.forEach((item, index) => {
|
||||||
const result = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate)
|
if (item.driveId) {
|
||||||
console.log("Media created successfully")
|
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(),
|
getName: () => blob.getName(),
|
||||||
getBlob: () => blob,
|
getBlob: () => blob,
|
||||||
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
|
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
|
||||||
getLastUpdated: () => new Date()
|
getLastUpdated: () => new Date(),
|
||||||
|
getThumbnail: () => ({ getBytes: () => [] })
|
||||||
} as unknown as GoogleAppsScript.Drive.File
|
} as unknown as GoogleAppsScript.Drive.File
|
||||||
|
|
||||||
if (!this.files.has(folderId)) {
|
if (!this.files.has(folderId)) {
|
||||||
@ -52,4 +53,26 @@ export class MockDriveService implements IDriveService {
|
|||||||
}
|
}
|
||||||
throw new Error("File not found in mock")
|
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 {
|
productCreateMedia(productId: string, media: any[]): any {
|
||||||
return {
|
return {
|
||||||
media: media.map(m => ({
|
media: media.map(m => ({
|
||||||
|
id: `gid://shopify/Media/${Math.random()}`,
|
||||||
alt: m.alt,
|
alt: m.alt,
|
||||||
mediaContentType: m.mediaContentType,
|
mediaContentType: m.mediaContentType,
|
||||||
status: "PROCESSING"
|
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!) {
|
mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) {
|
||||||
productCreateMedia(media: $media, productId: $productId) {
|
productCreateMedia(media: $media, productId: $productId) {
|
||||||
media {
|
media {
|
||||||
|
id
|
||||||
alt
|
alt
|
||||||
mediaContentType
|
mediaContentType
|
||||||
status
|
status
|
||||||
@ -68,4 +69,79 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
|||||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||||
return response.content.data.productCreateMedia
|
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