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.
This commit is contained in:
@ -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.
|
||||
|
||||
@ -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`)
|
||||
|
||||
|
||||
@ -848,6 +848,8 @@
|
||||
};
|
||||
|
||||
var state = new MediaState();
|
||||
state.sku = "<?!= initialSku ?>";
|
||||
state.title = "<?!= initialTitle ?>";
|
||||
window.state = state;
|
||||
|
||||
// --- ES5 Refactor: UI ---
|
||||
@ -1276,148 +1278,111 @@
|
||||
*/
|
||||
var controller = {
|
||||
init() {
|
||||
// Initialize by checking SKU once
|
||||
// Since this is a modal, the selection cannot change during the session.
|
||||
this.checkSku();
|
||||
},
|
||||
|
||||
checkSku() {
|
||||
google.script.run
|
||||
.withSuccessHandler(info => {
|
||||
// Info is now { sku, title } or null
|
||||
const sku = info ? info.sku : null;
|
||||
|
||||
if (sku && sku !== state.sku) {
|
||||
state.setSku(info);
|
||||
this.loadMedia();
|
||||
} else if (!sku && !state.sku) {
|
||||
if (document.getElementById('error-ui').style.display !== 'flex') {
|
||||
this.loadMedia(); // Likely to trigger error UI
|
||||
}
|
||||
}
|
||||
})
|
||||
.withFailureHandler(e => {
|
||||
console.warn("SKU check failed", e);
|
||||
// If it fails once at startup, we probably should alert or retry once,
|
||||
// but for now let's just leave it. If it fails, the UI might hang on "Loading..."
|
||||
// potentially better to trigger error UI?
|
||||
if (document.getElementById('loading-ui').style.display !== 'none') {
|
||||
alert("Failed to load product info: " + e.message);
|
||||
}
|
||||
})
|
||||
.getSelectedProductInfo();
|
||||
if (state.sku) {
|
||||
// If we already have the SKU from the template, show the UI shell immediately
|
||||
document.getElementById('loading-ui').style.display = 'none';
|
||||
document.getElementById('main-ui').style.display = 'block';
|
||||
ui.updateSku(state.sku, state.title);
|
||||
ui.setLoadingState(true); // Loading... spinner inside the grid
|
||||
}
|
||||
this.loadMedia();
|
||||
},
|
||||
|
||||
loadMedia(preserveLogs = false) {
|
||||
// ... (Resolving SKU/Title Logic preserved below implicitly or we verify we didn't clip it)
|
||||
// Actually, let's keep the resolving logic safe.
|
||||
// We are replacing lines 1120-1191 roughly.
|
||||
|
||||
let sku = state.sku;
|
||||
let title = state.title;
|
||||
|
||||
if (!sku) {
|
||||
const domSku = document.getElementById('current-sku').innerText;
|
||||
if (domSku && domSku !== '...') sku = domSku;
|
||||
}
|
||||
|
||||
// CHECK FOR MISSING SKU
|
||||
if (!sku || sku === '...') {
|
||||
console.warn("No SKU found. Showing error.");
|
||||
document.getElementById('loading-ui').style.display = 'none';
|
||||
document.getElementById('main-ui').style.display = 'none';
|
||||
document.getElementById('error-ui').style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
const domTitle = document.getElementById('current-title').innerText;
|
||||
if (domTitle && domTitle !== 'Loading...') title = domTitle;
|
||||
}
|
||||
|
||||
document.getElementById('loading-ui').style.display = 'none';
|
||||
document.getElementById('main-ui').style.display = 'block';
|
||||
// Visual optimization: Show loading immediately
|
||||
ui.setLoadingState(true);
|
||||
state.setSku({ sku, title });
|
||||
|
||||
if (!preserveLogs) {
|
||||
document.getElementById('status-log-container').innerHTML = ''; // Reset log
|
||||
ui.logStatus('ready', 'Ready.', 'info');
|
||||
} else {
|
||||
// We might want to clear "Ready" if we are preserving logs
|
||||
}
|
||||
|
||||
// ui.toggleLogBtn.style.display = 'inline-block'; // Removed
|
||||
|
||||
ui.logStatus('init', 'Initializing access...', 'info');
|
||||
|
||||
// 1. Diagnostics (Parallel)
|
||||
google.script.run
|
||||
.withSuccessHandler((diagnostics) => {
|
||||
// Check Resumption
|
||||
if (diagnostics.activeJobId) {
|
||||
ui.logStatus('resume', 'Resuming active background job...', 'info');
|
||||
ui.toggleSave(false);
|
||||
ui.saveBtn.innerText = "Saving in background...";
|
||||
controller.startLogPolling(diagnostics.activeJobId);
|
||||
if (!ui.logCard.classList.contains('expanded')) ui.toggleLogExpand();
|
||||
.withSuccessHandler(response => {
|
||||
const { sku: serverSku, title: serverTitle, diagnostics, media, token } = response;
|
||||
|
||||
if (!serverSku) {
|
||||
console.warn("No SKU found. Showing error.");
|
||||
document.getElementById('loading-ui').style.display = 'none';
|
||||
document.getElementById('main-ui').style.display = 'none';
|
||||
document.getElementById('error-ui').style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive Status
|
||||
if (diagnostics.drive.status === 'ok') {
|
||||
ui.logStatus('drive', `Drive Folder: ok (${diagnostics.drive.fileCount} files) <a href="${diagnostics.drive.folderUrl}" target="_blank" style="margin-left:8px;">Open Folder ↗</a>`, 'success');
|
||||
ui.setDriveLink(diagnostics.drive.folderUrl);
|
||||
} else {
|
||||
ui.logStatus('drive', `Drive Check Failed: ${diagnostics.drive.error}`, 'error');
|
||||
// Update State
|
||||
state.setSku({ sku: serverSku, title: serverTitle });
|
||||
state.token = token;
|
||||
|
||||
// Update UI
|
||||
document.getElementById('loading-ui').style.display = 'none';
|
||||
document.getElementById('main-ui').style.display = 'block';
|
||||
|
||||
if (!preserveLogs) {
|
||||
document.getElementById('status-log-container').innerHTML = ''; // Reset log
|
||||
ui.logStatus('ready', 'Ready.', 'info');
|
||||
}
|
||||
|
||||
// Capture Token
|
||||
if (diagnostics.token) state.token = diagnostics.token;
|
||||
ui.logStatus('init', 'Initializing access...', 'info');
|
||||
|
||||
// Shopify Status
|
||||
if (diagnostics.shopify.status === 'ok') {
|
||||
ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success');
|
||||
ui.setShopifyLink(diagnostics.shopify.adminUrl);
|
||||
} else if (diagnostics.shopify.status === 'skipped') {
|
||||
ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info');
|
||||
} else {
|
||||
ui.logStatus('shopify', `Shopify Check Failed: ${diagnostics.shopify.error}`, 'error');
|
||||
// Handle Diagnostics
|
||||
if (diagnostics) {
|
||||
// Check Resumption
|
||||
if (diagnostics.activeJobId) {
|
||||
ui.logStatus('resume', 'Resuming active background job...', 'info');
|
||||
ui.toggleSave(false);
|
||||
ui.saveBtn.innerText = "Saving in background...";
|
||||
this.startLogPolling(diagnostics.activeJobId);
|
||||
if (!ui.logCard.classList.contains('expanded')) ui.toggleLogExpand();
|
||||
}
|
||||
|
||||
// Drive Status
|
||||
if (diagnostics.drive.status === 'ok') {
|
||||
ui.logStatus('drive', `Drive Folder: ok (${diagnostics.drive.fileCount} files) <a href="${diagnostics.drive.folderUrl}" target="_blank" style="margin-left:8px;">Open Folder ↗</a>`, 'success');
|
||||
ui.setDriveLink(diagnostics.drive.folderUrl);
|
||||
} else {
|
||||
ui.logStatus('drive', `Drive Check Failed: ${diagnostics.drive.error}`, 'error');
|
||||
}
|
||||
|
||||
// Shopify Status
|
||||
if (diagnostics.shopify.status === 'ok') {
|
||||
ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success');
|
||||
ui.setShopifyLink(diagnostics.shopify.adminUrl);
|
||||
} else if (diagnostics.shopify.status === 'skipped') {
|
||||
ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info');
|
||||
} else {
|
||||
ui.logStatus('shopify', `Shopify Check Failed: ${diagnostics.shopify.error}`, 'error');
|
||||
}
|
||||
}
|
||||
})
|
||||
.withFailureHandler(function (err) {
|
||||
ui.logStatus('fatal', `Diagnostics failed: ${err.message}`, 'error');
|
||||
})
|
||||
.getMediaDiagnostics(sku, "");
|
||||
|
||||
// 2. Load Full Media (Parallel)
|
||||
ui.logStatus('fetch', 'Fetching full media state...', 'info');
|
||||
google.script.run
|
||||
.withSuccessHandler(function (items) {
|
||||
// Normalize items
|
||||
const normalized = items.map(function (i) {
|
||||
return {
|
||||
...i,
|
||||
id: i.id || Math.random().toString(36).substr(2, 9),
|
||||
status: i.source || 'drive_only',
|
||||
source: i.source,
|
||||
_deleted: false
|
||||
};
|
||||
});
|
||||
// Handle Media Items
|
||||
if (media) {
|
||||
ui.logStatus('fetch', 'Fetched full media state.', 'success');
|
||||
const normalized = media.map(function (i) {
|
||||
return {
|
||||
...i,
|
||||
id: i.id || Math.random().toString(36).substr(2, 9),
|
||||
status: i.source || 'drive_only',
|
||||
source: i.source,
|
||||
_deleted: false
|
||||
};
|
||||
});
|
||||
|
||||
state.setItems(normalized);
|
||||
state.setItems(normalized);
|
||||
|
||||
if (!controller.hasRunMatching) {
|
||||
controller.hasRunMatching = true;
|
||||
controller.checkMatches(normalized);
|
||||
} else {
|
||||
controller.showGallery();
|
||||
if (!this.hasRunMatching) {
|
||||
this.hasRunMatching = true;
|
||||
this.checkMatches(normalized);
|
||||
} else {
|
||||
this.showGallery();
|
||||
}
|
||||
}
|
||||
})
|
||||
.withFailureHandler(function (err) {
|
||||
ui.logStatus('fatal', `Failed to load media: ${err.message}`, 'error');
|
||||
|
||||
ui.setLoadingState(false);
|
||||
})
|
||||
.getMediaForSku(sku);
|
||||
.withFailureHandler(err => {
|
||||
console.error("Initial load failed", err);
|
||||
ui.logStatus('fatal', `Failed to load initialization data: ${err.message}`, 'error');
|
||||
ui.setLoadingState(false);
|
||||
})
|
||||
.getMediaManagerInitialState(sku, title);
|
||||
},
|
||||
|
||||
|
||||
|
||||
@ -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, pollJobLogs } 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
|
||||
@ -66,3 +66,4 @@ import { runSystemDiagnostics } from "./verificationSuite"
|
||||
;(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"
|
||||
@ -28,7 +28,8 @@ jest.mock("./services/MediaService", () => {
|
||||
MediaService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
getUnifiedMediaState: jest.fn().mockReturnValue([]),
|
||||
processMediaChanges: jest.fn().mockReturnValue([])
|
||||
processMediaChanges: jest.fn().mockReturnValue([]),
|
||||
getInitialState: jest.fn().mockReturnValue({ diagnostics: {}, media: [] })
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -168,6 +169,13 @@ global.HtmlService = {
|
||||
setTitle: 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
|
||||
|
||||
@ -291,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
|
||||
@ -431,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)
|
||||
|
||||
@ -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);
|
||||
@ -193,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: {} }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,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[] = []
|
||||
@ -118,26 +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));
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
@ -610,5 +601,20 @@ export class MediaService {
|
||||
|
||||
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 || {}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user