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 {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 24px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
@ -74,9 +74,9 @@
.media-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
margin-top: 16px;
}
.media-item {
@ -85,6 +85,10 @@
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
}
.media-item img {
@ -167,7 +171,7 @@
<button onclick="startPhotoSession()" class="btn btn-secondary" style="margin-top: 4px; font-size: 13px;">
Import from Google Photos
</button>
<div id="photos-session-ui"
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
<div style="font-weight:500; font-size:13px; margin-bottom:4px;">Pick Photos</div>
@ -321,11 +325,13 @@
.setIncludeFolders(true)
.setSelectFolderEnabled(false);
const photosView = new google.picker.PhotosView();
const picker = new google.picker.PickerBuilder()
.addView(view)
.addView(photosView)
.setOAuthToken(config.token)
.setDeveloperKey(config.apiKey)
.setOrigin(google.script.host.origin)
.setCallback(pickerCallback)
.build();
@ -338,98 +344,99 @@
const fileId = doc.id;
const mimeType = doc.mimeType;
const name = doc.name;
const url = doc.url; // Often the link to the file in Drive/Photos
// 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 url = doc.url;
const imageUrl = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
google.script.run
.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() {
// Reset UI
document.getElementById('photos-session-ui').style.display = 'block';
document.getElementById('photos-session-status').innerText = "Creating session...";
document.getElementById('photos-session-link').style.display = 'none';
function startPhotoSession() {
// Reset UI
document.getElementById('photos-session-ui').style.display = 'block';
document.getElementById('photos-session-status').innerText = "Creating session...";
document.getElementById('photos-session-link').style.display = 'none';
isProcessingPhotos = false;
google.script.run
.withSuccessHandler(onSessionCreated)
.withFailureHandler(e => {
alert('Failed to start session: ' + e.message);
document.getElementById('photos-session-ui').style.display = 'none';
})
.createPhotoSession();
}
google.script.run
.withSuccessHandler(onSessionCreated)
.withFailureHandler(e => {
alert('Failed to start session: ' + e.message);
document.getElementById('photos-session-ui').style.display = 'none';
})
.createPhotoSession();
}
function onSessionCreated(session) {
if (!session || !session.pickerUri) {
alert("Failed to get picker URI");
return;
}
function onSessionCreated(session) {
if (!session || !session.pickerUri) {
alert("Failed to get picker URI");
return;
}
const link = document.getElementById('photos-session-link');
link.href = session.pickerUri;
link.style.display = 'block';
link.innerText = "Click here to pick photos ↗";
const link = document.getElementById('photos-session-link');
link.href = session.pickerUri;
link.style.display = 'block';
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 polling
if (pollingTimer) clearInterval(pollingTimer);
pollingTimer = setInterval(() => pollSession(session.id), 2000); // Poll every 2s
}
// Start recursive polling
pollSession(session.id);
}
function pollSession(sessionId) {
google.script.run
.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 pollSession(sessionId) {
if (isProcessingPhotos) return;
function processPickedPhotos(items) {
// Reuse importFromPicker logic logic?
// We can call importFromPicker for each item.
let processed = 0;
google.script.run
.withSuccessHandler(result => {
console.log("Poll result:", result);
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 => {
// console.log("Processing item:", item);
// The new Picker API returns baseUrl nested inside mediaFile
const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
function processPickedPhotos(items) {
let processed = 0;
google.script.run
.withSuccessHandler(() => {
processed++;
if (processed === items.length) {
document.getElementById('photos-session-status').innerText = "Done!";
loadMedia(currentSku);
setTimeout(() => {
document.getElementById('photos-session-ui').style.display = 'none';
}, 3000);
}
})
.importFromPicker(currentSku, null, item.mimeType, item.filename, imageUrl);
});
items.forEach(item => {
const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
google.script.run
.withSuccessHandler(() => {
processed++;
if (processed === items.length) {
document.getElementById('photos-session-status').innerText = "Done!";
loadMedia(currentSku);
setTimeout(() => {
document.getElementById('photos-session-ui').style.display = 'none';
}, 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 { checkRecentSales, reconcileSalesHandler } from "./salesSync"
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"
// prettier-ignore
@ -51,7 +51,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).checkRecentSales = checkRecentSales
;(global as any).reconcileSalesHandler = reconcileSalesHandler
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
;(global as any).showMediaSidebar = showMediaSidebar
;(global as any).showMediaManager = showMediaManager
;(global as any).getSelectedSku = getSelectedSku
;(global as any).getMediaForSku = getMediaForSku
;(global as any).saveFileToDrive = saveFileToDrive

View File

@ -6,7 +6,7 @@ import { reinstallTriggers, installSalesSyncTrigger } from "./triggers"
import { reconcileSalesHandler } from "./salesSync"
import { toastAndLog } from "./sheetUtils"
import { showSidebar } from "./sidebar"
import { showMediaSidebar, debugScopes } from "./mediaHandlers"
import { showMediaManager, debugScopes } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite"
export function initMenu() {
@ -18,7 +18,7 @@ export function initMenu() {
.addItem("Fill out product from template", fillProductFromTemplate.name)
.addItem("Match product to Shopify", matchProductToShopifyHandler.name)
.addItem("Update Shopify Product", updateShopifyProductHandler.name)
.addItem("Media Manager", showMediaSidebar.name)
.addItem("Media Manager", showMediaManager.name)
)
.addSeparator()
.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 { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
@ -312,14 +312,25 @@ describe("mediaHandlers", () => {
})
describe("Utility Functions", () => {
test("showMediaSidebar should render template", () => {
const mockUi = { showSidebar: jest.fn() }
test("showMediaManager should render template", () => {
const mockUi = { showModalDialog: jest.fn() }
;(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")
expect(mockUi.showSidebar).toHaveBeenCalled()
showMediaManager()
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", () => {

View File

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