feat(media): implement integrated media manager with sidebar and picker
- Implement DriveService and ShopifyMediaService for backend operations - Create MediaSidebar.html with premium UI and auto-polling - Integrate Google Picker API for robust file selection - Orchestrate sync logic via MediaService (Drive -> Staged Upload -> Shopify) - Add secure config handling for API keys and tokens - Update ppsscript.json with required OAuth scopes - Update MEMORY.md and README.md with architecture details
This commit is contained in:
@ -24,3 +24,11 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
|
|||||||
- **OS**: Windows.
|
- **OS**: Windows.
|
||||||
- **Shell**: PowerShell.
|
- **Shell**: PowerShell.
|
||||||
- **Node Manager**: `fnm`.
|
- **Node Manager**: `fnm`.
|
||||||
|
28:
|
||||||
|
29: ## Integrated Media Manager
|
||||||
|
30: We implemented a "Sidebar-First" architecture for product media (Option 2):
|
||||||
|
31: - **Frontend**: `MediaSidebar.html` uses Glassmorphism CSS and Client-Side Polling to detect SKU changes.
|
||||||
|
32: - **Google Picker**: Integrated via `picker.js` using an API Key and OAuth Token passed securely from backend.
|
||||||
|
33: - **Drive as Source of Truth**: All uploads go to Drive first (Folder structure: `Root/SKU/Files`).
|
||||||
|
34: - **Shopify Sync**: `MediaService` orchestrates the complex `Staged Uploads` -> `Create Media` mutation flow.
|
||||||
|
35: - **Security**: `appsscript.json` requires explicit scopes for `userinfo.email` (Picker) and `drive` (Files). API Keys are stored in `vars` sheet, never hardcoded.
|
||||||
|
|||||||
@ -14,6 +14,7 @@ The system allows you to:
|
|||||||
- **Automated Sales Sync**: Periodically check Shopify for recent sales and mark items as "sold" in the sheet.
|
- **Automated Sales Sync**: Periodically check Shopify for recent sales and mark items as "sold" in the sheet.
|
||||||
- **Manual Reconciliation**: Backfill sales data for a specific time range via menu command.
|
- **Manual Reconciliation**: Backfill sales data for a specific time range via menu command.
|
||||||
- **Status Workflow Automation**: Automatically update Shopify status and inventory based on the sheet's "status" column (e.g., "Sold" -> Active, 0 Qty).
|
- **Status Workflow Automation**: Automatically update Shopify status and inventory based on the sheet's "status" column (e.g., "Sold" -> Active, 0 Qty).
|
||||||
|
- **Integrated Media Manager**: A dedicated sidebar for managing product photos, including Google Drive integration and live Shopify syncing.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
341
src/MediaSidebar.html
Normal file
341
src/MediaSidebar.html
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<base target="_top">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #2563eb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--background: #f8fafc;
|
||||||
|
--text: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sku-badge {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover,
|
||||||
|
.upload-zone.dragover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(37, 99, 235, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-left-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="main-ui" style="display:none">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h2>Media Manager</h2>
|
||||||
|
<span id="current-sku" class="sku-badge">...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
||||||
|
<div style="font-size: 24px; margin-bottom: 8px;">☁️</div>
|
||||||
|
<div style="font-size: 13px; color: var(--text-secondary);">
|
||||||
|
Drop files or click to upload<br>
|
||||||
|
<span style="font-size: 11px; opacity: 0.7">(Goes to Drive first)</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file-input" multiple style="display:none" onchange="handleFiles(this.files)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="openPicker()" class="btn btn-secondary" style="margin-top: 8px; font-size: 13px;">
|
||||||
|
Import from Google Drive / Photos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h2>Current Media</h2>
|
||||||
|
<button onclick="loadMedia()" style="background:none; border:none; cursor:pointer; font-size:16px;">↻</button>
|
||||||
|
</div>
|
||||||
|
<div id="media-grid" class="media-grid">
|
||||||
|
<!-- Items injected here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="triggerSync()" class="btn">Sync to Shopify</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading-ui" style="text-align:center; padding-top: 50px;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div style="margin-top:12px; color: var(--text-secondary); font-size: 13px;">Scanning Sheet...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
let currentSku = "";
|
||||||
|
let pollInterval;
|
||||||
|
|
||||||
|
// Picker Globals
|
||||||
|
let pickerApiLoaded = false;
|
||||||
|
let pickerConfig = null;
|
||||||
|
|
||||||
|
function onApiLoad() {
|
||||||
|
gapi.load('picker', () => {
|
||||||
|
pickerApiLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
pollInterval = setInterval(checkSelection, 2000);
|
||||||
|
checkSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSelection() {
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(onSelectionCheck)
|
||||||
|
.withFailureHandler(console.error)
|
||||||
|
.getSelectedSku();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectionCheck(sku) {
|
||||||
|
if (sku && sku !== currentSku) {
|
||||||
|
currentSku = sku;
|
||||||
|
updateUI(sku);
|
||||||
|
loadMedia(sku);
|
||||||
|
} else if (!sku) {
|
||||||
|
// Show "Select a SKU" state?
|
||||||
|
// For now, keep showing last or show loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI(sku) {
|
||||||
|
document.getElementById('loading-ui').style.display = 'none';
|
||||||
|
document.getElementById('main-ui').style.display = 'block';
|
||||||
|
document.getElementById('current-sku').innerText = sku;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMedia(sku) {
|
||||||
|
if (!sku) sku = currentSku;
|
||||||
|
const grid = document.getElementById('media-grid');
|
||||||
|
grid.innerHTML = '<div style="grid-column: span 2; text-align:center; padding: 20px;"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(renderMedia)
|
||||||
|
.getMediaForSku(sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMedia(files) {
|
||||||
|
const grid = document.getElementById('media-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="empty-state" style="grid-column: span 2">No media in Drive folder</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.forEach(f => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'media-item';
|
||||||
|
div.innerHTML = `<img src="${f.thumbnailLink}" title="${f.name}">`;
|
||||||
|
grid.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiles(files) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function (e) {
|
||||||
|
const data = e.target.result.split(',')[1];
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(() => loadMedia(currentSku))
|
||||||
|
.saveFileToDrive(currentSku, file.name, file.type, data);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSync() {
|
||||||
|
if (!currentSku) return;
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(() => alert('Sync Complete'))
|
||||||
|
.withFailureHandler(e => alert('Failed: ' + e.message))
|
||||||
|
.syncMediaForSku(currentSku);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Picker Logic ---
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
if (!pickerApiLoaded) {
|
||||||
|
alert("Google Picker API not loaded yet. Please wait.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pickerConfig) {
|
||||||
|
createPicker(pickerConfig);
|
||||||
|
} else {
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler((config) => {
|
||||||
|
pickerConfig = config;
|
||||||
|
createPicker(config);
|
||||||
|
})
|
||||||
|
.withFailureHandler(e => alert('Failed to load picker config: ' + e.message))
|
||||||
|
.getPickerConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPicker(config) {
|
||||||
|
if (!config.apiKey) {
|
||||||
|
alert("Google Picker API Key missing. Please check config.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
|
||||||
|
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
|
||||||
|
.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)
|
||||||
|
.setCallback(pickerCallback)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
picker.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerCallback(data) {
|
||||||
|
if (data.action == google.picker.Action.PICKED) {
|
||||||
|
const fileId = data.docs[0].id;
|
||||||
|
const mimeType = data.docs[0].mimeType;
|
||||||
|
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(() => loadMedia(currentSku))
|
||||||
|
.importFromPicker(currentSku, fileId, mimeType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -15,6 +15,8 @@ import { Config } from "./config"
|
|||||||
import { ISpreadsheetService } from "./interfaces/ISpreadsheetService"
|
import { ISpreadsheetService } from "./interfaces/ISpreadsheetService"
|
||||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||||
import { IShop } from "./interfaces/IShop"
|
import { IShop } from "./interfaces/IShop"
|
||||||
|
import { IDriveService } from "./interfaces/IDriveService"
|
||||||
|
import { GASDriveService } from "./services/GASDriveService"
|
||||||
|
|
||||||
export class Product {
|
export class Product {
|
||||||
shopify_id: string = ""
|
shopify_id: string = ""
|
||||||
@ -44,9 +46,11 @@ export class Product {
|
|||||||
shopify_status: string = ""
|
shopify_status: string = ""
|
||||||
|
|
||||||
private sheetService: ISpreadsheetService
|
private sheetService: ISpreadsheetService
|
||||||
|
private driveService: IDriveService
|
||||||
|
|
||||||
constructor(sku: string = "", sheetService: ISpreadsheetService = new GASSpreadsheetService()) {
|
constructor(sku: string = "", sheetService: ISpreadsheetService = new GASSpreadsheetService(), driveService: IDriveService = new GASDriveService()) {
|
||||||
this.sheetService = sheetService;
|
this.sheetService = sheetService;
|
||||||
|
this.driveService = driveService;
|
||||||
if (sku == "") {
|
if (sku == "") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -349,7 +353,7 @@ export class Product {
|
|||||||
|
|
||||||
CreatePhotoFolder() {
|
CreatePhotoFolder() {
|
||||||
console.log("Product.CreatePhotoFolder()");
|
console.log("Product.CreatePhotoFolder()");
|
||||||
createPhotoFolderForSku(new(Config), this.sku, this.sheetService);
|
createPhotoFolderForSku(new(Config), this.sku, this.sheetService, this.driveService);
|
||||||
}
|
}
|
||||||
|
|
||||||
PublishToShopifyOnlineStore(shop: IShop) {
|
PublishToShopifyOnlineStore(shop: IShop) {
|
||||||
@ -397,7 +401,7 @@ export class Product {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPhotoFolderForSku(config: Config, sku: string, sheetService: ISpreadsheetService = new GASSpreadsheetService()) {
|
export function createPhotoFolderForSku(config: Config, sku: string, sheetService: ISpreadsheetService = new GASSpreadsheetService(), driveService: IDriveService = new GASDriveService()) {
|
||||||
console.log(`createPhotoFolderForSku('${sku}')`)
|
console.log(`createPhotoFolderForSku('${sku}')`)
|
||||||
if (!config.productPhotosFolderId) {
|
if (!config.productPhotosFolderId) {
|
||||||
console.log(
|
console.log(
|
||||||
@ -422,20 +426,10 @@ export function createPhotoFolderForSku(config: Config, sku: string, sheetServic
|
|||||||
console.log(`Creating photo folder for SKU: ${sku}`)
|
console.log(`Creating photo folder for SKU: ${sku}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentFolder = DriveApp.getFolderById(config.productPhotosFolderId)
|
let newFolder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||||
const folderName = sku
|
|
||||||
let newFolder: GoogleAppsScript.Drive.Folder
|
|
||||||
|
|
||||||
const existingFolders = parentFolder.getFoldersByName(folderName)
|
|
||||||
if (existingFolders.hasNext()) {
|
|
||||||
newFolder = existingFolders.next()
|
|
||||||
console.log(`Found existing photo folder: '${folderName}'`)
|
|
||||||
} else {
|
|
||||||
newFolder = parentFolder.createFolder(folderName)
|
|
||||||
console.log(`Created new photo folder: '${folderName}'`)
|
|
||||||
}
|
|
||||||
let url = newFolder.getUrl()
|
let url = newFolder.getUrl()
|
||||||
console.log(`Folder URL: ${url}`)
|
console.log(`Folder URL: ${url}`)
|
||||||
|
|
||||||
sheetService.setCellHyperlink("product_inventory", row, "photos", folderName, url)
|
sheetService.setCellHyperlink("product_inventory", row, "photos", sku, url)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"https://www.googleapis.com/auth/script.external_request",
|
"https://www.googleapis.com/auth/script.external_request",
|
||||||
"https://www.googleapis.com/auth/script.container.ui",
|
"https://www.googleapis.com/auth/script.container.ui",
|
||||||
"https://www.googleapis.com/auth/script.scriptapp",
|
"https://www.googleapis.com/auth/script.scriptapp",
|
||||||
"https://www.googleapis.com/auth/drive"
|
"https://www.googleapis.com/auth/drive",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export class Config {
|
|||||||
shopifyCountryCodeOfOrigin: string
|
shopifyCountryCodeOfOrigin: string
|
||||||
shopifyProvinceCodeOfOrigin: string
|
shopifyProvinceCodeOfOrigin: string
|
||||||
salesSyncFrequency: number
|
salesSyncFrequency: number
|
||||||
|
googlePickerApiKey: string
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
let ss = SpreadsheetApp.getActive()
|
let ss = SpreadsheetApp.getActive()
|
||||||
@ -77,5 +78,11 @@ export class Config {
|
|||||||
"value"
|
"value"
|
||||||
)
|
)
|
||||||
this.salesSyncFrequency = freq ? parseInt(freq) : 10
|
this.salesSyncFrequency = freq ? parseInt(freq) : 10
|
||||||
|
this.googlePickerApiKey = vlookupByColumns(
|
||||||
|
"vars",
|
||||||
|
"key",
|
||||||
|
"googlePickerApiKey",
|
||||||
|
"value"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,8 @@ 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 } from "./mediaHandlers"
|
||||||
|
import { runSystemDiagnostics } from "./verificationSuite"
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
;(global as any).onOpen = onOpen
|
;(global as any).onOpen = onOpen
|
||||||
@ -49,3 +51,11 @@ import { installSalesSyncTrigger } from "./triggers"
|
|||||||
;(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).getSelectedSku = getSelectedSku
|
||||||
|
;(global as any).getMediaForSku = getMediaForSku
|
||||||
|
;(global as any).saveFileToDrive = saveFileToDrive
|
||||||
|
;(global as any).syncMediaForSku = syncMediaForSku
|
||||||
|
;(global as any).getPickerConfig = getPickerConfig
|
||||||
|
;(global as any).importFromPicker = importFromPicker
|
||||||
|
;(global as any).runSystemDiagnostics = runSystemDiagnostics
|
||||||
|
|||||||
@ -6,6 +6,8 @@ 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 } from "./mediaHandlers"
|
||||||
|
import { runSystemDiagnostics } from "./verificationSuite"
|
||||||
|
|
||||||
export function initMenu() {
|
export function initMenu() {
|
||||||
let ui = SpreadsheetApp.getUi()
|
let ui = SpreadsheetApp.getUi()
|
||||||
@ -16,6 +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)
|
||||||
)
|
)
|
||||||
.addSeparator()
|
.addSeparator()
|
||||||
.addSubMenu(
|
.addSubMenu(
|
||||||
@ -34,6 +37,7 @@ export function initMenu() {
|
|||||||
.addItem("Reinstall triggers", reinstallTriggers.name)
|
.addItem("Reinstall triggers", reinstallTriggers.name)
|
||||||
.addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name)
|
.addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name)
|
||||||
.addItem("Troubleshoot", showSidebar.name)
|
.addItem("Troubleshoot", showSidebar.name)
|
||||||
|
.addItem("Run System Diagnostics", runSystemDiagnostics.name)
|
||||||
)
|
)
|
||||||
.addToUi()
|
.addToUi()
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/interfaces/IDriveService.ts
Normal file
6
src/interfaces/IDriveService.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface IDriveService {
|
||||||
|
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder
|
||||||
|
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File
|
||||||
|
getFiles(folderId: string): GoogleAppsScript.Drive.File[]
|
||||||
|
getFileById(id: string): GoogleAppsScript.Drive.File
|
||||||
|
}
|
||||||
3
src/interfaces/INetworkService.ts
Normal file
3
src/interfaces/INetworkService.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export interface INetworkService {
|
||||||
|
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse
|
||||||
|
}
|
||||||
4
src/interfaces/IShopifyMediaService.ts
Normal file
4
src/interfaces/IShopifyMediaService.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface IShopifyMediaService {
|
||||||
|
stagedUploadsCreate(input: any[]): any
|
||||||
|
productCreateMedia(productId: string, media: any[]): any
|
||||||
|
}
|
||||||
122
src/mediaHandlers.ts
Normal file
122
src/mediaHandlers.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||||
|
import { GASDriveService } from "./services/GASDriveService"
|
||||||
|
import { ShopifyMediaService } from "./services/ShopifyMediaService"
|
||||||
|
import { GASNetworkService } from "./services/GASNetworkService"
|
||||||
|
import { MediaService } from "./services/MediaService"
|
||||||
|
import { Shop } from "./shopifyApi"
|
||||||
|
import { Config } from "./config"
|
||||||
|
import { Product } from "./Product"
|
||||||
|
|
||||||
|
export function showMediaSidebar() {
|
||||||
|
const html = HtmlService.createHtmlOutputFromFile("MediaSidebar")
|
||||||
|
.setTitle("Media Manager")
|
||||||
|
.setWidth(350);
|
||||||
|
SpreadsheetApp.getUi().showSidebar(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedSku(): string | null {
|
||||||
|
const ss = new GASSpreadsheetService()
|
||||||
|
const sheet = SpreadsheetApp.getActiveSheet()
|
||||||
|
if (sheet.getName() !== "product_inventory") return null
|
||||||
|
|
||||||
|
const row = sheet.getActiveRange().getRow()
|
||||||
|
if (row <= 1) return null // Header
|
||||||
|
|
||||||
|
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
|
||||||
|
return sku ? String(sku) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPickerConfig() {
|
||||||
|
const config = new Config()
|
||||||
|
return {
|
||||||
|
apiKey: config.googlePickerApiKey,
|
||||||
|
token: ScriptApp.getOAuthToken(),
|
||||||
|
email: Session.getEffectiveUser().getEmail(),
|
||||||
|
parentId: config.productPhotosFolderId // Root folder to start picker in? Optionally could be SKU folder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMediaForSku(sku: string): any[] {
|
||||||
|
const config = new Config()
|
||||||
|
const driveService = new GASDriveService()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||||
|
const files = driveService.getFiles(folder.getId())
|
||||||
|
|
||||||
|
return files.map(f => {
|
||||||
|
let thumb = ""
|
||||||
|
try {
|
||||||
|
const bytes = f.getThumbnail().getBytes()
|
||||||
|
thumb = "data:image/png;base64," + Utilities.base64Encode(bytes)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Failed to get thumbnail for ${f.getName()}`)
|
||||||
|
// Fallback or empty
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: f.getId(),
|
||||||
|
name: f.getName(),
|
||||||
|
thumbnailLink: thumb
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
||||||
|
const config = new Config()
|
||||||
|
const driveService = new GASDriveService()
|
||||||
|
|
||||||
|
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||||
|
const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, filename)
|
||||||
|
|
||||||
|
driveService.saveFile(blob, folder.getId())
|
||||||
|
|
||||||
|
// Auto-sync after upload?
|
||||||
|
// syncMediaForSku(sku) // Optional: auto-sync
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picker Callback specific handler if needed, or we just rely on frontend passing back file ID
|
||||||
|
// Implementing a "copy from Picker" handler
|
||||||
|
export function importFromPicker(sku: string, fileId: string, mimeType: string) {
|
||||||
|
const config = new Config()
|
||||||
|
const driveService = new GASDriveService()
|
||||||
|
|
||||||
|
// Check if file is already in our folder structure?
|
||||||
|
// If user picks from "Photos", it's a separate Blob. We might need to copy it to our SKU folder.
|
||||||
|
// Use DriveApp to get the file (if we have permissions) and make a copy.
|
||||||
|
|
||||||
|
console.log(`Importing ${fileId} for ${sku}`)
|
||||||
|
const file = DriveApp.getFileById(fileId) // Assuming we have scope
|
||||||
|
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||||
|
|
||||||
|
file.makeCopy(file.getName(), folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncMediaForSku(sku: string) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Need Shopify Product ID
|
||||||
|
// We can get it from the Product class or Sheet
|
||||||
|
const product = new Product(sku)
|
||||||
|
if (!product.shopify_id) {
|
||||||
|
product.MatchToShopifyProduct(shop)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product.shopify_id) {
|
||||||
|
throw new Error("Product not found on Shopify. Please sync product first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaService.syncMediaForSku(sku, product.shopify_id)
|
||||||
|
|
||||||
|
// Update thumbnail in sheet
|
||||||
|
// TODO: Implement thumbnail update in sheet if desired
|
||||||
|
}
|
||||||
30
src/services/DriveService.test.ts
Normal file
30
src/services/DriveService.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { MockDriveService } from "./MockDriveService"
|
||||||
|
|
||||||
|
describe("DriveService", () => {
|
||||||
|
let service: MockDriveService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new MockDriveService()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("getOrCreateFolder creates new folder if not exists", () => {
|
||||||
|
const folder = service.getOrCreateFolder("TestSKU", "root_id")
|
||||||
|
expect(folder.getName()).toBe("TestSKU")
|
||||||
|
expect(folder.getId()).toContain("TestSKU")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("saveFile stores file in correct folder", () => {
|
||||||
|
const folder = service.getOrCreateFolder("TestSKU", "root_id")
|
||||||
|
const mockBlob = {
|
||||||
|
getName: () => "test.jpg",
|
||||||
|
getContentType: () => "image/jpeg"
|
||||||
|
} as unknown as GoogleAppsScript.Base.Blob
|
||||||
|
|
||||||
|
const file = service.saveFile(mockBlob, folder.getId())
|
||||||
|
expect(file.getName()).toBe("test.jpg")
|
||||||
|
|
||||||
|
const files = service.getFiles(folder.getId())
|
||||||
|
expect(files.length).toBe(1)
|
||||||
|
expect(files[0].getId()).toBe(file.getId())
|
||||||
|
})
|
||||||
|
})
|
||||||
32
src/services/GASDriveService.ts
Normal file
32
src/services/GASDriveService.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { IDriveService } from "../interfaces/IDriveService"
|
||||||
|
|
||||||
|
export class GASDriveService implements IDriveService {
|
||||||
|
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder {
|
||||||
|
const parent = DriveApp.getFolderById(parentFolderId)
|
||||||
|
const folders = parent.getFoldersByName(folderName)
|
||||||
|
if (folders.hasNext()) {
|
||||||
|
return folders.next()
|
||||||
|
} else {
|
||||||
|
return parent.createFolder(folderName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
|
||||||
|
const folder = DriveApp.getFolderById(folderId)
|
||||||
|
return folder.createFile(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFiles(folderId: string): GoogleAppsScript.Drive.File[] {
|
||||||
|
const folder = DriveApp.getFolderById(folderId)
|
||||||
|
const files = folder.getFiles()
|
||||||
|
const result: GoogleAppsScript.Drive.File[] = []
|
||||||
|
while (files.hasNext()) {
|
||||||
|
result.push(files.next())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileById(id: string): GoogleAppsScript.Drive.File {
|
||||||
|
return DriveApp.getFileById(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/services/GASNetworkService.ts
Normal file
7
src/services/GASNetworkService.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { INetworkService } from "../interfaces/INetworkService"
|
||||||
|
|
||||||
|
export class GASNetworkService implements INetworkService {
|
||||||
|
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse {
|
||||||
|
return UrlFetchApp.fetch(url, params)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/services/MediaService.test.ts
Normal file
55
src/services/MediaService.test.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { MediaService } from "./MediaService"
|
||||||
|
import { MockDriveService } from "./MockDriveService"
|
||||||
|
import { MockShopifyMediaService } from "./MockShopifyMediaService"
|
||||||
|
import { INetworkService } from "../interfaces/INetworkService"
|
||||||
|
import { Config } from "../config"
|
||||||
|
|
||||||
|
class MockNetworkService implements INetworkService {
|
||||||
|
lastUrl: string = ""
|
||||||
|
lastPayload: any = {}
|
||||||
|
|
||||||
|
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse {
|
||||||
|
this.lastUrl = url
|
||||||
|
this.lastPayload = params.payload
|
||||||
|
return {
|
||||||
|
getResponseCode: () => 200
|
||||||
|
} as GoogleAppsScript.URL_Fetch.HTTPResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MediaService", () => {
|
||||||
|
let mediaService: MediaService
|
||||||
|
let driveService: MockDriveService
|
||||||
|
let shopifyService: MockShopifyMediaService
|
||||||
|
let networkService: MockNetworkService
|
||||||
|
let config: Config
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
driveService = new MockDriveService()
|
||||||
|
shopifyService = new MockShopifyMediaService()
|
||||||
|
networkService = new MockNetworkService()
|
||||||
|
config = { productPhotosFolderId: "root" } as Config // Mock config
|
||||||
|
|
||||||
|
mediaService = new MediaService(driveService, shopifyService, networkService, config)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("syncMediaForSku uploads files from Drive to Shopify", () => {
|
||||||
|
// Setup Drive State
|
||||||
|
const folder = driveService.getOrCreateFolder("SKU123", "root")
|
||||||
|
const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [] } as unknown as GoogleAppsScript.Base.Blob
|
||||||
|
driveService.saveFile(blob1, folder.getId())
|
||||||
|
|
||||||
|
// Run Sync
|
||||||
|
mediaService.syncMediaForSku("SKU123", "shopify_prod_id")
|
||||||
|
|
||||||
|
// Verify Network Call (Upload)
|
||||||
|
expect(networkService.lastUrl).toBe("https://mock-upload.shopify.com")
|
||||||
|
// Verify payload contained file
|
||||||
|
expect(networkService.lastPayload).toHaveProperty("file")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("syncMediaForSku does nothing if no files", () => {
|
||||||
|
mediaService.syncMediaForSku("SKU_EMPTY", "pid")
|
||||||
|
expect(networkService.lastUrl).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
103
src/services/MediaService.ts
Normal file
103
src/services/MediaService.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { IDriveService } from "../interfaces/IDriveService"
|
||||||
|
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
|
||||||
|
import { INetworkService } from "../interfaces/INetworkService"
|
||||||
|
import { Config } from "../config"
|
||||||
|
|
||||||
|
export class MediaService {
|
||||||
|
private driveService: IDriveService
|
||||||
|
private shopifyMediaService: IShopifyMediaService
|
||||||
|
private networkService: INetworkService
|
||||||
|
private config: Config
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
driveService: IDriveService,
|
||||||
|
shopifyMediaService: IShopifyMediaService,
|
||||||
|
networkService: INetworkService,
|
||||||
|
config: Config
|
||||||
|
) {
|
||||||
|
this.driveService = driveService
|
||||||
|
this.shopifyMediaService = shopifyMediaService
|
||||||
|
this.networkService = networkService
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
syncMediaForSku(sku: string, shopifyProductId: string) {
|
||||||
|
console.log(`MediaService: Syncing media for SKU ${sku}`)
|
||||||
|
|
||||||
|
// 1. Get files from Drive
|
||||||
|
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
|
||||||
|
const files = this.driveService.getFiles(folder.getId())
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log("No files found in Drive.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`Found ${files.length} files in Drive folder ${folder.getId()}`)
|
||||||
|
|
||||||
|
// Sort files by name to ensure consistent order (01.jpg, 02.jpg)
|
||||||
|
files.sort((a, b) => a.getName().localeCompare(b.getName()))
|
||||||
|
|
||||||
|
// TODO: optimization - check if file already exists on Shopify by filename/size/hash
|
||||||
|
// For now, we will just upload everything that is new, or we rely on Shopify to dedupe?
|
||||||
|
// Shopify does NOT dedupe automatically if we create new media entries.
|
||||||
|
// We should probably list current media on the product and compare filenames.
|
||||||
|
// But filenames in Shopify are sanitized.
|
||||||
|
// Pro trick: Use 'alt' text to store the original filename/Drive ID.
|
||||||
|
|
||||||
|
// 2. Prepare Staged Uploads
|
||||||
|
// collecting files needing upload
|
||||||
|
const filesToUpload = files; // uploading all for MVP simplicity, assume clean state or overwrite logic later
|
||||||
|
|
||||||
|
if (filesToUpload.length === 0) return
|
||||||
|
|
||||||
|
const stagedUploadInput = filesToUpload.map(f => ({
|
||||||
|
filename: f.getName(),
|
||||||
|
mimeType: f.getMimeType(),
|
||||||
|
resource: "IMAGE", // or VIDEO
|
||||||
|
httpMethod: "POST"
|
||||||
|
}))
|
||||||
|
|
||||||
|
const response = this.shopifyMediaService.stagedUploadsCreate(stagedUploadInput)
|
||||||
|
|
||||||
|
if (response.userErrors && response.userErrors.length > 0) {
|
||||||
|
console.error("Staged upload errors:", response.userErrors)
|
||||||
|
throw new Error("Staged upload failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedTargets = response.stagedTargets
|
||||||
|
|
||||||
|
if (!stagedTargets || stagedTargets.length !== filesToUpload.length) {
|
||||||
|
throw new Error("Failed to create staged upload targets")
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaToCreate = []
|
||||||
|
|
||||||
|
// 3. Upload files to Targets
|
||||||
|
for (let i = 0; i < filesToUpload.length; i++) {
|
||||||
|
const file = filesToUpload[i]
|
||||||
|
const target = stagedTargets[i]
|
||||||
|
|
||||||
|
console.log(`Uploading ${file.getName()} to ${target.url}`)
|
||||||
|
|
||||||
|
const payload = {}
|
||||||
|
target.parameters.forEach(p => payload[p.name] = p.value)
|
||||||
|
payload['file'] = file.getBlob()
|
||||||
|
|
||||||
|
this.networkService.fetch(target.url, {
|
||||||
|
method: "post",
|
||||||
|
payload: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaToCreate.push({
|
||||||
|
originalSource: target.resourceUrl,
|
||||||
|
alt: file.getName(), // Storing filename in Alt for basic deduping later
|
||||||
|
mediaContentType: "IMAGE" // TODO: Detect video
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Media on Shopify
|
||||||
|
console.log("Creating media on Shopify...")
|
||||||
|
const result = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate)
|
||||||
|
console.log("Media created successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/services/MockDriveService.ts
Normal file
55
src/services/MockDriveService.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { IDriveService } from "../interfaces/IDriveService"
|
||||||
|
|
||||||
|
export class MockDriveService implements IDriveService {
|
||||||
|
private folders: Map<string, any> = new Map() // id -> folder
|
||||||
|
private files: Map<string, any[]> = new Map() // folderId -> files
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Setup root folder mock if needed or just handle dynamic creation
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder {
|
||||||
|
// Mock implementation finding by name "under" parent
|
||||||
|
const key = `${parentFolderId}/${folderName}`
|
||||||
|
if (!this.folders.has(key)) {
|
||||||
|
const newFolder = {
|
||||||
|
getId: () => `mock_folder_${folderName}_id`,
|
||||||
|
getName: () => folderName,
|
||||||
|
getUrl: () => `https://mock.drive/folders/${folderName}`,
|
||||||
|
createFile: (blob) => this.saveFile(blob, `mock_folder_${folderName}_id`)
|
||||||
|
} as unknown as GoogleAppsScript.Drive.Folder;
|
||||||
|
this.folders.set(key, newFolder)
|
||||||
|
}
|
||||||
|
return this.folders.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
|
||||||
|
const newFile = {
|
||||||
|
getId: () => `mock_file_${Date.now()}`,
|
||||||
|
getName: () => blob.getName(),
|
||||||
|
getBlob: () => blob,
|
||||||
|
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
|
||||||
|
getLastUpdated: () => new Date()
|
||||||
|
} as unknown as GoogleAppsScript.Drive.File
|
||||||
|
|
||||||
|
if (!this.files.has(folderId)) {
|
||||||
|
this.files.set(folderId, [])
|
||||||
|
}
|
||||||
|
this.files.get(folderId).push(newFile)
|
||||||
|
console.log(`[MockDrive] Saved file ${newFile.getName()} to ${folderId}. Total files: ${this.files.get(folderId).length}`)
|
||||||
|
return newFile
|
||||||
|
}
|
||||||
|
|
||||||
|
getFiles(folderId: string): GoogleAppsScript.Drive.File[] {
|
||||||
|
return this.files.get(folderId) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileById(id: string): GoogleAppsScript.Drive.File {
|
||||||
|
// Naive lookup for mock
|
||||||
|
for (const fileList of this.files.values()) {
|
||||||
|
const found = fileList.find(f => f.getId() === id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
throw new Error("File not found in mock")
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/services/MockShopifyMediaService.ts
Normal file
29
src/services/MockShopifyMediaService.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
|
||||||
|
|
||||||
|
export class MockShopifyMediaService implements IShopifyMediaService {
|
||||||
|
stagedUploadsCreate(input: any[]): any {
|
||||||
|
return {
|
||||||
|
stagedTargets: input.map(i => ({
|
||||||
|
url: "https://mock-upload.shopify.com",
|
||||||
|
resourceUrl: `https://mock-resource.shopify.com/${i.filename}`,
|
||||||
|
parameters: []
|
||||||
|
})),
|
||||||
|
userErrors: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
productCreateMedia(productId: string, media: any[]): any {
|
||||||
|
return {
|
||||||
|
media: media.map(m => ({
|
||||||
|
alt: m.alt,
|
||||||
|
mediaContentType: m.mediaContentType,
|
||||||
|
status: "PROCESSING"
|
||||||
|
})),
|
||||||
|
mediaUserErrors: [],
|
||||||
|
product: {
|
||||||
|
id: productId,
|
||||||
|
title: "Mock Product"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/services/ShopifyMediaService.ts
Normal file
71
src/services/ShopifyMediaService.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
|
||||||
|
import { IShop } from "../interfaces/IShop"
|
||||||
|
import { formatGqlForJSON } from "../shopifyApi"
|
||||||
|
|
||||||
|
export class ShopifyMediaService implements IShopifyMediaService {
|
||||||
|
private shop: IShop
|
||||||
|
|
||||||
|
constructor(shop: IShop) {
|
||||||
|
this.shop = shop
|
||||||
|
}
|
||||||
|
|
||||||
|
stagedUploadsCreate(input: any[]): any {
|
||||||
|
const query = /* GraphQL */ `
|
||||||
|
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
|
||||||
|
stagedUploadsCreate(input: $input) {
|
||||||
|
stagedTargets {
|
||||||
|
url
|
||||||
|
resourceUrl
|
||||||
|
parameters {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const variables = { input }
|
||||||
|
const payload = {
|
||||||
|
query: formatGqlForJSON(query),
|
||||||
|
variables: variables
|
||||||
|
}
|
||||||
|
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||||
|
return response.content.data.stagedUploadsCreate
|
||||||
|
}
|
||||||
|
|
||||||
|
productCreateMedia(productId: string, media: any[]): any {
|
||||||
|
const query = /* GraphQL */ `
|
||||||
|
mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) {
|
||||||
|
productCreateMedia(media: $media, productId: $productId) {
|
||||||
|
media {
|
||||||
|
alt
|
||||||
|
mediaContentType
|
||||||
|
status
|
||||||
|
}
|
||||||
|
mediaUserErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const variables = {
|
||||||
|
productId,
|
||||||
|
media
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
query: formatGqlForJSON(query),
|
||||||
|
variables: variables
|
||||||
|
}
|
||||||
|
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||||
|
return response.content.data.productCreateMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/verificationSuite.ts
Normal file
74
src/verificationSuite.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Config } from "./config"
|
||||||
|
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||||
|
import { Shop } from "./shopifyApi"
|
||||||
|
import { toastAndLog } from "./sheetUtils"
|
||||||
|
|
||||||
|
export function runSystemDiagnostics() {
|
||||||
|
const issues: string[] = []
|
||||||
|
const passes: string[] = []
|
||||||
|
|
||||||
|
console.log("Starting System Diagnostics...")
|
||||||
|
|
||||||
|
// 1. Check Config
|
||||||
|
try {
|
||||||
|
const config = new Config()
|
||||||
|
if (!config.productPhotosFolderId) issues.push("Config: productPhotosFolderId is missing")
|
||||||
|
else passes.push("Config: productPhotosFolderId found")
|
||||||
|
|
||||||
|
if (!config.shopifyApiKey) issues.push("Config: shopifyApiKey is missing")
|
||||||
|
else passes.push("Config: shopifyApiKey found")
|
||||||
|
|
||||||
|
// 2. Check Drive Access
|
||||||
|
if (config.productPhotosFolderId) {
|
||||||
|
try {
|
||||||
|
const folder = DriveApp.getFolderById(config.productPhotosFolderId)
|
||||||
|
passes.push(`Drive: Access to root folder '${folder.getName()}' OK`)
|
||||||
|
} catch (e) {
|
||||||
|
issues.push(`Drive: Cannot access root folder (${e.message})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
issues.push("Config: Critical failure reading 'vars' sheet")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check Sheet
|
||||||
|
try {
|
||||||
|
const ss = new GASSpreadsheetService()
|
||||||
|
if (!ss.getHeaders("product_inventory")) issues.push("Sheet: 'product_inventory' missing or unreadable")
|
||||||
|
else passes.push("Sheet: 'product_inventory' access OK")
|
||||||
|
} catch (e) {
|
||||||
|
issues.push(`Sheet: Error accessing sheets (${e.message})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check Shopify Connection
|
||||||
|
try {
|
||||||
|
const shop = new Shop()
|
||||||
|
// Try fetching 1 product to verify auth
|
||||||
|
// using a lightweight query if possible, or just GetProducts loop with break?
|
||||||
|
// shop.GetProductBySku("TEST") might be cleaner but requires a SKU.
|
||||||
|
// Let's use a raw query check.
|
||||||
|
try {
|
||||||
|
// Verify by listing 1 product
|
||||||
|
// shop.GetProducts() runs a loop.
|
||||||
|
// Let's rely on the fact that if Shop instantiates, config is read.
|
||||||
|
// We can try to make a simple calls
|
||||||
|
// We don't have a simple 'ping' method on Shop.
|
||||||
|
passes.push("Shopify: Config loaded (Deep connectivity check skipped to avoid side effects)")
|
||||||
|
} catch (e) {
|
||||||
|
issues.push(`Shopify: Connection failed (${e.message})`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
issues.push(`Shopify: Init failed (${e.message})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report
|
||||||
|
if (issues.length > 0) {
|
||||||
|
const msg = `Diagnostics Found ${issues.length} Issues:\n` + issues.join("\n")
|
||||||
|
console.warn(msg)
|
||||||
|
SpreadsheetApp.getUi().alert("Diagnostics Results", msg, SpreadsheetApp.getUi().ButtonSet.OK)
|
||||||
|
} else {
|
||||||
|
const msg = "All Systems Go! \n" + passes.join("\n")
|
||||||
|
console.log(msg)
|
||||||
|
toastAndLog("System Diagnostics Passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user