feat: add troubleshooting side panel and advanced queue controls

- Implemented a global toggle to enable/disable background queue processing.
- Added a Side Panel (Sidebar.html) to view pending edits.
- Added per-item controls: 'Delete' to remove from queue, 'Push' to force update.
- Updated 'onEditQueue.ts' with robust error handling for batch processing.
- Updated documentation (README, ARCHITECTURE) to reflect new features.
- Fixed 'clasp' deployment issues by cleaning up manifest management.
This commit is contained in:
2025-12-24 21:14:19 -07:00
parent ca0ba1dc94
commit 418123d742
13 changed files with 459 additions and 5 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
dist/**
desktop.ini
.continue/**
.clasp.json

View File

@ -9,6 +9,7 @@ The system allows you to:
- Automatically upload product photos from Google Drive to Shopify.
- specific triggers (`onEdit`, `onOpen`) to sync changes to Shopify in real-time or on-demand.
- Handle rate limiting and concurrency using a custom queue system.
- Monitor and troubleshoot background processes via a custom side panel.
## Prerequisites

View File

@ -66,3 +66,17 @@ Triggers are managed programmatically via `src/triggers.ts`. Running `reinstallT
- `onEdit` -> `matchProductToShopifyOnEditHandler`
- `onEdit` -> `onEditQueue`
- `TimeBased (1 min)` -> `processBatchedEdits`
### 5. Troubleshooting Panel (`src/sidebar.ts`, `src/Sidebar.html`)
A dedicated side panel provides visibility into the background queue system.
- **Backend (`src/sidebar.ts`)**:
- `getQueueStatus()`: Returns the current state of the queue and global toggle.
- `setQueueEnabled()`: Toggles the global `queueEnabled` script property.
- `deleteEdit()` / `pushEdit()`: Manages specific items in the queue with safety checks.
- **Frontend (`src/Sidebar.html`)**:
- Displays pending edits with timestamps.
- Provides controls to globally enable/disable processing.
- Allows manual intervention (delete/push) for individual items.

View File

@ -21,10 +21,23 @@
3. **Clasp Login**
Authenticate with Google to allow pushing code.
```bash
```powershell
clasp login
```
4. **Initialize Project**
You must either create a new Apps Script project or clone an existing one.
- **Option A: New Project**
```bash
clasp create --type sheets --title "Product Inventory" --rootDir ./dist
```
- **Option B: Existing Project**
```bash
clasp clone <scriptId> --rootDir ./dist
```
*Note: The `--rootDir ./dist` flag is crucial so that clasp knows where to look for files.*
## Google Sheets Configuration
1. **Create a Google Sheet** (or use existing).

119
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.1",
"devDependencies": {
"@types/google-apps-script": "^1.0.85",
"copy-webpack-plugin": "^13.0.1",
"gas-webpack-plugin": "^2.6.0",
"graphql-tag": "^2.12.6",
"shopify-admin-api-typings": "github:beepmill/shopify-admin-api-typings",
@ -609,6 +610,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/copy-webpack-plugin": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz",
"integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"glob-parent": "^6.0.1",
"normalize-path": "^3.0.0",
"schema-utils": "^4.2.0",
"serialize-javascript": "^6.0.2",
"tinyglobby": "^0.2.12"
},
"engines": {
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -898,6 +923,19 @@
"webpack-sources": "^3.1.1"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
@ -1008,6 +1046,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -1200,6 +1261,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@ -1606,6 +1677,54 @@
}
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@ -7,10 +7,11 @@
],
"scripts": {
"build": "webpack --mode production",
"deploy": "clasp push -P ./dist"
"deploy": "clasp push"
},
"devDependencies": {
"@types/google-apps-script": "^1.0.85",
"copy-webpack-plugin": "^13.0.1",
"gas-webpack-plugin": "^2.6.0",
"graphql-tag": "^2.12.6",
"shopify-admin-api-typings": "github:beepmill/shopify-admin-api-typings",

181
src/Sidebar.html Normal file
View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Roboto', sans-serif;
padding: 12px;
color: #333;
}
h2 {
font-size: 18px;
margin-bottom: 8px;
color: #202124;
}
.section {
margin-bottom: 24px;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
background-color: #fff;
}
.stat-count {
font-size: 32px;
font-weight: 500;
color: #1a73e8;
}
.stat-label {
font-size: 14px;
color: #5f6368;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 13px;
}
th {
text-align: left;
color: #5f6368;
font-weight: 500;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
}
td {
padding: 8px 0;
border-bottom: 1px solid #f1f3f4;
}
button {
background-color: #1a73e8;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 500;
width: 100%;
}
button:hover {
background-color: #1557b0;
}
.loading {
color: #5f6368;
font-style: italic;
margin-top: 8px;
text-align: center;
}
</style>
</head>
<body>
<h2>System Status</h2>
<div class="section">
<label style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<span>Enable Automatic Processing</span>
<input type="checkbox" id="queue-toggle" onchange="toggleQueue()">
</label>
</div>
<div class="section">
<div class="stat-count" id="queue-count">-</div>
<div class="stat-label">Pending Edits</div>
<div id="loading" class="loading" style="display: none;">Loading...</div>
<table id="queue-table">
<thead>
<tr>
<th>SKU</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="queue-body">
</tbody>
</table>
</div>
<button onclick="refreshData()">Refresh</button>
<script>
function refreshData() {
showLoading('Loading...');
google.script.run
.withSuccessHandler(updateUI)
.withFailureHandler(showError)
.getQueueStatus();
}
function toggleQueue() {
const enabled = document.getElementById('queue-toggle').checked;
showLoading('Updating...');
google.script.run
.withSuccessHandler(() => refreshData())
.withFailureHandler(showError)
.setQueueEnabled(enabled);
}
function deleteItem(sku) {
if(!confirm('Are you sure you want to remove ' + sku + ' from the queue?')) return;
showLoading('Deleting...');
google.script.run
.withSuccessHandler(() => refreshData())
.withFailureHandler(showError)
.deleteEdit(sku);
}
function pushItem(sku) {
showLoading('Pushing ' + sku + '...');
google.script.run
.withSuccessHandler(() => refreshData())
.withFailureHandler(showError)
.pushEdit(sku);
}
function updateUI(status) {
hideLoading();
document.getElementById('queue-toggle').checked = status.enabled;
const items = status.items || [];
document.getElementById('queue-count').innerText = items.length;
const tbody = document.getElementById('queue-body');
tbody.innerHTML = '';
items.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${item.sku}</td>
<td>${item.time}</td>
<td>
<button style="padding: 4px 8px; font-size: 11px; width: auto; background-color: #d93025; margin-right: 4px;" onclick="deleteItem('${item.sku}')">Del</button>
<button style="padding: 4px 8px; font-size: 11px; width: auto; background-color: #1a73e8;" onclick="pushItem('${item.sku}')">Push</button>
</td>
`;
tbody.appendChild(row);
});
}
function showLoading(msg) {
const el = document.getElementById('loading');
el.innerText = msg;
el.style.display = 'block';
}
function hideLoading() {
document.getElementById('loading').style.display = 'none';
}
function showError(error) {
document.getElementById('loading').innerText = 'Error: ' + error.message;
document.getElementById('loading').style.display = 'block';
}
// Load on start
refreshData();
</script>
</body>
</html>

12
src/appsscript.json Normal file
View File

@ -0,0 +1,12 @@
{
"timeZone": "America/Denver",
"dependencies": {
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/script.container.ui"
]
}

View File

@ -10,6 +10,7 @@ import {
updateShopifyProductHandler,
reauthorizeScript,
} from "./initMenu"
import { createMissingPhotoFolders } from "./createMissingPhotoFolders"
import { reinstallTriggers } from "./triggers"
import { newSkuHandler } from "./newSku"
@ -19,6 +20,7 @@ import {
processBatchedEdits
} from "./onEditQueue"
import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
// prettier-ignore
;(global as any).onOpen = onOpen
@ -36,3 +38,8 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
;(global as any).newSkuHandler = newSkuHandler
;(global as any).fillProductFromTemplate = fillProductFromTemplate
;(global as any).createMissingPhotoFolders = createMissingPhotoFolders
;(global as any).showSidebar = showSidebar
;(global as any).getQueueStatus = getQueueStatus
;(global as any).setQueueEnabled = setQueueEnabled
;(global as any).deleteEdit = deleteEdit
;(global as any).pushEdit = pushEdit

View File

@ -4,6 +4,7 @@ import { createMissingPhotoFolders } from "./createMissingPhotoFolders"
import { matchProductToShopify, updateProductToShopify } from "./match"
import { reinstallTriggers } from "./triggers"
import { toastAndLog } from "./sheetUtils"
import { showSidebar } from "./sidebar"
export function initMenu() {
let ui = SpreadsheetApp.getUi()
@ -29,6 +30,7 @@ export function initMenu() {
.createMenu("Utilities...")
.addItem("Reauthorize script", reauthorizeScript.name)
.addItem("Reinstall triggers", reinstallTriggers.name)
.addItem("Troubleshoot", showSidebar.name)
)
.addToUi()
}

View File

@ -8,6 +8,7 @@ const LOCK_TIMEOUT_MS = 10 * 1000 // 10 seconds for lock acquisition
const CACHE_KEY_EDITS = "pendingEdits"
const CACHE_KEY_LAST_EDIT_TIME = "lastEditTime"
const SCRIPT_PROPERTY_TRIGGER_SCHEDULED = "batchTriggerScheduled"
export const SCRIPT_PROPERTY_QUEUE_ENABLED = "queueEnabled"
export function onEditQueue(e) {
const sheet = e.source.getActiveSheet()
@ -79,6 +80,13 @@ export function processBatchedEdits() {
}
console.log(`Total SKUs in queue: ${pendingEdits.length}`)
const queueEnabled = scriptProperties.getProperty(SCRIPT_PROPERTY_QUEUE_ENABLED) !== "false"
if (!queueEnabled) {
console.log("Queue disabled, skipping processing.")
return
}
const now = Date.now()
const toProcess = pendingEdits.filter(
(edit) => now - edit.timestamp > BATCH_INTERVAL_MS
@ -96,7 +104,11 @@ export function processBatchedEdits() {
return
}
let p = new Product(edit.sku)
p.UpdateShopifyProduct(shop)
try {
p.UpdateShopifyProduct(shop)
} catch (err) {
console.error(`Failed to update SKU ${edit.sku}: ${err.message}`)
}
})
pendingEdits = pendingEdits.filter(

83
src/sidebar.ts Normal file
View File

@ -0,0 +1,83 @@
import { SCRIPT_PROPERTY_QUEUE_ENABLED } from "./onEditQueue";
import { Product } from "./Product";
import { Shop } from "./shopifyApi";
const CACHE_KEY_EDITS = "pendingEdits"
const LOCK_TIMEOUT_MS = 10000;
export function showSidebar() {
const html = HtmlService.createHtmlOutputFromFile('Sidebar')
.setTitle('Troubleshooting')
.setWidth(300);
SpreadsheetApp.getUi().showSidebar(html);
}
export function getQueueStatus() {
const scriptProperties = PropertiesService.getScriptProperties();
let pendingEdits = [];
try {
const pendingEditsStr = scriptProperties.getProperty(CACHE_KEY_EDITS);
pendingEdits = pendingEditsStr ? JSON.parse(pendingEditsStr) : [];
} catch (e) {
console.log("Cache corruption: " + e.message);
}
const queueEnabled = scriptProperties.getProperty(SCRIPT_PROPERTY_QUEUE_ENABLED) !== "false";
// Convert timestamps to readable strings
return {
enabled: queueEnabled,
items: pendingEdits.map(edit => ({
sku: edit.sku,
time: new Date(edit.timestamp).toLocaleString()
}))
};
}
export function setQueueEnabled(enabled: boolean) {
const scriptProperties = PropertiesService.getScriptProperties();
scriptProperties.setProperty(SCRIPT_PROPERTY_QUEUE_ENABLED, enabled.toString());
}
export function deleteEdit(sku: string) {
const lock = LockService.getScriptLock();
try {
lock.waitLock(LOCK_TIMEOUT_MS);
const scriptProperties = PropertiesService.getScriptProperties();
const pendingEditsStr = scriptProperties.getProperty(CACHE_KEY_EDITS);
let pendingEdits = pendingEditsStr ? JSON.parse(pendingEditsStr) : [];
pendingEdits = pendingEdits.filter(edit => edit.sku !== sku);
scriptProperties.setProperty(CACHE_KEY_EDITS, JSON.stringify(pendingEdits));
} finally {
lock.releaseLock();
}
}
export function pushEdit(sku: string) {
const lock = LockService.getScriptLock();
try {
lock.waitLock(LOCK_TIMEOUT_MS);
const scriptProperties = PropertiesService.getScriptProperties();
// 1. Process the edit safely (try/catch already handled in Product methods or here)
// We do this BEFORE removing from queue to ensure data safety if it fails.
let shop = new Shop();
let p = new Product(sku);
p.UpdateShopifyProduct(shop);
// 2. If successful, remove from queue
const pendingEditsStr = scriptProperties.getProperty(CACHE_KEY_EDITS);
let pendingEdits = pendingEditsStr ? JSON.parse(pendingEditsStr) : [];
pendingEdits = pendingEdits.filter(edit => edit.sku !== sku);
scriptProperties.setProperty(CACHE_KEY_EDITS, JSON.stringify(pendingEdits));
} catch (e) {
throw new Error(`Failed to push edit for SKU ${sku}: ${e.message}`);
} finally {
// Ensure lock is always released
lock.releaseLock();
}
}

View File

@ -2,6 +2,8 @@ const path = require('path');
const GasPlugin = require("gas-webpack-plugin");
const TerserPlugin = require('terser-webpack-plugin');
const CopyPlugin = require("copy-webpack-plugin");
module.exports = {
entry: './src/global.ts',
optimization: {
@ -35,6 +37,12 @@ module.exports = {
new GasPlugin({
comment: true,
autoGlobalExportsFiles: ['**/*.ts'],
})
}),
new CopyPlugin({
patterns: [
{ from: "src/*.html", to: "[name][ext]" },
{ from: "src/appsscript.json", to: "[name][ext]" },
],
}),
]
};