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:
@ -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 {
|
||||
@ -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);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user