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 {
|
.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,13 +344,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -356,13 +356,14 @@
|
|||||||
|
|
||||||
// --- 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)
|
||||||
@ -386,35 +387,41 @@
|
|||||||
|
|
||||||
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) {
|
||||||
|
if (isProcessingPhotos) return;
|
||||||
|
|
||||||
google.script.run
|
google.script.run
|
||||||
.withSuccessHandler(result => {
|
.withSuccessHandler(result => {
|
||||||
console.log("Poll result:", result);
|
console.log("Poll result:", result);
|
||||||
if (result.status === 'complete') {
|
if (result.status === 'complete') {
|
||||||
clearInterval(pollingTimer);
|
if (!isProcessingPhotos) {
|
||||||
|
isProcessingPhotos = true;
|
||||||
document.getElementById('photos-session-status').innerText = "Importing photos...";
|
document.getElementById('photos-session-status').innerText = "Importing photos...";
|
||||||
processPickedPhotos(result.mediaItems);
|
processPickedPhotos(result.mediaItems);
|
||||||
|
}
|
||||||
} else if (result.status === 'error') {
|
} else if (result.status === 'error') {
|
||||||
document.getElementById('photos-session-status').innerText = "Error: " + result.message;
|
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);
|
.checkPhotoSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processPickedPhotos(items) {
|
function processPickedPhotos(items) {
|
||||||
// Reuse importFromPicker logic logic?
|
|
||||||
// We can call importFromPicker for each item.
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
|
|
||||||
items.forEach(item => {
|
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;
|
const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
|
||||||
|
|
||||||
google.script.run
|
google.script.run
|
||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user