diff --git a/package-lock.json b/package-lock.json
index d2a1440..7539820 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index b11ad2e..07ce7b7 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/MediaManager.html b/src/MediaManager.html
index f30a94e..6eec733 100644
--- a/src/MediaManager.html
+++ b/src/MediaManager.html
@@ -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 '
No pending changes.
';
}
- const renderSection = (title, items, icon) => {
+ const renderSection = (title, items, icon, phaseKey) => {
if (!items || items.length === 0) return '';
return `
${icon} ${title} (${items.length})
- ${items.map(i => `- ${i.filename}
`).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 '- ' + (i.filename || 'Item') + '
';
+ }).join('')}
`;
};
@@ -1654,10 +1663,10 @@
if (plan.deletions || plan.adoptions || plan.uploads || plan.reorders) {
// Structured
let html = '';
- 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 += '
';
return html;
}
@@ -1914,16 +1923,8 @@
// Build Plan Summary HTML
let html = ui.renderPlanHtml(plan);
- // Progress Section (Hidden initially)
- html += `
-
-
šļø Deletions: Pending
-
ā¬ļø Adoptions: Pending
-
ā¬ļø Uploads: Pending
-
š Reorder: Pending
-
š Sheet Update: Pending
-
- `;
+ // 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;
}
})
diff --git a/src/global.ts b/src/global.ts
index bb018d6..993f5ff 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -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
diff --git a/src/mediaHandlers.ts b/src/mediaHandlers.ts
index 564aacf..50d4a0d 100644
--- a/src/mediaHandlers.ts
+++ b/src/mediaHandlers.ts
@@ -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();
diff --git a/src/mediaManager.integration.test.ts b/src/mediaManager.integration.test.ts
index ba7cc39..27fb663 100644
--- a/src/mediaManager.integration.test.ts
+++ b/src/mediaManager.integration.test.ts
@@ -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"
diff --git a/src/services/MediaService.ts b/src/services/MediaService.ts
index fab443e..8e7e027 100644
--- a/src/services/MediaService.ts
+++ b/src/services/MediaService.ts
@@ -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();
+ // 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;
+ }
}
diff --git a/src/test/GlobalFunctions.test.ts b/src/test/GlobalFunctions.test.ts
index 23729bd..304151d 100644
--- a/src/test/GlobalFunctions.test.ts
+++ b/src/test/GlobalFunctions.test.ts
@@ -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 => {
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();
- 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 => {
- const calls = new Map(); // functionName -> filename (for error msg)
+ const calls = new Map(); // 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)) {
diff --git a/tools/validate_html.ts b/tools/validate_html.ts
new file mode 100644
index 0000000..808571d
--- /dev/null
+++ b/tools/validate_html.ts
@@ -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);
+ });
+}