Compare commits

...

20 Commits

Author SHA1 Message Date
78bbf04824 Improve Media Manager transition after matching wizard
- Removed full-page 'Connecting...' loading screen after matching modal closes.
- Ensured main UI (SKU info and gallery shell) appears immediately after the wizard.
- Leveraged grid-level loading indicator for non-blocking background refresh.
- Verified transition stability with integration and handler tests.
2025-12-31 21:14:32 -07:00
63b2ff2fd0 Fix Media Manager action buttons, flickering, and card spacing
- Switched from index-based to unique ID-based deletion to fix incorrect target after reordering.
- Enhanced media state normalization in loadMedia to ensure robust link button () visibility.
- Implemented targeted UI updates via ui.updateCardState(id) to prevent thumbnail flickering on status changes.
- Standardized vertical card spacing to 8px and corrected Gallery card action bar overhang/overlap.
2025-12-31 10:52:15 -07:00
8b1da56820 feat: implement manual media matching in Media Manager
- Added selection logic to MediaState to track Drive-only and Shopify-only items
- Refined UI to include a link button () in media card action overlays
- Reordered action buttons to: Preview, Link, Delete
- Replaced JS confirm with a visual side-by-side matching modal for linking
- Added adaptive 'Link Selected' button to the gallery header
- Fixed TypeError by restoring the quick-links element
2025-12-31 10:29:45 -07:00
05d459d58f chore: remove temporary test output file 2025-12-31 09:52:52 -07:00
e39bc862cc feat(media): Optimize Media Manager loading performance
Significant performance improvements to the 'Loading media...' phase:
- Reduced client-server round trips by consolidating the initial handshake (diagnostics + media fetch) into a single backend call: getMediaManagerInitialState.
- Implemented batched Google Drive metadata retrieval in GASDriveService using the Advanced Drive API, eliminating per-file property fetching calls.
- Switched to HtmlService templates in showMediaManager to pass initial SKU/Title data directly, enabling the UI shell to appear instantly upon opening.
- Updated documentation (ARCHITECTURE.md, MEMORY.md) to clarify Webpack global assignment requirements for GAS functions.
- Verified with comprehensive updates to unit and integration tests.
2025-12-31 09:46:56 -07:00
fc25e877f1 Disable grid interactions during save operations
- Added .grid-disabled CSS class to prevent pointer events and provide visual feedback (grayscale/opacity) during save.
- Implemented UI.prototype.setSavingState to toggle grid interaction and disable SortableJS reordering.
- Integrated setSavingState into controller.saveChanges to block edits while saving is in progress.
- Added loading message updates ('Refreshing media...' and 'Loading media...') for better UX.
2025-12-31 09:11:57 -07:00
e0e5b76c8e Improve Media Manager loading state with parallel fetching and overlay
- Implemented simultaneous execution of getMediaDiagnostics and getMediaForSku in MediaManager.html to speed up initial load and refresh.
- Added a translucent grid-loading-overlay that appears over existing tiles during refresh, preventing interaction while maintaining context.
- Differentiated loading messages: 'Connecting to systems...' for initial load vs 'Refreshing media...' for updates.
- Fixed a syntax error in the save handler.
2025-12-31 09:05:38 -07:00
8487df3ea0 Optimize media manager polling and product info retrieval
- Remove recursive polling in MediaManager.html; context is now loaded once at startup.
- Optimize getSelectedProductInfo in mediaHandlers.ts to reduce SpreadsheetApp API calls.
- Update related tests to match new optimization.
2025-12-31 08:53:39 -07:00
ad67dd9ab5 Optimize Media Matching Workflow
- **Preload Thumbnails**: Implemented image preloading in the media matching wizard to ensure instant rendering of candidate matches.
- **Async Linking**: Refactored the linking confirmation to be asynchronous. The UI now optimistically advances to the next match immediately, performing the backend linking in the background.
- **Completion Gate**: Added a check to ensure all pending background linking operations verify completion before the wizard closes and reloading occurs.
- **Video Support**: Verified that filename-based matching correctly handles video files by extracting filenames from Shopify video URLs.
2025-12-31 08:43:04 -07:00
55a89a0802 Refine Photo Picker Session UI and Logic
- Unified transfer session UI layout with instructions at the top and footer controls.
- Implemented side-by-side 'Re-open Popup' and 'Cancel' buttons with proper state management (disabled/enabled).
- Added dynamic service context to instructions (e.g., 'Importing from Google Photos').
- Refactored UI class to handle new DOM structure and button logic.
- Updated controller to support new UI interactions and improved cancellation flow.
2025-12-31 08:18:51 -07:00
d34f9a1417 Fix Unexpected Keyword in MediaManager and Add Build Linting
- Fix corrupted line in src/MediaManager.html causing syntax error.
- Add ESLint integration to build process to prevent future syntax errors.
- Create .eslintrc.js with TypeScript and HTML support.
- Relax strict lint rules to accommodate existing codebase.
2025-12-31 07:02:16 -07:00
3abc57f45a Refactor Media Manager log to use streaming and card UI
- **UI Overhaul**: Moved the activity log to a dedicated, expandable card at the bottom of the Media Manager modal.
- **Styling**: Updated the log card to match the application's light theme using CSS variables (`--surface`, `--text`).
- **Log Streaming**: Replaced batch logging with real-time streaming via `CacheService` and `pollJobLogs`.
- **Session Resumption**: Implemented logic to resume log polling for active jobs upon page reload.
- **Fixes**:
    - Exposed `pollJobLogs` in `global.ts` to fix "Script function not found" error.
    - Updated `mediaHandlers.test.ts` with `CacheService` mocks and new signatures.
    - Removed legacy auto-hide/toggle logic for the log.
