Implement interactive execution plan and strict HTML validation

Features:
- **Interactive Checklist**: 'Review Changes' modal now updates in real-time as save tasks complete.
- **Signal Logging**: Backend emits [SIGNAL] logs for deletions, adoptions, uploads, and reorders.
- **UI Cleanup**: Removed redundant textual 'Execute Progress' log pane.

Build & Quality:
- **HTML Validation**: Added 	ools/validate_html.ts to build pipeline to prevent syntax errors in embedded JS.
- **Strict Build**:
pm run build now runs alidate:html first.
This commit is contained in:
Ben Miller
2026-01-02 00:23:01 -07:00
parent ee5fd782fe
commit 1068c912dc
9 changed files with 1021 additions and 327 deletions

527
package-lock.json generated
View File

@ -8,14 +8,17 @@
"name": "product_inventory",
"version": "0.0.1",
"devDependencies": {
"@types/cheerio": "^0.22.35",
"@types/google-apps-script": "^1.0.85",
"@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"cheerio": "^1.1.2",
"copy-webpack-plugin": "^13.0.1",
"eslint": "^8.57.1",
"eslint-plugin-html": "^8.1.3",
"gas-webpack-plugin": "^2.6.0",
"glob": "^13.0.0",
"graphql-tag": "^2.12.6",
"husky": "^9.1.7",
"jest": "^29.7.0",
@ -771,6 +774,29 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@ -1453,6 +1479,39 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@jest/reporters/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@jest/reporters/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@jest/reporters/node_modules/jest-message-util": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
@ -1508,6 +1567,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jest/reporters/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@jest/reporters/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@ -1861,6 +1933,16 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/cheerio": {
"version": "0.22.35",
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.35.tgz",
"integrity": "sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -2721,6 +2803,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true,
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@ -2875,6 +2964,50 @@
"node": ">=10"
}
},
"node_modules/cheerio": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz",
"integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.0.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.12.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chrome-trace-event": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@ -3169,6 +3302,36 @@
"node": ">= 8"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -3374,6 +3537,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
@ -4200,22 +4377,18 @@
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "*"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@ -4241,28 +4414,20 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^1.1.7"
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "*"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
@ -4454,6 +4619,19 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -5084,6 +5262,39 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/jest-config/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/jest-config/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest-config/node_modules/jest-util": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
@ -5102,6 +5313,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-config/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/jest-config/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@ -5805,6 +6029,39 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/jest-runtime/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/jest-runtime/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest-runtime/node_modules/jest-message-util": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
@ -5859,6 +6116,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-runtime/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/jest-runtime/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@ -6827,6 +7097,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -6898,6 +7178,19 @@
"node": ">=8"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -7013,6 +7306,46 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"dev": true,
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -7050,6 +7383,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@ -7421,6 +7781,52 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -7466,6 +7872,13 @@
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/schema-utils": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
@ -7866,6 +8279,28 @@
"concat-map": "0.0.1"
}
},
"node_modules/test-exclude/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -8193,6 +8628,16 @@
"node": ">=0.8.0"
}
},
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
@ -8417,6 +8862,30 @@
"node": ">=10.13.0"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -6,7 +6,8 @@
"global.ts"
],
"scripts": {
"build": "npm run lint && webpack --mode production",
"validate:html": "ts-node tools/validate_html.ts",
"build": "npm run validate:html && npm run lint && webpack --mode production",
"lint": "eslint \"src/**/*.{ts,js,html}\"",
"deploy": "clasp push",
"test": "jest",
@ -14,14 +15,17 @@
"prepare": "husky"
},
"devDependencies": {
"@types/cheerio": "^0.22.35",
"@types/google-apps-script": "^1.0.85",
"@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"cheerio": "^1.1.2",
"copy-webpack-plugin": "^13.0.1",
"eslint": "^8.57.1",
"eslint-plugin-html": "^8.1.3",
"gas-webpack-plugin": "^2.6.0",
"glob": "^13.0.0",
"graphql-tag": "^2.12.6",
"husky": "^9.1.7",
"jest": "^29.7.0",

View File

@ -811,9 +811,14 @@
alert("Script Error: " + msg + "\nLine: " + line);
};
// Template Variables (Injected by Server)
var initialSku = "<?= initialSku ?>";
var initialTitle = "<?= initialTitle ?>";
// --- ES5 Refactor: MediaState ---
function MediaState() {
this.sku = null;
this.sku = (initialSku && initialSku !== "undefined") ? initialSku : null;
this.title = (initialTitle && initialTitle !== "undefined") ? initialTitle : "";
this.token = null;
this.items = [];
this.initialState = [];
@ -1626,12 +1631,16 @@
return '<div style="text-align:center; padding:20px;">No pending changes.</div>';
}
const renderSection = (title, items, icon) => {
const renderSection = (title, items, icon, phaseKey) => {
if (!items || items.length === 0) return '';
return `
<h4 style="margin:10px 0 5px; border-bottom:1px solid #eee;">${icon} ${title} (${items.length})</h4>
<ul style="font-size:12px; padding-left:20px; color:#555;">
${items.map(i => `<li>${i.filename}</li>`).join('')}
${items.map(function (i) {
var idSuffix = i.id || (i.filename ? i.filename.replace(/[^a-z0-9]/gi, '_') : 'unknown');
var liId = 'li-plan-' + (phaseKey || 'legacy') + '-' + idSuffix;
return '<li id="' + liId + '">' + (i.filename || 'Item') + ' <span class="plan-status"></span></li>';
}).join('')}
</ul>
`;
};
@ -1654,10 +1663,10 @@
if (plan.deletions || plan.adoptions || plan.uploads || plan.reorders) {
// Structured
let html = '<div style="text-align:left; max-height:400px; overflow-y:auto;">';
html += renderSection('Deletions', plan.deletions, '🗑️');
html += renderSection('Adoptions (Save to Drive)', plan.adoptions, '⬇️');
html += renderSection('Uploads (Send to Shopify)', plan.uploads, '⬆️');
html += renderSection('Reorder & Rename', plan.reorders, '🔄');
html += renderSection('Deletions', plan.deletions, '🗑️', 'deletions');
html += renderSection('Adoptions (Save to Drive)', plan.adoptions, '⬇️', 'adoptions');
html += renderSection('Uploads (Send to Shopify)', plan.uploads, '⬆️', 'uploads');
html += renderSection('Reorder & Rename', plan.reorders, '🔄', 'reorders');
html += '</div>';
return html;
}
@ -1914,16 +1923,8 @@
// Build Plan Summary HTML
let html = ui.renderPlanHtml(plan);
// Progress Section (Hidden initially)
html += `
<div id="execution-progress" style="margin-top:15px; display:none; border-top:1px solid #eee; padding-top:10px;">
<div id="prog-delete" style="color:#888;">🗑️ Deletions: Pending</div>
<div id="prog-adopt" style="color:#888;">⬇️ Adoptions: Pending</div>
<div id="prog-upload" style="color:#888;">⬆️ Uploads: Pending</div>
<div id="prog-reorder" style="color:#888;">🔄 Reorder: Pending</div>
<div id="prog-sheet" style="color:#888;">📊 Sheet Update: Pending</div>
</div>
`;
// Progress Section (Removed)
// html += `...`
document.getElementById('match-modal-text').innerHTML = html;
@ -1959,164 +1960,47 @@
const btnSkip = document.getElementById('btn-match-skip');
btnConfirm.disabled = true;
btnSkip.disabled = true;
btnConfirm.innerText = "Saving...";
document.getElementById('execution-progress').style.display = 'block';
btnConfirm.innerText = "Saving...";
// Removed progressDiv setup
// Job ID
const jobId = Math.random().toString(36).substring(2) + Date.now().toString(36);
this.startLogPolling(jobId);
// Helpers for UI
const updateStatus = (id, status, color) => {
const el = document.getElementById(id);
if (el) {
el.style.color = color;
if (status) el.innerText = el.innerText.split(':')[0] + ': ' + status;
// Optimistic Sheet Update (Fire and Forget)
const first = activeItems[0];
let url = null;
if (first) {
const isShopify = (first.source === 'shopify_only' || first.source === 'synced') && first.thumbnail;
if (isShopify) {
url = first.thumbnail;
} else if (first.id) {
url = "https://drive.google.com/thumbnail?id=" + first.id + "&sz=w400";
}
};
// Optimistic Sheet Update (Immediate Parallel Execution)
const pSheet = new Promise(resolve => {
const first = activeItems[0];
let url = null;
if (first) {
// Determine best URL based on item type
// Note: MediaHandlers logic prioritizes Shopify URL if present.
// We mimic that logic here.
const isShopify = (first.source === 'shopify_only' || first.source === 'synced') && first.thumbnail;
if (isShopify) {
url = first.thumbnail;
} else if (first.id) {
// Drive Item - Construct URL compatible with server logic
url = "https://drive.google.com/thumbnail?id=" + first.id + "&sz=w400";
}
}
if (!url) {
updateStatus('prog-sheet', 'Skipped', '#aaa');
resolve();
return;
}
updateStatus('prog-sheet', 'Running...', 'blue');
google.script.run
.withSuccessHandler(() => {
updateStatus('prog-sheet', 'Done', 'green');
resolve();
})
.withFailureHandler(e => {
console.warn("Sheet update failed", e);
updateStatus('prog-sheet', 'Failed (Warn)', 'orange');
resolve(); // Don't block completion
})
.updateSpreadsheetThumbnail(state.sku, url);
});
// Phase 1: Deletions
if (plan.deletions.length === 0) {
updateStatus('prog-delete', 'Skipped', '#aaa');
this.startParallelPhases(plan, activeItems, jobId, pSheet);
return;
}
if (url) {
console.log("Triggering Optimistic Sheet Update");
google.script.run.updateSpreadsheetThumbnail(state.sku, url);
}
updateStatus('prog-delete', 'Running...', 'blue');
// Full Backend Execution
google.script.run
.withSuccessHandler(() => {
updateStatus('prog-delete', 'Done', 'green');
this.startParallelPhases(plan, activeItems, jobId, pSheet);
this.finishSave(true);
})
.withFailureHandler(e => {
updateStatus('prog-delete', 'Failed', 'red');
alert("Deletion Phase Failed: " + e.message);
this.stopLogPolling();
ui.setSavingState(false);
.withFailureHandler((e) => {
console.error("Save Failed", e);
// progressDiv.innerHTML += ... removed
alert("Save Operation Failed: " + e.message);
this.finishSave(false);
})
.executeSavePhase(state.sku, 'deletions', plan.deletions, jobId);
.executeFullSavePlan(state.sku, plan, jobId);
},
startParallelPhases(plan, activeItems, jobId, pSheet) {
// Helper function defined at the top to avoid hoisting issues
const updateStatus = (id, status, color) => {
const el = document.getElementById(id);
if (el) {
el.style.color = color;
if (status) el.innerText = el.innerText.split(':')[0] + ': ' + status;
}
};
// Phase 2 & 3: Adoptions & Uploads (Parallel)
const pAdoption = new Promise((resolve, reject) => {
if (plan.adoptions.length === 0) {
updateStatus('prog-adopt', 'Skipped', '#aaa');
return resolve();
}
updateStatus('prog-adopt', 'Running...', 'blue');
google.script.run
.withSuccessHandler(() => {
updateStatus('prog-adopt', 'Done', 'green');
resolve();
})
.withFailureHandler(reject)
.executeSavePhase(state.sku, 'adoptions', plan.adoptions, jobId);
});
const pUpload = new Promise((resolve, reject) => {
if (plan.uploads.length === 0) {
updateStatus('prog-upload', 'Skipped', '#aaa');
return resolve();
}
updateStatus('prog-upload', 'Running...', 'blue');
google.script.run
.withSuccessHandler(() => {
updateStatus('prog-upload', 'Done', 'green');
resolve();
})
.withFailureHandler(reject)
.executeSavePhase(state.sku, 'uploads', plan.uploads, jobId);
});
Promise.all([pAdoption, pUpload])
.then(() => {
// Phase 4: Reorder
const pReorder = new Promise((resolve, reject) => {
if (plan.reorders.length === 0) {
updateStatus('prog-reorder', 'Skipped', '#aaa');
resolve();
return;
}
updateStatus('prog-reorder', 'Running...', 'blue');
google.script.run
.withSuccessHandler(() => {
updateStatus('prog-reorder', 'Done', 'green');
resolve();
})
.withFailureHandler(e => {
alert("Reorder Phase Failed: " + e.message);
reject(e);
})
.executeSavePhase(state.sku, 'reorder', plan.reorders, jobId);
});
// Wait for BOTH Reorder and Early Sheet Update
Promise.all([pReorder, pSheet])
.then(() => {
this.finishSave(true);
})
.catch(e => {
// If reorder failed, we already alerted.
console.error("Save completion failed", e);
this.finishSave(false);
});
})
.catch(e => {
alert("Parallel Execution Failed: " + e.message);
this.stopLogPolling();
ui.setSavingState(false);
});
},
finishSave(success) {
this.stopLogPolling();
@ -2148,7 +2032,29 @@
// Simple approach: standard loop since we know count
if (logs.length > this.knownLogCount) {
const newLogs = logs.slice(this.knownLogCount);
newLogs.forEach(l => ui.logStatus('stream', l));
newLogs.forEach(l => {
// Check for Signal
if (l.startsWith('[SIGNAL] ')) {
try {
var sig = JSON.parse(l.substring(9));
// Format: { phase: string, id: string, status: string }
var li = document.getElementById('li-plan-' + sig.phase + '-' + sig.id);
if (li) {
li.style.color = '#aaa';
li.style.textDecoration = 'line-through';
var statusSpan = li.querySelector('.plan-status');
if (statusSpan) statusSpan.innerText = '✅';
}
} catch (e) { console.warn("Signal Parse Error", e); }
return; // Do NOT log signal to UI
}
ui.logStatus('stream', l);
// Modal progress update removed
});
this.knownLogCount = logs.length;
}
})

View File

@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
import { installSalesSyncTrigger } from "./triggers"
import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia, pollJobLogs, getMediaManagerInitialState, getMediaSavePlan, executeSavePhase, updateSpreadsheetThumbnail } from "./mediaHandlers"
import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia, pollJobLogs, getMediaManagerInitialState, getMediaSavePlan, executeSavePhase, updateSpreadsheetThumbnail, executeFullSavePlan } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite"
// prettier-ignore
@ -70,3 +70,4 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).getMediaSavePlan = getMediaSavePlan
;(global as any).executeSavePhase = executeSavePhase
;(global as any).updateSpreadsheetThumbnail = updateSpreadsheetThumbnail
;(global as any).executeFullSavePlan = executeFullSavePlan

