Fix duplicate media import bug and rename MediaSidebar to MediaManager

- Renamed src/MediaSidebar.html to src/MediaManager.html to align with modal UI.
- Fixed race condition in Photo Picker polling preventing duplicate imports.
- Updated global.ts, initMenu.ts, and mediaHandlers.ts used in the fix.
- Fixed unit tests for mediaHandlers.
This commit is contained in:
Ben Miller
2025-12-26 22:57:46 -07:00
parent 3da46958f7
commit 8554ae9610
5 changed files with 114 additions and 95 deletions

View File

@ -59,7 +59,7 @@
.upload-zone { .upload-zone {
border: 2px dashed var(--border); border: 2px dashed var(--border);
border-radius: 8px; border-radius: 8px;
padding: 24px; padding: 40px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@ -74,9 +74,9 @@
.media-grid { .media-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px; gap: 16px;
margin-top: 12px; margin-top: 16px;
} }
.media-item { .media-item {
@ -85,6 +85,10 @@
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
} }
.media-item img { .media-item img {
@ -321,11 +325,13 @@
.setIncludeFolders(true) .setIncludeFolders(true)
.setSelectFolderEnabled(false); .setSelectFolderEnabled(false);
const photosView = new google.picker.PhotosView();
const picker = new google.picker.PickerBuilder() const picker = new google.picker.PickerBuilder()
.addView(view) .addView(view)
.addView(photosView)
.setOAuthToken(config.token) .setOAuthToken(config.token)
.setDeveloperKey(config.apiKey) .setDeveloperKey(config.apiKey)
.setOrigin(google.script.host.origin)
.setCallback(pickerCallback) .setCallback(pickerCallback)
.build(); .build();
@ -338,98 +344,99 @@
const fileId = doc.id; const fileId = doc.id;
const mimeType = doc.mimeType; const mimeType = doc.mimeType;
const name = doc.name; const name = doc.name;
const url = doc.url; // Often the link to the file in Drive/Photos // const url = doc.url;
// For Photos, we might need the direct image URL, which is often in thumbnails or requires specific handling
// doc.thumbnails contains 's75-c' style URLs. We can strip the size to get full size?
// Actually, for Photos API items, 'url' might be the user-facing URL.
// Let's pass the 'thumbnails' closest to original if possible, or just pass the whole doc object to backend?
// Simpler: pass specific fields.
const imageUrl = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null; const imageUrl = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
google.script.run google.script.run
.withSuccessHandler(() => loadMedia(currentSku)) .withSuccessHandler(() => loadMedia(currentSku))
.importFromPicker(currentSku, fileId, mimeType, name, imageUrl); .importFromPicker(currentSku, fileId, mimeType, name, imageUrl);
} }
} }
// --- Photos Session Logic (New API) --- // --- Photos Session Logic (New API) ---
let pollingTimer = null; let isProcessingPhotos = false;
function startPhotoSession() { function startPhotoSession() {
// Reset UI // Reset UI
document.getElementById('photos-session-ui').style.display = 'block'; document.getElementById('photos-session-ui').style.display = 'block';
document.getElementById('photos-session-status').innerText = "Creating session..."; document.getElementById('photos-session-status').innerText = "Creating session...";
document.getElementById('photos-session-link').style.display = 'none'; document.getElementById('photos-session-link').style.display = 'none';
isProcessingPhotos = false;
google.script.run google.script.run
.withSuccessHandler(onSessionCreated) .withSuccessHandler(onSessionCreated)
.withFailureHandler(e => { .withFailureHandler(e => {
alert('Failed to start session: ' + e.message); alert('Failed to start session: ' + e.message);
document.getElementById('photos-session-ui').style.display = 'none'; document.getElementById('photos-session-ui').style.display = 'none';
}) })
.createPhotoSession(); .createPhotoSession();
} }
function onSessionCreated(session) { function onSessionCreated(session) {
if (!session || !session.pickerUri) { if (!session || !session.pickerUri) {
alert("Failed to get picker URI"); alert("Failed to get picker URI");
return; return;
} }
const link = document.getElementById('photos-session-link'); const link = document.getElementById('photos-session-link');
link.href = session.pickerUri; link.href = session.pickerUri;
link.style.display = 'block'; link.style.display = 'block';
link.innerText = "Click here to pick photos ↗"; link.innerText = "Click here to pick photos ↗";
document.getElementById('photos-session-status').innerText = "Waiting for you to pick photos..."; document.getElementById('photos-session-status').innerText = "Waiting for you to pick photos...";
// Open automatically? Browsers block it. User must click. // Start recursive polling
// Start polling pollSession(session.id);
if (pollingTimer) clearInterval(pollingTimer); }
pollingTimer = setInterval(() => pollSession(session.id), 2000); // Poll every 2s
}
function pollSession(sessionId) { function pollSession(sessionId) {
google.script.run if (isProcessingPhotos) return;
.withSuccessHandler(result => {
console.log("Poll result:", result);
if (result.status === 'complete') {
clearInterval(pollingTimer);
document.getElementById('photos-session-status').innerText = "Importing photos...";
processPickedPhotos(result.mediaItems);
} else if (result.status === 'error') {
document.getElementById('photos-session-status').innerText = "Error: " + result.message;
}
})
.checkPhotoSession(sessionId);
}
function processPickedPhotos(items) { google.script.run
// Reuse importFromPicker logic logic? .withSuccessHandler(result => {
// We can call importFromPicker for each item. console.log("Poll result:", result);
let processed = 0; if (result.status === 'complete') {
if (!isProcessingPhotos) {
isProcessingPhotos = true;
document.getElementById('photos-session-status').innerText = "Importing photos...";
processPickedPhotos(result.mediaItems);
}
} else if (result.status === 'error') {
document.getElementById('photos-session-status').innerText = "Error: " + result.message;
} else {
// Still waiting, poll again in 2s
setTimeout(() => pollSession(sessionId), 2000);
}
})
.withFailureHandler(e => {
console.error("Poll failed", e);
// Retry? Or stop? Let's retry slowly.
setTimeout(() => pollSession(sessionId), 5000);
})
.checkPhotoSession(sessionId);
}
items.forEach(item => { function processPickedPhotos(items) {
// console.log("Processing item:", item); let processed = 0;
// The new Picker API returns baseUrl nested inside mediaFile
const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
google.script.run items.forEach(item => {
.withSuccessHandler(() => { const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
processed++;
if (processed === items.length) { google.script.run
document.getElementById('photos-session-status').innerText = "Done!"; .withSuccessHandler(() => {
loadMedia(currentSku); processed++;
setTimeout(() => { if (processed === items.length) {
document.getElementById('photos-session-ui').style.display = 'none'; document.getElementById('photos-session-status').innerText = "Done!";
}, 3000); loadMedia(currentSku);
} setTimeout(() => {
}) document.getElementById('photos-session-ui').style.display = 'none';
.importFromPicker(currentSku, null, item.mimeType, item.filename, imageUrl); }, 3000);
}); }
})
.importFromPicker(currentSku, null, item.mimeType, item.filename, imageUrl);
});
} }