2025-12-31 06:08:34 -07:00
dc33390650 Refine media state handling and fix CellImageBuilder errors
- Update MediaService delegation tests in src/mediaHandlers.test.ts to use mock.results for more reliable instance retrieval.
- Fix CellImageBuilder failure in src/mediaHandlers.ts by using public Shopify thumbnail URLs for synced items and direct Drive thumbnail endpoints for non-synced items.
- Fallback to IMAGE() formula in the spreadsheet for Drive items to avoid authentication issues with native cell images.
- Add test_*.txt to .gitignore to keep the workspace clean.
- Ensure all tests pass with updated log expectations and mock data.
2025-12-31 04:21:46 -07:00
f25fb359e8 Fix Shopify video previews and various improvements
- Ensure Shopify video sync updates Media Manager with active video previews
- Fix "Image load failed" error for video icons by using Base64 SVG
- Resolve Drive picker origin error by using google.script.host.origin
- Fix Drive video playback issues by using Drive iframe player
- Add `test:log` script to package.json for full output logging in Windows
- Update .gitignore to exclude coverage, test_output.txt, and .agent/
- Remove test_output.txt from git tracking
2025-12-31 01:10:18 -07:00
64ab548593 Fix Shopify video preview propagation on save
Updates logic to detect processing state (including READY-but-no-sources race condition) and propagates contentUrl updates to the frontend immediately.
2025-12-31 01:08:12 -07:00
772957058d Merge branch 'thumbnails-fix' 2025-12-31 00:15:55 -07:00
ben
16dec5e888 revert ebc1a39ce3
revert feat: Implement Server-Side Chunked Transfer for Drive Uploads

- Implemented 'Client-Orchestrated, Server-Side Chunked Transfer' to bypass CORS and 50MB limits for Google Photos.
- Added 'getResumableUploadUrl' to GASDriveService for high-priority video processing.
- Refactored 'MediaManager.html' to orchestrate uploads using 'transferRemoteChunk' loop.
- Added 'getRemoteFileSize' and 'transferRemoteChunk' to 'mediaHandlers.ts'.
- Updated 'global.ts' to expose new backend functions.
2025-12-31 00:14:52 -07:00
ben
ec6602cbde revert f1ab3b7b84
revert feat: Add custom video thumbnails for Drive uploads

- Implemented custom thumbnail injection in GASDriveService.getResumableUploadUrl.
- Fetches thumbnails from Google Photos using w320 size to avoid API limits.
- Added strict < 2MB size check for thumbnails.
- Updated mediaHandlers and MediaManager to pass sourceUrl to the backend.
- This allows Drive to display a visual cue immediately for video files still processing.
2025-12-31 00:14:38 -07:00
f1ab3b7b84 feat: Add custom video thumbnails for Drive uploads
- Implemented custom thumbnail injection in GASDriveService.getResumableUploadUrl.
- Fetches thumbnails from Google Photos using w320 size to avoid API limits.
- Added strict < 2MB size check for thumbnails.
- Updated mediaHandlers and MediaManager to pass sourceUrl to the backend.
- This allows Drive to display a visual cue immediately for video files still processing.
2025-12-30 00:38:57 -07:00
ebc1a39ce3 feat: Implement Server-Side Chunked Transfer for Drive Uploads
- Implemented 'Client-Orchestrated, Server-Side Chunked Transfer' to bypass CORS and 50MB limits for Google Photos.
- Added 'getResumableUploadUrl' to GASDriveService for high-priority video processing.
- Refactored 'MediaManager.html' to orchestrate uploads using 'transferRemoteChunk' loop.
- Added 'getRemoteFileSize' and 'transferRemoteChunk' to 'mediaHandlers.ts'.
- Updated 'global.ts' to expose new backend functions.
2025-12-29 22:08:21 -07:00
19 changed files with 2859 additions and 349 deletions

57
.eslintrc.js Normal file
View File

@ -0,0 +1,57 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: [
"@typescript-eslint",
"html",
],
globals: {
"google": "readonly",
"Logger": "readonly",
"item": "writable",
"Utilities": "readonly",
"state": "writable",
"ui": "writable",
"controller": "writable",
"gapi": "readonly",
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off", // Too noisy for existing codebase
"no-unused-vars": "off",
"prefer-const": "off",
"no-var": "off",
"no-undef": "off",
"no-redeclare": "off",
"no-empty": "warn",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-var-requires": "off",
"no-useless-escape": "off",
"no-extra-semi": "off",
"no-array-constructor": "off",
"@typescript-eslint/no-array-constructor": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off"
},
overrides: [
{
files: ["*.html"],
parser: "espree", // Use default parser for HTML scripts if TS parser fails, or just rely on plugin handling
// Actually plugin-html handles it. But we usually need to specify not to use TS rules that require type info if we don't have full project info for snippets.
}
]
};

4
.gitignore vendored
View File

