Compare commits
2 Commits
237f57cf36
...
418123d742
| Author | SHA1 | Date | |
|---|---|---|---|
| 418123d742 | |||
| ca0ba1dc94 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
||||
dist/**
|
||||
desktop.ini
|
||||
.continue/**
|
||||
.clasp.json
|
||||
8
GEMINI.md
Normal file
8
GEMINI.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Instructions for Gemini
|
||||
|
||||
This file serves as a guide for future sessions working on this codebase.
|
||||
|
||||
1. **Read Memory First**: Always read `MEMORY.md` at the start of a session to understand the project context and our working agreements.
|
||||
2. **Update Memory**: If we make significant architectural decisions or change our working patterns, update `MEMORY.md` to reflect this.
|
||||
3. **Check Documentation**: `README.md`, `docs/ARCHITECTURE.md`, and `docs/SETUP.md` are the sources of truth for the system. Keep them updated as code changes.
|
||||
4. **Task Tracking**: Use the `task.md` artifact to track progress on multi-step tasks.
|
||||
25
MEMORY.md
Normal file
25
MEMORY.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Project Memory
|
||||
|
||||
## Project Context
|
||||
This project (`product_inventory`) integrates Google Sheets with Shopify. It serves as a master inventory management tool where users edit product data in a Google Sheet, and scripts automatically sync those changes to Shopify.
|
||||
|
||||
**Critical Components:**
|
||||
- **Google Apps Script**: Runs the logic.
|
||||
- **"vars" Sheet**: Holds all configuration and API keys. NEVER hardcode credentials.
|
||||
- **Shopify Admin API**: Used for syncing. REST for Orders, GraphQL for Products.
|
||||
|
||||
## Work Patterns & Agreements
|
||||
1. **Documentation First**: Before implementing complex features, we update the plan and often the documentation (README/ARCHITECTURE).
|
||||
2. **Safety First**: We use `SafeToAutoRun: false` for commands that deploy or modify external state until verified.
|
||||
3. **Strict Typing**: We use TypeScript. No `any` unless absolutely necessary (and even then, we try to avoid it).
|
||||
4. **Artifact Usage**: We use `task.md`, `implementation_plan.md`, and `walkthrough.md` to track state.
|
||||
|
||||
## Key Technical Decisions
|
||||
- **Queue System**: We implemented `onEditQueue.ts` to batch edits. This prevents hitting Shopify API rate limits and Google Apps Script execution limits during rapid manual edits.
|
||||
- **Hybrid API**: We use REST for retrieving Orders (legacy/easier for flat data) and GraphQL for Products (more efficient/flexible).
|
||||
- **Global Exports**: Functions in `src/global.ts` are explicitly exposed to be callable by Apps Script triggers.
|
||||
|
||||
## User Preferences
|
||||
- **OS**: Windows.
|
||||
- **Shell**: PowerShell.
|
||||
- **Node Manager**: `fnm`.
|
||||
47
README.md
Normal file
47
README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Product Inventory Management
|
||||
|
||||
This project integrates Google Sheets with Shopify to manage product inventory, photos, and metadata. It uses Google Apps Script to synchronize data between a "master" Google Sheet and your Shopify store.
|
||||
|
||||
## Overview
|
||||
|
||||
The system allows you to:
|
||||
- Manage product details (SKUs, titles, descriptions) in a Google Sheet.
|
||||
- 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
|
||||
|
||||
- **Node.js**: managed via `fnm` (Fast Node Manager)
|
||||
- **Google Clasp**: for pushing code to Apps Script
|
||||
- **Google Cloud Project**: tied to the Apps Script container
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Build Project**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. **Deploy to Apps Script**
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/`: Source code (TypeScript)
|
||||
- `src/config.ts`: Configuration loading from Sheets
|
||||
- `src/global.ts`: Entry points for Apps Script
|
||||
- `src/shopifyApi.ts`: Shopify Admin API wrapper
|
||||
- `src/onEditQueue.ts`: Concurrency management
|
||||
|
||||
For more details, see:
|
||||
- [Architecture Guide](docs/ARCHITECTURE.md)
|
||||
- [Setup Guide](docs/SETUP.md)
|
||||
82
docs/ARCHITECTURE.md
Normal file
82
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,82 @@
|
||||
# Architecture Documentation
|
||||
|
||||
## System Overview
|
||||
|
||||
This project serves as a bridge between Google Sheets and Shopify. It enables a two-way sync (primarily Sheets to Shopify for products) and allows managing inventory directly from a spreadsheet.
|
||||
|
||||
### Core Flows
|
||||
|
||||
1. **Product Updates**:
|
||||
- User edits a cell in the "product_inventory" sheet.
|
||||
- `onEditQueue` trigger fires, capturing the SKU and timestamp.
|
||||
- Edits are batched in `PropertiesService` (script properties).
|
||||
- A time-based trigger runs `processBatchedEdits` every minute.
|
||||
- The processing function locks the script, reads the queue, and pushes changes to Shopify via the Admin API.
|
||||
|
||||
2. **Order Sync**:
|
||||
- Users can run menu commands to fetch orders from Shopify.
|
||||
- The `Shop` class fetches orders via the REST API, handling pagination.
|
||||
- Data is populated into specific sheets (`_orders`, `_line_items`, `_customer`, etc.).
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. Queue System (`src/onEditQueue.ts`)
|
||||
|
||||
To avoid hitting Shopify API rate limits and Google Apps Script execution time limits, edits are not processed immediately.
|
||||
|
||||
- **`onEditQueue(e)`**:
|
||||
- Triggered on every cell edit.
|
||||
- Checks if the edit is valid (correct sheet, valid SKU).
|
||||
- Acquires a `DocumentLock`.
|
||||
- Updates a JSON list in `ScriptProperties` (`pendingEdits`).
|
||||
- Debounces edits (updates timestamp if SKU is already pending).
|
||||
|
||||
- **`processBatchedEdits()`**:
|
||||
- Run via time-based trigger (every 1 minute).
|
||||
- Acquires a `ScriptLock`.
|
||||
- Reads `pendingEdits`.
|
||||
- Filters for edits older than `BATCH_INTERVAL_MS` (30s) to allow for multiple quick edits to the same SKU.
|
||||
- Iterates through valid edits and calls `Product.UpdateShopifyProduct`.
|
||||
|
||||
### 2. Shopify Integration (`src/shopifyApi.ts`)
|
||||
|
||||
The project uses a hybrid approach for the Shopify Admin API:
|
||||
|
||||
- **REST API**: Used primarily for fetching Orders (legacy support).
|
||||
- **GraphQL API**: Used for fetching and updating Products and Inventory.
|
||||
|
||||
The `Shop` class handles authentication using credentials stored in the "vars" sheet.
|
||||
|
||||
### 3. Configuration (`src/config.ts`)
|
||||
|
||||
Configuration, including API keys, is stored in a dedicated Google Sheet named "vars". The `Config` class reads these values at runtime using a `vlookup` style helper.
|
||||
|
||||
**Required "vars" columns:**
|
||||
- `key`: The name of the configuration variable.
|
||||
- `value`: The actual value.
|
||||
|
||||
### 4. Global Entry Points (`src/global.ts`)
|
||||
|
||||
Since Apps Script functions must be top-level to be triggered or attached to buttons, `src/global.ts` explicitly exposes necessary functions from the modules to the global scope.
|
||||
|
||||
## Triggers
|
||||
|
||||
Triggers are managed programmatically via `src/triggers.ts`. Running `reinstallTriggers` will wipe existing project triggers and set up the standard set:
|
||||
- `onEdit` -> `newSkuHandler`
|
||||
- `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.
|
||||
70
docs/SETUP.md
Normal file
70
docs/SETUP.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Setup Guide
|
||||
|
||||
## Local Development Environment
|
||||
|
||||
1. **Install Node.js**
|
||||
This project uses `fnm` to manage Node versions.
|
||||
```powershell
|
||||
# Install fnm (if not installed)
|
||||
winget install Schniz.fnm
|
||||
# Configure environment
|
||||
fnm env --use-on-cd | Out-String | Invoke-Expression
|
||||
# Install Node version
|
||||
fnm use --install-if-missing 22
|
||||
```
|
||||
|
||||
2. **Install Dependencies**
|
||||
```bash
|
||||
npm install
|
||||
npm install -g @google/clasp
|
||||
```
|
||||
|
||||
3. **Clasp Login**
|
||||
Authenticate with Google to allow pushing code.
|
||||
|
||||
```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).
|
||||
2. **"vars" Sheet**:
|
||||
Create a tab named `vars` with the following columns: `key`, `value`.
|
||||
Add the following rows:
|
||||
- `productPhotosFolderId`: ID of the Drive folder for photos.
|
||||
- `shopifyApiKey`: Your Shopify API Key.
|
||||
- `shopifyApiSecretKey`: Your Shopify API Secret.
|
||||
- `shopifyAdminApiAccessToken`: The Admin API access token.
|
||||
- `shopifyApiURI`: e.g., `https://your-store.myshopify.com`
|
||||
- `shopifyLocationId`: Location ID for inventory.
|
||||
- `shopifyCountryCodeOfOrigin`: Two-letter country code (e.g., `US`).
|
||||
- `shopifyProvinceCodeOfOrigin`: Two-letter province code (e.g., `NY`).
|
||||
|
||||
## Deployment
|
||||
|
||||
1. **Build**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. **Push to Apps Script**
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
3. **Install Triggers**
|
||||
Open the Apps Script editor (Extensions > Apps Script).
|
||||
Run the `reinstallTriggers` function manually once to set up the automation.
|
||||
119
package-lock.json
generated
119
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
181
src/Sidebar.html
Normal 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
12
src/appsscript.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
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
83
src/sidebar.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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]" },
|
||||
],
|
||||
}),
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user