View File

@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar" import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync" import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
import { installSalesSyncTrigger } from "./triggers" import { installSalesSyncTrigger } from "./triggers"
import { showMediaSidebar, getSelectedSku, getMediaForSku, saveFileToDrive, syncMediaForSku, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers" import { showMediaManager, getSelectedSku, getMediaForSku, saveFileToDrive, syncMediaForSku, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite" import { runSystemDiagnostics } from "./verificationSuite"
// prettier-ignore // prettier-ignore
@ -51,7 +51,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).checkRecentSales = checkRecentSales ;(global as any).checkRecentSales = checkRecentSales
;(global as any).reconcileSalesHandler = reconcileSalesHandler ;(global as any).reconcileSalesHandler = reconcileSalesHandler
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger ;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
;(global as any).showMediaSidebar = showMediaSidebar ;(global as any).showMediaManager = showMediaManager
;(global as any).getSelectedSku = getSelectedSku ;(global as any).getSelectedSku = getSelectedSku
;(global as any).getMediaForSku = getMediaForSku ;(global as any).getMediaForSku = getMediaForSku
;(global as any).saveFileToDrive = saveFileToDrive ;(global as any).saveFileToDrive = saveFileToDrive

View File

@ -6,7 +6,7 @@ import { reinstallTriggers, installSalesSyncTrigger } from "./triggers"
import { reconcileSalesHandler } from "./salesSync" import { reconcileSalesHandler } from "./salesSync"
import { toastAndLog } from "./sheetUtils" import { toastAndLog } from "./sheetUtils"
import { showSidebar } from "./sidebar" import { showSidebar } from "./sidebar"
import { showMediaSidebar, debugScopes } from "./mediaHandlers" import { showMediaManager, debugScopes } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite" import { runSystemDiagnostics } from "./verificationSuite"
export function initMenu() { export function initMenu() {
@ -18,7 +18,7 @@ export function initMenu() {
.addItem("Fill out product from template", fillProductFromTemplate.name) .addItem("Fill out product from template", fillProductFromTemplate.name)
.addItem("Match product to Shopify", matchProductToShopifyHandler.name) .addItem("Match product to Shopify", matchProductToShopifyHandler.name)
.addItem("Update Shopify Product", updateShopifyProductHandler.name) .addItem("Update Shopify Product", updateShopifyProductHandler.name)
.addItem("Media Manager", showMediaSidebar.name) .addItem("Media Manager", showMediaManager.name)
) )
.addSeparator() .addSeparator()
.addSubMenu( .addSubMenu(

View File

@ -1,5 +1,5 @@
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaSidebar, getSelectedSku, getPickerConfig, saveFileToDrive, syncMediaForSku, debugScopes } from "./mediaHandlers" import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedSku, getPickerConfig, saveFileToDrive, syncMediaForSku, debugScopes } from "./mediaHandlers"
import { Config } from "./config" import { Config } from "./config"
import { GASDriveService } from "./services/GASDriveService" import { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService" import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
@ -312,14 +312,25 @@ describe("mediaHandlers", () => {
}) })
describe("Utility Functions", () => { describe("Utility Functions", () => {
test("showMediaSidebar should render template", () => { test("showMediaManager should render template", () => {
const mockUi = { showSidebar: jest.fn() } const mockUi = { showModalDialog: jest.fn() }
;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi) ;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi)
showMediaSidebar() // Mock HTML output chain
const mockHtml = {
setTitle: jest.fn().mockReturnThis(),
setWidth: jest.fn().mockReturnThis(),
setHeight: jest.fn().mockReturnThis()
}
;(global.HtmlService.createHtmlOutputFromFile as jest.Mock).mockReturnValue(mockHtml)
expect(global.HtmlService.createHtmlOutputFromFile).toHaveBeenCalledWith("MediaSidebar") showMediaManager()
expect(mockUi.showSidebar).toHaveBeenCalled()
expect(global.HtmlService.createHtmlOutputFromFile).toHaveBeenCalledWith("MediaManager")
expect(mockHtml.setTitle).toHaveBeenCalledWith("Media Manager")
expect(mockHtml.setWidth).toHaveBeenCalledWith(1100)
expect(mockHtml.setHeight).toHaveBeenCalledWith(750)
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
}) })
test("getSelectedSku should return sku from sheet", () => { test("getSelectedSku should return sku from sheet", () => {

View File

@ -7,11 +7,12 @@ import { Shop } from "./shopifyApi"
import { Config } from "./config" import { Config } from "./config"
import { Product } from "./Product" import { Product } from "./Product"
export function showMediaSidebar() { export function showMediaManager() {
const html = HtmlService.createHtmlOutputFromFile("MediaSidebar") const html = HtmlService.createHtmlOutputFromFile("MediaManager")
.setTitle("Media Manager") .setTitle("Media Manager")
.setWidth(350); .setWidth(1100)
SpreadsheetApp.getUi().showSidebar(html); .setHeight(750);
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
} }
export function getSelectedSku(): string | null { export function getSelectedSku(): string | null {