@ -3,4 +3,6 @@ dist/**
desktop.ini
.continue/**
.clasp.json
coverage/
coverage/
test_*.txt
.agent/

View File

@ -18,7 +18,7 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
## Key Technical Decisions
- **Queue System**: We implemented `onEditQueue.ts` to batch edits. This prevents hitting Shopify API rate limits and Google Apps Script execution limits during rapid manual edits.
- **Hybrid API**: We use REST for retrieving Orders (legacy/easier for flat data) and GraphQL for Products (more efficient/flexible).
- **Global Exports**: Functions in `src/global.ts` are explicitly exposed to be callable by Apps Script triggers.
- **Global Exports**: Functions in `src/global.ts` must be explicitly assigned to the `global` object (e.g., `(global as any).func = func`). This is required because Webpack bundles code into an IIFE, making top-level module functions unreachable from the frontend `google.script.run` or Apps Script triggers unless exposed this way.
## User Preferences
- **OS**: Windows.
@ -46,3 +46,6 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
- **Client-Side Syntax**:
- **ES5 ONLY**: Do not use `class` in client-side HTML files. The Apps Script sanitizer often fails to parse them. Use `function` constructors.
## Troubleshooting
- **Test Output**: When running tests, use `npm run test:log` to capture full output to `test_output.txt`. This avoids terminal truncation and allows agents to read the full results without manual redirection.

View File

@ -71,7 +71,15 @@ Configuration, including API keys, is stored in a dedicated Google Sheet named "
### 4. Global Entry Points (`src/global.ts`)
Since Apps Script functions must be top-level to be triggered or attached to buttons, `src/global.ts` explicitly exposes necessary functions from the modules to the global scope.
Because Webpack bundles the code into an IIFE (Immediately Invoked Function Expression) to avoid global scope pollution, top-level functions defined in modules are **not** automatically globally accessible in the Apps Script environment.
- **Requirement**: Any function that needs to be called from the frontend via `google.script.run`, triggered by a menu, or attached to a spreadsheet event must be explicitly assigned to the `global` object in `src/global.ts`.
- **Example**:
```typescript
import { myFunc } from "./myModule"
;(global as any).myFunc = myFunc
```
- **Rationale**: This is the only way for the Google Apps Script runtime to find these functions when they are invoked via the `google.script.run` API or other entry point mechanisms.
### 5. Status Automation (`src/statusHandlers.ts`)

1298
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,15 +6,21 @@
"global.ts"
],
"scripts": {
"build": "webpack --mode production",
"build": "npm run lint && webpack --mode production",
"lint": "eslint \"src/**/*.{ts,js,html}\"",
"deploy": "clasp push",
"test": "jest",
"test:log": "jest > test_output.txt 2>&1",
"prepare": "husky"
},
"devDependencies": {
"@types/google-apps-script": "^1.0.85",
"@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"copy-webpack-plugin": "^13.0.1",
"eslint": "^8.57.1",
"eslint-plugin-html": "^8.1.3",
"gas-webpack-plugin": "^2.6.0",
"graphql-tag": "^2.12.6",
"husky": "^9.1.7",

File diff suppressed because it is too large Load Diff

View File

@ -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, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia } from "./mediaHandlers"
import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia, pollJobLogs, getMediaManagerInitialState } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite"
// prettier-ignore
@ -65,3 +65,5 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).checkPhotoSession = checkPhotoSession
;(global as any).debugFolderAccess = debugFolderAccess
;(global as any).linkDriveFileToShopifyMedia = linkDriveFileToShopifyMedia
;(global as any).pollJobLogs = pollJobLogs
;(global as any).getMediaManagerInitialState = getMediaManagerInitialState

View File

@ -8,4 +8,5 @@ export interface IDriveService {
updateFileProperties(fileId: string, properties: {[key: string]: string}): void
createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File
getFileProperties(fileId: string): {[key: string]: string}
getFilesWithProperties(folderId: string): { file: GoogleAppsScript.Drive.File, properties: {[key: string]: string} }[]
}

View File

