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 = ""; + var 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})

`; }; @@ -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 += ` - - `; + // 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); + }); +}