View File

@ -66,6 +66,14 @@ export function getPickerConfig() {
}
}
export function fetchRawData(sku: string) {
// expose for testing if needed, or if UI needs raw dump
// but MediaService implementation is private.
// We stick to getInitialState.
}
export function getMediaForSku(sku: string): any[] {
const config = new Config()
const driveService = new GASDriveService()
@ -157,7 +165,7 @@ export function updateSpreadsheetThumbnail(sku: string, forcedThumbnailUrl: stri
// Need Shopify ID for accurate state logic?
// getUnifiedMediaState uses it.
try { product.MatchToShopifyProduct(shop); } catch(e) {}
try { product.MatchToShopifyProduct(shop); } catch(e) { /* ignore mismatch during initial load */ }
try {
// Refresh state to get Shopify CDN URLs
@ -244,6 +252,28 @@ export function executeSavePhase(sku: string, phase: string, planData: any, jobI
return mediaService.executeSavePhase(sku, phase, planData, product.shopify_id, jobId);
}
export function executeFullSavePlan(sku: string, plan: any, jobId: string | null = null) {
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)
const product = new Product(sku)
try {
product.MatchToShopifyProduct(shop);
} catch (e) {
console.warn("MatchToShopifyProduct failed", e);
}
if (!product.shopify_id) {
throw new Error("Product must be synced to Shopify before saving media changes.")
}
return mediaService.executeFullSavePlan(sku, plan, product.shopify_id, jobId);
}
export function pollJobLogs(jobId: string): string[] {
try {
const cache = CacheService.getDocumentCache();

View File

@ -130,7 +130,7 @@ describe("MediaService V2 Integration Logic", () => {
getThumbnail: () => ({ getBytes: () => [] }),
getMimeType: () => "image/jpeg"
}
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" })
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1", getUrl: () => "http://mock.url" })
mockDrive.getFiles.mockReturnValue([driveFile])
// Setup Shopify
@ -160,7 +160,7 @@ describe("MediaService V2 Integration Logic", () => {
getThumbnail: () => ({ getBytes: () => [] }),
getMimeType: () => "image/jpeg"
}
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" })
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1", getUrl: () => "http://mock.url" })
mockDrive.getFiles.mockReturnValue([driveFile])
mockShopify.getProductMedia.mockReturnValue([])
@ -171,7 +171,7 @@ describe("MediaService V2 Integration Logic", () => {
})
test("should identify Shopify-Only items", () => {
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1", addFile: jest.fn() })
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1", getUrl: () => "http://mock.url", addFile: jest.fn() })
mockDrive.getFiles.mockReturnValue([])
const shopMedia = {
@ -230,7 +230,7 @@ describe("MediaService V2 Integration Logic", () => {
jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([])
// Mock file creation
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1", addFile: jest.fn() })
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1", getUrl: () => "http://mock.url", addFile: jest.fn() })
// We set default mockDrive.createFile above but we can specialize if needed
// Default returns "new_created_file_id"

View File

@ -39,74 +39,106 @@ export class MediaService {
}
}
getDiagnostics(sku: string, shopifyProductId: string) {
const results = {
drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null },
shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null },
matching: { status: 'pending', error: null },
activeJobId: null
}
private fetchRawData(sku: string, shopifyProductId: string) {
const result = {
drive: { folder: null, files: [], error: null, folderUrl: null },
shopify: { media: [], error: null }
};
// Check for Active Job
// 1. Unsafe Drive Check
try {
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId);
result.drive.folder = folder;
result.drive.folderUrl = folder.getUrl();
// Fetch files with properties immediately
result.drive.files = this.driveService.getFilesWithProperties(folder.getId());
} catch (e) {
result.drive.error = e;
}
// 2. Unsafe Shopify Check
if (shopifyProductId) {
try {
const cache = CacheService.getDocumentCache();
const activeJobId = cache.get(`active_job_${sku}`);
if (activeJobId) {
results.activeJobId = activeJobId;
}
result.shopify.media = this.shopifyMediaService.getProductMedia(shopifyProductId);
} catch (e) {
console.warn("Failed to check active job", e);
result.shopify.error = e;
}
}
// 1. Unsafe Drive Check
try {
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
results.drive.folderId = folder.getId()
results.drive.folderUrl = folder.getUrl()
const files = this.driveService.getFiles(folder.getId())
results.drive.fileCount = files.length
results.drive.status = 'ok'
} catch (e) {
results.drive.status = 'error'
results.drive.error = e.toString()
}
// 2. Unsafe Shopify Check
try {
if (shopifyProductId) {
const media = this.shopifyMediaService.getProductMedia(shopifyProductId)
results.shopify.mediaCount = media.length
// Admin URL construction (Best effort)
// Assuming standard Shopify admin pattern
const domain = this.shopifyMediaService.getShopDomain? this.shopifyMediaService.getShopDomain() : 'admin.shopify.com';
results.shopify.adminUrl = `https://${domain.replace('.myshopify.com','')}.myshopify.com/admin/products/${shopifyProductId.split('/').pop()}`
results.shopify.status = 'ok'
} else {
results.shopify.status = 'skipped' // Not linked yet
}
} catch (e) {
results.shopify.status = 'error'
results.shopify.error = e.toString()
}
return results
return result;
}
getUnifiedMediaState(sku: string, shopifyProductId: string): any[] {
console.log(`MediaService: Getting unified state for SKU ${sku}`)
getDiagnostics(sku: string, shopifyProductId: string, rawData?: any) {
const results = {
drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null },
shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null },
matching: { status: 'pending', error: null },
activeJobId: null
}
// Check for Active Job
try {
const cache = CacheService.getDocumentCache();
const activeJobId = cache.get(`active_job_${sku}`);
if (activeJobId) {
results.activeJobId = activeJobId;
}
} catch (e) {
console.warn("Failed to check active job", e);
}
// Ensure we have data
const data = rawData || this.fetchRawData(sku, shopifyProductId);
// 1. Drive Status
if (data.drive.error) {
results.drive.status = 'error';
results.drive.error = data.drive.error.toString();
} else {
results.drive.folderId = data.drive.folder ? data.drive.folder.getId() : null;
results.drive.folderUrl = data.drive.folderUrl;
results.drive.fileCount = data.drive.files.length;
results.drive.status = 'ok';
}
// 2. Shopify Status
if (shopifyProductId) {
if (data.shopify.error) {
results.shopify.status = 'error';
results.shopify.error = data.shopify.error.toString();
} else {
results.shopify.mediaCount = data.shopify.media.length;
// Admin URL construction (Best effort)
const domain = this.shopifyMediaService.getShopDomain ? this.shopifyMediaService.getShopDomain() : 'admin.shopify.com';
results.shopify.adminUrl = `https://${domain.replace('.myshopify.com', '')}.myshopify.com/admin/products/${shopifyProductId.split('/').pop()}`;
results.shopify.status = 'ok';
}
} else {
results.shopify.status = 'skipped';
}
return results;
}
getUnifiedMediaState(sku: string, shopifyProductId: string, rawData?: any): any[] {
console.log(`MediaService: Getting unified state for SKU ${sku}`);
const data = rawData || this.fetchRawData(sku, shopifyProductId);
// Handle Errors from Fetch
if (data.drive.error) {
console.warn("Drive fetch failed, returning empty state or throwing?", data.drive.error);
// Previously we let it crash or return partial. Let's return empty if drive fails as it's the primary source.
return [];
// OR: throw data.drive.error; // To match previous behavior?
}
// 1. Get Drive Files
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
// We need strict file list.
// Optimization: getFiles() usually returns limited info.
// We might need to iterate and pull props if getFiles() doesn't include appProperties (DriveApp doesn't).
const driveFiles = this.driveService.getFilesWithProperties(folder.getId())
// const folder = ... // Already in data.drive.folder
const driveFiles = data.drive.files;
// 2. Get Shopify Media
let shopifyMedia: any[] = []
if (shopifyProductId) {
shopifyMedia = this.shopifyMediaService.getProductMedia(shopifyProductId)
}
let shopifyMedia = data.shopify.media || [];
// 3. Match
const unifiedState: any[] = []
@ -156,12 +188,37 @@ export class MediaService {
// Sort: Gallery Order ASC, then Filename ASC
driveFileStats.sort((a, b) => {
if (a.galleryOrder !== b.galleryOrder) {
return a.galleryOrder - b.galleryOrder
return a.galleryOrder - b.galleryOrder;
}
return a.file.getName().localeCompare(b.file.getName())
})
return a.file.getName().localeCompare(b.file.getName());
});
// Batch Status Check for Videos with Sidecars
const videoStatusMap = new Map<string, any>();
// Identify videos that MIGHT be ready (have sidecar)
const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId()));
if (videosToCheck.length > 0 && typeof Drive !== 'undefined') {
try {
// Check status for ALL videos in folder. Easier than filtering by specific IDs in 'q' which has length limits.
// We assume the folder ID is valid.
const folderId = data.drive.folder ? data.drive.folder.getId() : null;
if (folderId) {
// @ts-ignore
const response = Drive.Files.list({
q: `'${folderId}' in parents and mimeType contains 'video/' and trashed = false`,
fields: 'files(id, hasThumbnail, thumbnailLink, videoMediaMetadata)'
});
if (response.files) {
response.files.forEach((f: any) => videoStatusMap.set(f.id, f));
}
}
} catch (e) {
console.warn("[MediaService] Batch video status check failed", e);
}
}
// Match Logic (Strict ID Match Only)
driveFileStats.forEach(d => {
// Skip Sidecar Files in main list
@ -203,44 +260,22 @@ export class MediaService {
// But we want to CLEANUP.
// Let's use Advanced Drive API to check `thumbnailLink` existence for this specific file, if it has a sidecar.
// This minimizes API calls to ONLY when we have a sidecar candidate.
if (sidecarThumbMap.has(d.file.getId())) {
const fileId = d.file.getId();
// @ts-ignore
const drive = Drive;
const meta = drive.Files.get(fileId, { fields: 'thumbnailLink, hasThumbnail, videoMediaMetadata' });
// Batch Optimized Check
if (videoStatusMap.has(d.file.getId())) {
const meta = videoStatusMap.get(d.file.getId());
// Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid..
// Note: Drive sets hasThumbnail=true even for generic icons sometimes?
// But `thumbnailLink` definitely exists.
// For videos, `videoMediaMetadata` might NOT have 'width' while processing?
// Let's check `videoMediaMetadata.width`.
// Check `videoMediaMetadata.width` to ensure processing is complete (width is often missing during processing)
if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
// SUCCESS: Drive has finished processing (we have dimensions).
// SUCCESS: Drive has finished processing.
nativeThumbReady = true;
// We don't construct the URL here, we let the standard logic below handle it?
// No, we need the bytes for the frontend or a link.
// `thumbnailLink` is short lived.
// Let's use the native generation below.
console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`);
// Cleanup Sidecar Loop
// TRASH the sidecar file.
// We need the sidecar ID. We have to map IDs or iterate.
// Optimization: We didn't store Sidecar ID in the simpler Map.
// Let's find it.
const sidecarId = Array.from(sidecarFileIds).find(id => {
// This is slow: O(N) lookup.
// But we only do this ONCE per file lifecycle.
// Actually better to store ID in map?
// Let's just find the file in `driveFiles` that corresponds.
// We have `d.customThumbnailId`!
return id === d.customThumbnailId;
});
// Cleanup Sidecar
const sidecarId = d.customThumbnailId; // Direct lookup from properties
if (sidecarId) {
try {
this.driveService.trashFile(sidecarId);
sidecarFileIds.delete(sidecarId); // Remove from set so we don't trip later
sidecarFileIds.delete(sidecarId);
sidecarThumbMap.delete(d.file.getId());
console.log(`[MediaService] Trashed sidecar ${sidecarId}`);
} catch (trashErr) {
@ -248,10 +283,10 @@ export class MediaService {
}
}
}
}
}
}
} catch (e) {
// Ignore
// Ignore individual file errors
}
// 1. Check Sidecar (If it still exists after potential cleanup)
@ -272,7 +307,7 @@ export class MediaService {
try {
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
if (nativeThumb.length > 100) thumbnail = nativeThumb;
} catch(e) {}
} catch(e) { /* ignore thumbnail generation error */ }
}
} else {
// 2. Native / Fallback
@ -506,11 +541,15 @@ export class MediaService {
if (item.driveId) {
try {
if (item.customThumbnailId) {
try { this.driveService.trashFile(item.customThumbnailId); } catch(e) {}
try { this.driveService.trashFile(item.customThumbnailId); } catch(e) { /* ignore */ }
}
this.driveService.trashFile(item.driveId);
log(`- Trashed in Drive (${item.driveId})`);
log(`[SIGNAL] {"phase": "deletions", "id": "${item.id}", "status": "complete"}`);
} catch (e) { log(`- Failed to delete from Drive: ${e.message}`); }
} else if (item.shopifyId && !item.driveId) {
// Shopify Only deletion
log(`[SIGNAL] {"phase": "deletions", "id": "${item.id}", "status": "complete"}`);
}
});
}
@ -554,6 +593,7 @@ export class MediaService {
this.driveService.updateFileProperties(file.getId(), { shopify_media_id: item.shopifyId });
log(`- Adopted ${item.filename} => Drive ID: ${file.getId()}`);
log(`[SIGNAL] {"phase": "adoptions", "id": "${item.id}", "status": "complete"}`);
} else {
log(`- Failed to download ${item.filename}`);
}
@ -653,6 +693,7 @@ export class MediaService {
item.source = 'synced';
this.driveService.updateFileProperties(item.driveId, { shopify_media_id: created.id });
log(`- Created in Shopify (${created.id})`);
log(`[SIGNAL] {"phase": "uploads", "id": "${item.id}", "status": "complete"}`);
}
});
}
@ -691,6 +732,8 @@ export class MediaService {
log(`- Renamed ${currentName} -> ${newName}`);
}
log(`[SIGNAL] {"phase": "reorders", "id": "${item.id}", "status": "complete"}`);
// C. Prepare Shopify Reorder
if (item.shopifyId) {
reorderMoves.push({ id: item.shopifyId, newPosition: index.toString() });
@ -712,19 +755,81 @@ export class MediaService {
}
}
getInitialState(sku: string, shopifyProductId: string): { diagnostics: any, media: any[] } {
// 1. Diagnostics (Reusing the existing method logic but avoiding redundant setup)
const diagnostics = this.getDiagnostics(sku, shopifyProductId);
// 2. Unified Media State
// If diagnostics succeeded in finding the folder, we should probably pass that info
// to getUnifiedMediaState to avoid re-fetching the folder, but for now
// let's just call the method to keep it clean.
const media = this.getUnifiedMediaState(sku, shopifyProductId);
// New Implementation using Fetch Once
const rawData = this.fetchRawData(sku, shopifyProductId);
const diagnostics = this.getDiagnostics(sku, shopifyProductId, rawData);
const media = this.getUnifiedMediaState(sku, shopifyProductId, rawData);
return {
diagnostics,
media
};
}
executeFullSavePlan(sku: string, plan: any, shopifyProductId: string, jobId: string | null = null): string[] {
const logs: string[] = [];
const log = (msg: string) => {
logs.push(msg);
console.log(msg);
if (jobId) this.logToCache(jobId, msg);
};
try {
log(`Starting Save Operation for SKU ${sku}`);
// Store Active Job ID
if (jobId) {
CacheService.getDocumentCache().put(`active_job_${sku}`, jobId, 600); // 10 min lock
}
// 1. Deletions
if (plan.deletions && plan.deletions.length > 0) {
log(`Phase 1/4: Executing ${plan.deletions.length} Deletions...`);
this.executeDeletions(plan.deletions, shopifyProductId, log);
} else {
log('Phase 1/4: No Deletions.');
}
// 2. Adoptions
if (plan.adoptions && plan.adoptions.length > 0) {
log(`Phase 2/4: Executing ${plan.adoptions.length} Adoptions...`);
this.executeAdoptions(sku, plan.adoptions, log);
} else {
log('Phase 2/4: No Adoptions.');
}
// 3. Uploads
if (plan.uploads && plan.uploads.length > 0) {
log(`Phase 3/4: Executing ${plan.uploads.length} Uploads...`);
this.executeUploads(sku, plan.uploads, shopifyProductId, log);
} else {
log('Phase 3/4: No Uploads.');
}
// 4. Reorder & Rename
if (plan.reorders && plan.reorders.length > 0) {
log(`Phase 4/4: Executing Reorder & Rename...`);
this.executeReorderAndRename(sku, plan.reorders, shopifyProductId, log);
} else {
log('Phase 4/4: No Reordering.');
}
log("Save Operation Completed Successfully.");
// Clear Job Lock
if (jobId) {
CacheService.getDocumentCache().remove(`active_job_${sku}`);
}
} catch (e) {
log(`CRITICAL ERROR: Save failed: ${e.message}`);
// Clear Job Lock on error too so user isn't stuck forever
if (jobId) {
CacheService.getDocumentCache().remove(`active_job_${sku}`);
}
throw e;
}
return logs;
}
}

View File

@ -1,25 +1,59 @@
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import * as cheerio from 'cheerio';
describe('Global Function Exports', () => {
const srcDir = path.resolve(__dirname, '../'); // Assumes src/test/GlobalFunctions.test.ts
describe('Global Function Exports (AST Analysis)', () => {
const srcDir = path.resolve(__dirname, '../');
const globalFile = path.join(srcDir, 'global.ts');
// 1. Get all globally exported function names
// --- Helper: Parse Global Exports ---
const getGlobalExports = (): Set<string> => {
const content = fs.readFileSync(globalFile, 'utf-8');
const regex = /;\(global as any\)\.(\w+)\s*=/g;
const sourceFile = ts.createSourceFile('global.ts', content, ts.ScriptTarget.Latest, true);
const exports = new Set<string>();
let match;
while ((match = regex.exec(content)) !== null) {
exports.add(match[1]);
}
const visit = (node: ts.Node) => {
// Look for: ;(global as any).funcName = ...
if (ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
let left = node.left;
// Handle property access: (exp).funcName or exp.funcName
if (ts.isPropertyAccessExpression(left)) {
// Check if expression is (global as any) or global
let expression: ts.Expression = left.expression;
// Unprap parens: ((global as any))
while (ts.isParenthesizedExpression(expression)) {
expression = expression.expression;
}
// Unwrap 'as': global as any
if (ts.isAsExpression(expression)) {
expression = expression.expression;
}
if (ts.isIdentifier(expression) && expression.text === 'global') {
if (ts.isIdentifier(left.name)) {
exports.add(left.name.text);
}
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return exports;
};
// 2. Find all google.script.run calls in HTML files
// --- Helper: Find google.script.run Calls ---
const getFrontendCalls = (): Map<string, string> => {
const calls = new Map<string, string>(); // functionName -> filename (for error msg)
const calls = new Map<string, string>(); // functionName -> filename
const scanDir = (dir: string) => {
const files = fs.readdirSync(dir);
@ -30,27 +64,30 @@ describe('Global Function Exports', () => {
if (stat.isDirectory()) {
scanDir(fullPath);
} else if (file.endsWith('.html')) {
const content = fs.readFileSync(fullPath, 'utf-8');
// Matches:
// google.script.run.myFunc()
// google.script.run.withSuccessHandler(...).myFunc()
// google.script.run.withFailureHandler(...).myFunc()
// google.script.run.withSuccessHandler(...).withFailureHandler(...).myFunc()
const htmlContent = fs.readFileSync(fullPath, 'utf-8');
const $ = cheerio.load(htmlContent);
// Regex strategy:
// 1. Find "google.script.run"
// 2. Consume optional handlers .with...(...)
// 3. Capture the final function name .FunctionName(
$('script').each((_, script) => {
const scriptContent = $(script).html();
if (!scriptContent) return;
const callRegex = /google\.script\.run(?:[\s\n]*\.(?:withSuccessHandler|withFailureHandler|withUserObject)\([^)]*\))*[\s\n]*\.(\w+)\s*\(/g;
const sourceFile = ts.createSourceFile(file + '.js', scriptContent, ts.ScriptTarget.Latest, true);
let match;
while ((match = callRegex.exec(content)) !== null) {
const funcName = match[1];
if (!['withSuccessHandler', 'withFailureHandler', 'withUserObject'].includes(funcName)) {
calls.set(funcName, file);
}
}
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
// Check if this call is part of a google.script.run chain
const chain = analyzeChain(node.expression);
if (chain && chain.isGoogleScriptRun) {
if (!['withSuccessHandler', 'withFailureHandler', 'withUserObject'].includes(chain.methodName)) {
calls.set(chain.methodName, file);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
});
}
}
};
@ -59,10 +96,53 @@ describe('Global Function Exports', () => {
return calls;
};
// Helper to analyze property access chain
// Returns { isGoogleScriptRun: boolean, methodName: string } if valid
const analyzeChain = (expression: ts.Expression): { isGoogleScriptRun: boolean, methodName: string } | null => {
if (!ts.isPropertyAccessExpression(expression)) {
return null;
}
if (!ts.isIdentifier(expression.name)) {
return null;
}
const methodName = expression.name.text;
let current = expression.expression;
let depth = 0;
let p = current;
while (depth < 20) { // Safety break
if (ts.isCallExpression(p)) {
p = p.expression;
} else if (ts.isPropertyAccessExpression(p)) {
// Check for google.script.run
if (ts.isIdentifier(p.name) && p.name.text === 'run') {
// check exp.exp is script, exp.exp.exp is google
if (ts.isPropertyAccessExpression(p.expression) &&
ts.isIdentifier(p.expression.name) &&
p.expression.name.text === 'script' &&
ts.isIdentifier(p.expression.expression) &&
p.expression.expression.text === 'google') {
return { isGoogleScriptRun: true, methodName };
}
}
p = p.expression;
} else {
break;
}
depth++;
}
return null;
};
test('All client-side google.script.run calls must be exported in global.ts', () => {
const globalExports = getGlobalExports();
const frontendCalls = getFrontendCalls();
const missingQuery = [];
const missingQuery: string[] = [];
frontendCalls.forEach((filename, funcName) => {
if (!globalExports.has(funcName)) {

99
tools/validate_html.ts Normal file
View File

@ -0,0 +1,99 @@
import * as fs from 'fs';
import * as path from 'path';
import * as cheerio from 'cheerio';
import * as ts from 'typescript';
import { glob } from 'glob';
// Configuration
const SRC_DIR = 'src';
async function validateHtmlFiles() {
console.log(`[HTML Validator] Scanning ${SRC_DIR} for HTML files...`);
// Find all HTML files
const htmlFiles = glob.sync(`${SRC_DIR}/**/*.html`);
let hasErrors = false;
for (const file of htmlFiles) {
const absolutPath = path.resolve(file);
const content = fs.readFileSync(absolutPath, 'utf-8');
// Load with source location info enabled
// Cast options to any to avoid TS version mismatches with cheerio types
const options: any = { sourceCodeLocationInfo: true };
const $ = cheerio.load(content, options);
const scripts = $('script').toArray();
for (const element of scripts) {
// Cast to any to access startIndex safely
const node = element as any;
// Skip external scripts
if ($(element).attr('src')) continue;
const scriptContent = $(element).html();
if (!scriptContent) continue;
// Determine start line of the script tag in the original file
// Cheerio (htmlparser2) location info:
const loc = node.startIndex !== undefined ?
getLineNumber(content, node.startIndex) : 1;
// Validate Syntax using TypeScript Compiler API
const sourceFile = ts.createSourceFile(
'virtual.js',
scriptContent,
ts.ScriptTarget.ES2020,
true, // setParentNodes
ts.ScriptKind.JS
);
// Cast to any because parseDiagnostics might not be in the public interface depending on version
const sf: any = sourceFile;
if (sf.parseDiagnostics && sf.parseDiagnostics.length > 0) {
hasErrors = true;
console.error(`\n❌ Syntax Error in ${file}`);
sf.parseDiagnostics.forEach((diag: any) => {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(diag.start!);
const message = ts.flattenDiagnosticMessageText(diag.messageText, '\n');
// Adjust line number: Script Start line + Error line inside script
// Note: 'line' is 0-indexed relative to script start
const visualLine = loc + line;
console.error(` Line ${visualLine}: ${message}`);
// Show snippet
const lines = scriptContent.split('\n');
if (lines[line]) {
console.error(` > ${lines[line].trim()}\n`);
}
});
}
}
}
if (hasErrors) {
console.error(`\n[HTML Validator] Failed. Syntax errors detected.`);
process.exit(1);
} else {
console.log(`[HTML Validator] Passed. All HTML scripts are valid.`);
}
}
// Helper to calculate line number from char index
function getLineNumber(fullText: string, index: number): number {
return fullText.substring(0, index).split('\n').length;
}
// Check if run directly
if (require.main === module) {
validateHtmlFiles().catch(err => {
console.error("Validator crashed:", err);
process.exit(1);
});
}