@ -1,5 +1,5 @@
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers"
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges, getMediaManagerInitialState } from "./mediaHandlers"
import { Config } from "./config"
import { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
@ -23,8 +23,17 @@ jest.mock("./config", () => {
jest.mock("./services/GASNetworkService")
jest.mock("./services/ShopifyMediaService")
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
jest.mock("./services/MediaService")
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) }))
jest.mock("./services/MediaService", () => {
return {
MediaService: jest.fn().mockImplementation(() => {
return {
getUnifiedMediaState: jest.fn().mockReturnValue([]),
processMediaChanges: jest.fn().mockReturnValue([]),
getInitialState: jest.fn().mockReturnValue({ diagnostics: {}, media: [] })
}
})
}
})
// Mock GASDriveService
@ -49,10 +58,30 @@ jest.mock("./services/GASSpreadsheetService", () => {
GASSpreadsheetService: jest.fn().mockImplementation(() => {
return {
getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => {
// console.log(`Mock GASSpreadsheetService getCellValueByColumnName called: ${col}`);
if (col === "sku") return "TEST-SKU"
if (col === "title") return "Test Product Title"
return null
})
}),
getRowNumberByColumnValue: jest.fn().mockReturnValue(5),
setCellValueByColumnName: jest.fn(),
getHeaders: jest.fn().mockReturnValue(["sku", "title", "thumbnail"]),
getRowData: jest.fn()
}
})
}
})
// Mock Product
jest.mock("./Product", () => {
return {
Product: jest.fn().mockImplementation((sku) => {
return {
sku: sku,
shopify_id: "shopify_id_123",
title: "Test Product Title",
MatchToShopifyProduct: jest.fn(),
ImportFromInventory: jest.fn()
}
})
}
@ -88,7 +117,13 @@ global.SpreadsheetApp = {
getName: jest.fn().mockReturnValue("product_inventory"),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 })
}),
getActive: jest.fn()
getActive: jest.fn(),
newCellImage: jest.fn().mockReturnValue({
setSourceUrl: jest.fn().mockReturnThis(),
setAltTextTitle: jest.fn().mockReturnThis(),
setAltTextDescription: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue("CELL_IMAGE_OBJECT")
})
} as any
// UrlFetchApp
@ -132,10 +167,32 @@ global.Session = {
global.HtmlService = {
createHtmlOutputFromFile: jest.fn().mockReturnValue({
setTitle: jest.fn().mockReturnThis(),
setWidth: jest.fn().mockReturnThis()
setWidth: jest.fn().mockReturnThis(),
setHeight: jest.fn().mockReturnThis()
}),
createTemplateFromFile: jest.fn().mockReturnValue({
evaluate: jest.fn().mockReturnValue({
setTitle: jest.fn().mockReturnThis(),
setWidth: jest.fn().mockReturnThis(),
setHeight: jest.fn().mockReturnThis()
})
})
} as any
// MimeType
global.MimeType = {
JPEG: "image/jpeg",
PNG: "image/png"
} as any
// Mock CacheService for log streaming
global.CacheService = {
getDocumentCache: () => ({
get: (key) => null,
put: (k, v, t) => {},
remove: (k) => {}
})
} as any
describe("mediaHandlers", () => {
beforeEach(() => {
@ -242,6 +299,33 @@ describe("mediaHandlers", () => {
})
})
describe("getMediaManagerInitialState", () => {
test("should consolidate diagnostics and media fetching", () => {
// Mock SpreadsheetApp behavior for SKU detection
const mockRange = { getValues: jest.fn().mockReturnValue([["sku", "title", "thumb"]]) };
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
getLastColumn: jest.fn().mockReturnValue(3),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 }),
getRange: jest.fn().mockReturnValue({
getValues: jest.fn()
.mockReturnValueOnce([["sku", "title", "thumbnail"]]) // Headers
.mockReturnValueOnce([["TEST-SKU", "Test Title", ""]]) // Row
})
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
const response = getMediaManagerInitialState()
expect(response.sku).toBe("TEST-SKU")
expect(response.title).toBe("Test Title")
const MockMediaService = MediaService as unknown as jest.Mock
const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
expect(mockInstance.getInitialState).toHaveBeenCalledWith("TEST-SKU", "shopify_id_123")
})
})
describe("getMediaForSku", () => {
test("should delegate to MediaService.getUnifiedMediaState", () => {
// Execute
@ -249,7 +333,8 @@ describe("mediaHandlers", () => {
// Get the instance that was created
const MockMediaService = MediaService as unknown as jest.Mock
const mockInstance = MockMediaService.mock.instances[MockMediaService.mock.instances.length - 1]
expect(MockMediaService).toHaveBeenCalled()
const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
// Checking delegation
expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", expect.anything())
@ -263,16 +348,55 @@ describe("mediaHandlers", () => {
saveMediaChanges("SKU123", finalState)
const MockMediaService = MediaService as unknown as jest.Mock
const mockInstance = MockMediaService.mock.instances[MockMediaService.mock.instances.length - 1]
expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything())
const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything(), null)
})
test("should throw if product not synced", () => {
const { Product } = require("./Product")
Product.mockImplementationOnce(() => ({ shopify_id: null, MatchToShopifyProduct: jest.fn() }))
const MockProduct = Product as unknown as jest.Mock
MockProduct.mockImplementationOnce(() => ({
shopify_id: null,
MatchToShopifyProduct: jest.fn(),
ImportFromInventory: jest.fn()
}))
expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced")
})
test("should update sheet thumbnail with first image", () => {
// Setup mock MediaService to NOT throw and just return logs
const MockMediaService = MediaService as unknown as jest.Mock
const mockGetUnifiedMediaState = jest.fn().mockReturnValue([
{ id: "2", driveId: "drive_file_2", galleryOrder: 1, contentUrl: "https://cdn.shopify.com/test.jpg", thumbnail: "https://cdn.shopify.com/test.jpg" }
])
MockMediaService.mockImplementation(() => ({
processMediaChanges: jest.fn().mockReturnValue(["Log 1"]),
getUnifiedMediaState: mockGetUnifiedMediaState
}))
const finalState = [
{ id: "1", driveId: "drive_file_1", galleryOrder: 10 },
{ id: "2", driveId: "drive_file_2", galleryOrder: 1 } // Should be first
]
const logs = saveMediaChanges("TEST-SKU", finalState)
// Logs are now just passed through from MediaService since we commented out local log appending
expect(logs).toEqual(["Log 1"])
// Verify spreadsheet service interaction
const MockSpreadsheet = GASSpreadsheetService as unknown as jest.Mock
expect(MockSpreadsheet).toHaveBeenCalled()
const mockSS = MockSpreadsheet.mock.results[MockSpreadsheet.mock.results.length - 1].value
expect(mockSS.setCellValueByColumnName).toHaveBeenCalledWith(
"product_inventory",
5,
"thumbnail",
"CELL_IMAGE_OBJECT"
)
})
})
describe("Photo Session API", () => {
@ -342,17 +466,35 @@ describe("mediaHandlers", () => {
const mockUi = { showModalDialog: jest.fn() }
;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi)
// Mock HTML output chain
// Mock getSelectedProductInfo specifically for the optimized implementation
const mockRange = { getValues: jest.fn() };
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
getLastColumn: jest.fn().mockReturnValue(2),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 }),
getRange: jest.fn().mockReturnValue(mockRange)
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
mockRange.getValues.mockReturnValueOnce([["sku", "title"]]);
mockRange.getValues.mockReturnValueOnce([["SKU-1", "Product-1"]]);
// Mock Template chain
const mockHtml = {
setTitle: jest.fn().mockReturnThis(),
setWidth: jest.fn().mockReturnThis(),
setHeight: jest.fn().mockReturnThis()
}
;(global.HtmlService.createHtmlOutputFromFile as jest.Mock).mockReturnValue(mockHtml)
const mockTemplate = {
evaluate: jest.fn().mockReturnValue(mockHtml),
initialSku: "",
initialTitle: ""
}
;(global.HtmlService.createTemplateFromFile as jest.Mock).mockReturnValue(mockTemplate)
showMediaManager()
expect(global.HtmlService.createHtmlOutputFromFile).toHaveBeenCalledWith("MediaManager")
expect(global.HtmlService.createTemplateFromFile).toHaveBeenCalledWith("MediaManager")
expect(mockTemplate.evaluate).toHaveBeenCalled()
expect(mockHtml.setTitle).toHaveBeenCalledWith("Media Manager")
expect(mockHtml.setWidth).toHaveBeenCalledWith(1100)
expect(mockHtml.setHeight).toHaveBeenCalledWith(750)
@ -360,6 +502,29 @@ describe("mediaHandlers", () => {
})
test("getSelectedProductInfo should return sku and title from sheet", () => {
// Mock SpreadsheetApp behavior specifically for the optimized implementation
// The implementation calls:
// 1. sheet.getRange(1, 1, 1, lastCol).getValues()[0] (headers)
// 2. sheet.getRange(row, 1, 1, lastCol).getValues()[0] (values)
const mockRange = {
getValues: jest.fn()
};
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
getLastColumn: jest.fn().mockReturnValue(3),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 5 }),
getRange: jest.fn().mockReturnValue(mockRange)
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
// First call: Headers
mockRange.getValues.mockReturnValueOnce([["sku", "title", "thumbnail"]]);
// Second call: Row Values
mockRange.getValues.mockReturnValueOnce([["TEST-SKU", "Test Product Title", "thumb.jpg"]]);
const info = getSelectedProductInfo()
expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" })
})

View File

@ -8,7 +8,14 @@ import { Config } from "./config"
import { Product } from "./Product"
export function showMediaManager() {
const html = HtmlService.createHtmlOutputFromFile("MediaManager")
const productInfo = getSelectedProductInfo();
const template = HtmlService.createTemplateFromFile("MediaManager");
// Pass variables to template
(template as any).initialSku = productInfo ? productInfo.sku : "";
(template as any).initialTitle = productInfo ? productInfo.title : "";
const html = template.evaluate()
.setTitle("Media Manager")
.setWidth(1100)
.setHeight(750);
@ -17,14 +24,34 @@ export function showMediaManager() {
export function getSelectedProductInfo(): { sku: string, title: string } | null {
const ss = new GASSpreadsheetService()
// Optimization: Direct usage to avoid multiple service calls overhead
// Use SpreadsheetApp only once if possible to get active context
const sheet = SpreadsheetApp.getActiveSheet()
if (sheet.getName() !== "product_inventory") return null
const row = sheet.getActiveRange().getRow()
if (row <= 1) return null // Header
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
const title = ss.getCellValueByColumnName("product_inventory", row, "title")
// Optimization: Get the whole row values in one go
// We need to know which index is SKU and Title.
// Getting headers once is cheaper than searching by name twice if we cache or just linear scan once.
// Actually, getCellValueByColumnName does: getSheet -> getHeaders (read) -> getRowData (read).
// Doing it twice = 6 operations.
// Let's do it manually efficiently:
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] as string[];
const skuIdx = headers.indexOf("sku");
const titleIdx = headers.indexOf("title");
if (skuIdx === -1) return null; // No SKU column
// Read the specific row
// getRange(row, 1, 1, lastCol)
const rowValues = sheet.getRange(row, 1, 1, sheet.getLastColumn()).getValues()[0];
const sku = rowValues[skuIdx];
const title = titleIdx !== -1 ? rowValues[titleIdx] : "";
return sku ? { sku: String(sku), title: String(title || "") } : null
}
@ -61,7 +88,7 @@ export function getMediaForSku(sku: string): any[] {
return mediaService.getUnifiedMediaState(sku, shopifyId)
}
export function saveMediaChanges(sku: string, finalState: any[]) {
export function saveMediaChanges(sku: string, finalState: any[], jobId: string | null = null) {
const config = new Config()
const driveService = new GASDriveService()
const shop = new Shop()
@ -84,7 +111,64 @@ export function saveMediaChanges(sku: string, finalState: any[]) {
throw new Error("Product must be synced to Shopify before saving media changes.")
}
return mediaService.processMediaChanges(sku, finalState, product.shopify_id)
const logs = mediaService.processMediaChanges(sku, finalState, product.shopify_id, jobId)
// Update Sheet Thumbnail (Top of Gallery)
try {
// Refresh state to get Shopify CDN URLs
const latestState = mediaService.getUnifiedMediaState(sku, product.shopify_id);
const sorted = latestState.sort((a, b) => (a.galleryOrder || 0) - (b.galleryOrder || 0));
const firstItem = sorted[0];
if (firstItem) {
const ss = new GASSpreadsheetService();
const row = ss.getRowNumberByColumnValue("product_inventory", "sku", sku);
if (row) {
// Decide on the most reliable URL for the spreadsheet
// 1. If it's a synced Shopify item, use the Shopify preview image URL (public)
// 2. Otherwise (Drive item or adoption), use the dedicated Drive thumbnail endpoint
const isShopifyThumb = firstItem.thumbnail && firstItem.thumbnail.startsWith('http');
const driveThumbUrl = `https://drive.google.com/thumbnail?id=${firstItem.driveId}&sz=w400`;
const thumbUrl = isShopifyThumb ? firstItem.thumbnail : driveThumbUrl;
// Use CellImageBuilder for native in-cell image (Shopify only)
try {
// CellImageBuilder is picky about URLs and often fails with Drive's redirects/auth
// even if the file is public. Formula-based IMAGE() is more robust for Drive.
if (!isShopifyThumb) throw new Error("Use formula for Drive thumbnails");
const image = SpreadsheetApp.newCellImage()
.setSourceUrl(thumbUrl)
.setAltTextTitle(sku)
.setAltTextDescription(`Thumbnail for ${sku}`)
.build();
ss.setCellValueByColumnName("product_inventory", row, "thumbnail", image);
// logs.push(`Updated sheet thumbnail for SKU ${sku}`); // Logs array is static now, won't stream this unless we refactor sheet update to use log() too. User cares mostly about main process.
} catch (builderErr) {
// Fallback to formula
ss.setCellValueByColumnName("product_inventory", row, "thumbnail", `=IMAGE("${thumbUrl}")`);
// logs.push(`Updated sheet thumbnail (Formula) for SKU ${sku}`);
}
} else {
// logs.push(`Warning: Could not find row for SKU ${sku} to update thumbnail.`);
}
}
} catch (e) {
console.warn("Failed to update sheet thumbnail", e);
// logs.push(`Warning: Failed to update sheet thumbnail: ${e.message}`);
}
return logs
}
export function pollJobLogs(jobId: string): string[] {
try {
const cache = CacheService.getDocumentCache();
const json = cache.get(`job_logs_${jobId}`);
return json ? JSON.parse(json) : [];
} catch(e) {
return [];
}
}
@ -116,6 +200,62 @@ export function getMediaDiagnostics(sku: string) {
}
}
export function getMediaManagerInitialState(providedSku?: string, providedTitle?: string): {
sku: string | null,
title: string,
diagnostics: any,
media: any[],
token: string
} {
let sku = providedSku;
let title = providedTitle || "";
if (!sku) {
const info = getSelectedProductInfo();
if (info) {
sku = info.sku;
title = info.title;
}
}
if (!sku) {
return {
sku: null,
title: "",
diagnostics: null,
media: [],
token: ScriptApp.getOAuthToken()
}
}
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)
try {
product.MatchToShopifyProduct(shop);
} catch (e) {
console.warn("MatchToShopifyProduct failed", e);
}
const shopifyId = product.shopify_id || ""
const initialState = mediaService.getInitialState(sku, shopifyId);
return {
sku,
title,
diagnostics: initialState.diagnostics,
media: initialState.media,
token: ScriptApp.getOAuthToken()
}
}
export function linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
const config = new Config()
const driveService = new GASDriveService()

View File

@ -12,7 +12,8 @@ const mockDrive = {
trashFile: jest.fn(),
updateFileProperties: jest.fn(),
getFileProperties: jest.fn(),
getFileById: jest.fn()
getFileById: jest.fn(),
getFilesWithProperties: jest.fn()
}
const mockShopify = {
getProductMedia: jest.fn(),
@ -80,6 +81,13 @@ describe("MediaService V2 Integration Logic", () => {
getId: () => "new_created_file_id"
})
mockDrive.getFileProperties.mockReturnValue({})
mockDrive.getFilesWithProperties.mockImplementation((folderId: string) => {
const files = mockDrive.getFiles(folderId) || []
return files.map(f => ({
file: f,
properties: mockDrive.getFileProperties(f.getId())
}))
})
})
describe("getUnifiedMediaState (Phase A)", () => {

View File

@ -99,4 +99,55 @@ export class GASDriveService implements IDriveService {
return {}
}
}
getFilesWithProperties(folderId: string): { file: GoogleAppsScript.Drive.File, properties: { [key: string]: string } }[] {
if (typeof Drive === 'undefined') {
return this.getFiles(folderId).map(f => ({ file: f, properties: {} }))
}
try {
const drive = Drive as any
const isV3 = !!drive.Files.create
const query = `'${folderId}' in parents and trashed = false`
const fields = isV3 ? 'nextPageToken, files(id, name, mimeType, appProperties)' : 'nextPageToken, items(id, title, mimeType, properties)'
const results: { file: GoogleAppsScript.Drive.File, properties: { [key: string]: string } }[] = []
let pageToken: string | null = null
do {
const response = drive.Files.list({ q: query, fields: fields, pageToken: pageToken, supportsAllDrives: true, includeItemsFromAllDrives: true })
const items = isV3 ? response.files : response.items
if (items) {
items.forEach((item: any) => {
const file = DriveApp.getFileById(item.id)
const props: { [key: string]: string } = {}
if (isV3) {
if (item.appProperties) {
Object.assign(props, item.appProperties)
}
} else {
if (item.properties) {
item.properties.forEach((p: any) => {
if (p.visibility === 'PRIVATE') {
props[p.key] = p.value
}
})
}
}
results.push({ file: file, properties: props })
})
}
pageToken = response.nextPageToken
} while (pageToken)
return results
} catch (e) {
console.error(`Failed to get files with properties for folder ${folderId}`, e)
return this.getFiles(folderId).map(f => ({ file: f, properties: {} }))
}
}
}

View File

@ -48,6 +48,15 @@ describe("MediaService Robust Sync", () => {
removeFile: (f) => {}
})
} as any
// Mock CacheService for log streaming
global.CacheService = {
getDocumentCache: () => ({
get: (key) => null,
put: (k, v, t) => {},
remove: (k) => {}
})
} as any
})
test("Strict Matching: Only matches via property, ignores filename", () => {
@ -304,4 +313,29 @@ describe("MediaService Robust Sync", () => {
expect(item.isProcessing).toBe(true)
expect(item.thumbnail).toContain("data:image/svg+xml;base64")
})
test("Processing: Marks item as processing if Shopify status is PROCESSING", () => {
const folder = driveService.getOrCreateFolder("SKU_SHOP_PROCESS", "root")
// Drive File
const blob = { getName: () => "vid.mp4", getBytes: () => [], getMimeType: () => "video/mp4", getThumbnail: () => ({ getBytes: () => [] }) } as any
const f = driveService.saveFile(blob, folder.getId())
driveService.updateFileProperties(f.getId(), { shopify_media_id: "gid://shopify/Media/Proc1" })
// Shopify Media (Processing)
shopifyService.getProductMedia = jest.fn().mockReturnValue([
{
id: "gid://shopify/Media/Proc1",
filename: "vid.mp4",
mediaContentType: "VIDEO",
status: "PROCESSING",
preview: { image: { originalSrc: null } } // Preview might be missing during processing
}
])
const state = mediaService.getUnifiedMediaState("SKU_SHOP_PROCESS", "pid")
const item = state.find(s => s.id === f.getId())
expect(item.isProcessing).toBe(true)
})
})

