Compare commits
16 Commits
thumbnails
...
e39bc862cc
| Author | SHA1 | Date | |
|---|---|---|---|
| e39bc862cc | |||
| fc25e877f1 | |||
| e0e5b76c8e | |||
| 8487df3ea0 | |||
| ad67dd9ab5 | |||
| 55a89a0802 | |||
| d34f9a1417 | |||
| 3abc57f45a | |||
| dc33390650 | |||
| f25fb359e8 | |||
| 64ab548593 | |||
| 772957058d | |||
| 16dec5e888 | |||
| ec6602cbde | |||
| f1ab3b7b84 | |||
| ebc1a39ce3 |
57
.eslintrc.js
Normal file
57
.eslintrc.js
Normal 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
4
.gitignore
vendored
@ -3,4 +3,6 @@ dist/**
|
||||
desktop.ini
|
||||
.continue/**
|
||||
.clasp.json
|
||||
coverage/
|
||||
coverage/
|
||||
test_*.txt
|
||||
.agent/
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
1298
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
@ -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
|
||||
|
||||
@ -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} }[]
|
||||
}
|
||||
|
||||
@ -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" })
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)", () => {
|
||||
|
||||
@ -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: {} }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 || {}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,7 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
||||
id
|
||||
alt
|
||||
mediaContentType
|
||||
status
|
||||
preview {
|
||||
image {
|
||||
originalSrc
|
||||
|
||||
BIN
test_output.txt
BIN
test_output.txt
Binary file not shown.
Reference in New Issue
Block a user