View File

@ -24,11 +24,38 @@ export class MediaService {
private logToCache(jobId: string, message: string) {
if (!jobId) return;
try {
const cache = CacheService.getDocumentCache();
const key = `job_logs_${jobId}`;
const existing = cache.get(key);
let logs = existing ? JSON.parse(existing) : [];
logs.push(message);
// Expire in 10 minutes (plenty for a save operation)
cache.put(key, JSON.stringify(logs), 600);
} catch (e) {
console.warn("Retrying log to cache failed slightly", e);
}
}
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 }
matching: { status: 'pending', error: null },
activeJobId: null
}
// Check for Active Job
try {
const cache = CacheService.getDocumentCache();
const activeJobId = cache.get(`active_job_${sku}`);
if (activeJobId) {
results.activeJobId = activeJobId;
}
} catch (e) {
console.warn("Failed to check active job", e);
}
// 1. Unsafe Drive Check
@ -73,7 +100,7 @@ export class MediaService {
// We need strict file list.
// Optimization: getFiles() usually returns limited info.
// We might need to iterate and pull props if getFiles() doesn't include appProperties (DriveApp doesn't).
const driveFiles = this.driveService.getFiles(folder.getId())
const driveFiles = this.driveService.getFilesWithProperties(folder.getId())
// 2. Get Shopify Media
let shopifyMedia: any[] = []
@ -91,24 +118,17 @@ export class MediaService {
const sidecarFileIds = new Set<string>();
// Map of Drive Files (Enriched)
const driveFileStats = driveFiles.map(f => {
let shopifyId = null
let galleryOrder = 9999
let type = 'media';
let customThumbnailId = null;
let parentVideoId = null;
const driveFileStats = driveFiles.map(d => {
const f = d.file
const props = d.properties
let shopifyId = props['shopify_media_id'] || null
let galleryOrder = props['gallery_order'] ? parseInt(props['gallery_order']) : 9999
let type = props['type'] || 'media';
let customThumbnailId = props['custom_thumbnail_id'] || null;
let parentVideoId = props['parent_video_id'] || null;
try {
const props = this.driveService.getFileProperties(f.getId())
if (props['shopify_media_id']) shopifyId = props['shopify_media_id']
if (props['gallery_order']) galleryOrder = parseInt(props['gallery_order'])
if (props['type']) type = props['type'];
if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id'];
if (props['parent_video_id']) parentVideoId = props['parent_video_id'];
console.log(`[DEBUG] File ${f.getName()} Props:`, JSON.stringify(props));
} catch (e) {
console.warn(`Failed to get properties for ${f.getName()}`)
}
return { file: f, shopifyId, galleryOrder, type, customThumbnailId, parentVideoId }
})
@ -239,6 +259,21 @@ export class MediaService {
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
isProcessing = true; // SHOW HOURGLASS (Request #3)
} else if (match && (
match.status === 'PROCESSING' ||
match.status === 'UPLOADED' ||
(match.mediaContentType === 'VIDEO' && (!match.sources || match.sources.length === 0) && match.status !== 'FAILED')
)) {
// Shopify Processing (Explicit Status OR Ready-but-missing-sources)
console.log(`[MediaService] Shopify Media is Processing: ${d.file.getName()} (Status: ${match.status}, Sources: ${match.sources ? match.sources.length : 0})`);
isProcessing = true;
// Use Drive thumb as fallback if Shopify preview not ready
if (!thumbnail) {
try {
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
if (nativeThumb.length > 100) thumbnail = nativeThumb;
} catch(e) {}
}
} else {
// 2. Native / Fallback
try {
@ -331,10 +366,24 @@ export class MediaService {
return { success: true };
}
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string): string[] {
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string, jobId: string | null = null): string[] {
const logs: string[] = []
logs.push(`Starting processing for SKU ${sku}`)
console.log(`MediaService: Processing changes for SKU ${sku}`)
// Helper to log to both return array and cache
const log = (msg: string) => {
logs.push(msg);
console.log(msg);
if (jobId) this.logToCache(jobId, msg);
}
log(`Starting processing for SKU ${sku}`)
// Register Job
if (jobId) {
try {
CacheService.getDocumentCache().put(`active_job_${sku}`, jobId, 600);
} catch(e) { console.warn("Failed to register active job", e); }
}
// 0. Service Availability Check & Local Capture (Fixing 'undefined' context issues)
const shopifySvc = this.shopifyMediaService
@ -349,15 +398,14 @@ export class MediaService {
// 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.")
if (toDelete.length === 0) log("No deletions found.")
toDelete.forEach(item => {
const msg = `Deleting item: ${item.filename}`
logs.push(msg)
console.log(msg)
log(msg)
if (item.shopifyId) {
shopifySvc.productDeleteMedia(shopifyProductId, item.shopifyId)
logs.push(`- Deleted from Shopify (${item.shopifyId})`)
log(`- Deleted from Shopify (${item.shopifyId})`)
}
if (item.driveId) {
// Check for Associated Sidecar Thumbs (Request #2)
@ -372,14 +420,14 @@ export class MediaService {
const props = driveSvc.getFileProperties(item.driveId);
if (props && props['custom_thumbnail_id']) {
driveSvc.trashFile(props['custom_thumbnail_id']);
logs.push(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`);
log(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`);
}
} catch (ignore) {
// If file already gone or other error
}
driveSvc.trashFile(item.driveId)
logs.push(`- Trashed in Drive (${item.driveId})`)
log(`- Trashed in Drive (${item.driveId})`)
}
})
@ -389,8 +437,7 @@ export class MediaService {
finalState.forEach(item => {
if (item.source === 'shopify_only' && item.shopifyId) {
const msg = `Adopting Orphan: ${item.filename}`
logs.push(msg)
console.log(msg)
log(msg)
try {
// Download
@ -416,9 +463,9 @@ export class MediaService {
// Update item refs for subsequent steps
item.driveId = file.getId()
item.source = 'synced'
logs.push(`- Adopted to Drive (${file.getId()})`)
log(`- Adopted to Drive (${file.getId()})`)
} catch (e) {
logs.push(`- Failed to adopt ${item.filename}: ${e}`)
log(`- Failed to adopt ${item.filename}: ${e}`)
}
}
})
@ -427,7 +474,7 @@ export class MediaService {
const toUpload = finalState.filter(item => item.source === 'drive_only' && item.driveId)
if (toUpload.length > 0) {
const msg = `Uploading ${toUpload.length} new items from Drive`
logs.push(msg)
log(msg)
const uploads = toUpload.map(item => {
const f = driveSvc.getFileById(item.driveId)
return {
@ -454,7 +501,7 @@ export class MediaService {
if (stagedResp.userErrors && stagedResp.userErrors.length > 0) {
console.error("[MediaService] stagedUploadsCreate Errors:", JSON.stringify(stagedResp.userErrors))
logs.push(`- Upload preparation failed: ${stagedResp.userErrors.map(e => e.message).join(', ')}`)
log(`- Upload preparation failed: ${stagedResp.userErrors.map(e => e.message).join(', ')}`)
}
const targets = stagedResp.stagedTargets
@ -463,7 +510,7 @@ export class MediaService {
uploads.forEach((u, i) => {
const target = targets[i]
if (!target || !target.url) {
logs.push(`- Failed to get upload target for ${u.filename}: Invalid target`)
log(`- Failed to get upload target for ${u.filename}: Invalid target`)
console.warn(`[MediaService] Missing target URL for ${u.filename}. Target:`, JSON.stringify(target))
return
}
@ -490,7 +537,7 @@ export class MediaService {
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`)
log(`- Created in Shopify (${m.id}) and linked`)
}
})
}
@ -524,7 +571,7 @@ export class MediaService {
const timestamp = new Date().getTime()
const newName = `${sku}_${timestamp}.${ext}`
driveSvc.renameFile(item.driveId, newName)
logs.push(`- Renamed ${currentName} -> ${newName} (Non-conforming)`)
log(`- Renamed ${currentName} -> ${newName} (Non-conforming)`)
}
// C. Prepare Shopify Reorder
@ -533,18 +580,41 @@ export class MediaService {
}
} catch (e) {
logs.push(`- Error updating ${item.filename}: ${e}`)
log(`- Error updating ${item.filename}: ${e}`)
}
})
// 6. Execute Shopify Reorder
if (reorderMoves.length > 0) {
shopifySvc.productReorderMedia(shopifyProductId, reorderMoves)
logs.push("Reordered media in Shopify.")
log("Reordered media in Shopify.")
}
log("Processing Complete.")
// Clear Job (Success)
if (jobId) {
try {
CacheService.getDocumentCache().remove(`active_job_${sku}`);
} catch(e) {}
}
logs.push("Processing Complete.")
return logs
}
getInitialState(sku: string, shopifyProductId: string): { diagnostics: any, media: any[] } {
// 1. Diagnostics (Reusing the existing method logic but avoiding redundant setup)
const diagnostics = this.getDiagnostics(sku, shopifyProductId);
// 2. Unified Media State
// If diagnostics succeeded in finding the folder, we should probably pass that info
// to getUnifiedMediaState to avoid re-fetching the folder, but for now
// let's just call the method to keep it clean.
const media = this.getUnifiedMediaState(sku, shopifyProductId);
return {
diagnostics,
media
};
}
}

View File

@ -127,4 +127,12 @@ export class MockDriveService implements IDriveService {
return {}
}
}
getFilesWithProperties(folderId: string): { file: GoogleAppsScript.Drive.File, properties: { [key: string]: string } }[] {
const files = this.getFiles(folderId)
return files.map(f => ({
file: f,
properties: (f as any)._properties || {}
}))
}
}

View File

@ -73,6 +73,7 @@ export class ShopifyMediaService implements IShopifyMediaService {
id
alt
mediaContentType
status
preview {
image {
originalSrc

Binary file not shown.

Binary file not shown.