Compare commits

...

10 Commits

Author SHA1 Message Date
d34f9a1417 Fix Unexpected Keyword in MediaManager and Add Build Linting
- Fix corrupted line in src/MediaManager.html causing syntax error.
- Add ESLint integration to build process to prevent future syntax errors.
- Create .eslintrc.js with TypeScript and HTML support.
- Relax strict lint rules to accommodate existing codebase.
2025-12-31 07:02:16 -07:00
3abc57f45a Refactor Media Manager log to use streaming and card UI
- **UI Overhaul**: Moved the activity log to a dedicated, expandable card at the bottom of the Media Manager modal.
- **Styling**: Updated the log card to match the application's light theme using CSS variables (`--surface`, `--text`).
- **Log Streaming**: Replaced batch logging with real-time streaming via `CacheService` and `pollJobLogs`.
- **Session Resumption**: Implemented logic to resume log polling for active jobs upon page reload.
- **Fixes**:
    - Exposed `pollJobLogs` in `global.ts` to fix "Script function not found" error.
    - Updated `mediaHandlers.test.ts` with `CacheService` mocks and new signatures.
    - Removed legacy auto-hide/toggle logic for the log.
2025-12-31 06:08:34 -07:00
dc33390650 Refine media state handling and fix CellImageBuilder errors
- Update MediaService delegation tests in src/mediaHandlers.test.ts to use mock.results for more reliable instance retrieval.
- Fix CellImageBuilder failure in src/mediaHandlers.ts by using public Shopify thumbnail URLs for synced items and direct Drive thumbnail endpoints for non-synced items.
- Fallback to IMAGE() formula in the spreadsheet for Drive items to avoid authentication issues with native cell images.
- Add test_*.txt to .gitignore to keep the workspace clean.
- Ensure all tests pass with updated log expectations and mock data.
2025-12-31 04:21:46 -07:00
f25fb359e8 Fix Shopify video previews and various improvements
- Ensure Shopify video sync updates Media Manager with active video previews
- Fix "Image load failed" error for video icons by using Base64 SVG
- Resolve Drive picker origin error by using google.script.host.origin
- Fix Drive video playback issues by using Drive iframe player
- Add `test:log` script to package.json for full output logging in Windows
- Update .gitignore to exclude coverage, test_output.txt, and .agent/
- Remove test_output.txt from git tracking
2025-12-31 01:10:18 -07:00
64ab548593 Fix Shopify video preview propagation on save
Updates logic to detect processing state (including READY-but-no-sources race condition) and propagates contentUrl updates to the frontend immediately.
2025-12-31 01:08:12 -07:00
772957058d Merge branch 'thumbnails-fix' 2025-12-31 00:15:55 -07:00
ben
16dec5e888 revert ebc1a39ce3
revert feat: Implement Server-Side Chunked Transfer for Drive Uploads

- Implemented 'Client-Orchestrated, Server-Side Chunked Transfer' to bypass CORS and 50MB limits for Google Photos.
- Added 'getResumableUploadUrl' to GASDriveService for high-priority video processing.
- Refactored 'MediaManager.html' to orchestrate uploads using 'transferRemoteChunk' loop.
- Added 'getRemoteFileSize' and 'transferRemoteChunk' to 'mediaHandlers.ts'.
- Updated 'global.ts' to expose new backend functions.
2025-12-31 00:14:52 -07:00
ben
ec6602cbde revert f1ab3b7b84
revert feat: Add custom video thumbnails for Drive uploads

- Implemented custom thumbnail injection in GASDriveService.getResumableUploadUrl.
- Fetches thumbnails from Google Photos using w320 size to avoid API limits.
- Added strict < 2MB size check for thumbnails.
- Updated mediaHandlers and MediaManager to pass sourceUrl to the backend.
- This allows Drive to display a visual cue immediately for video files still processing.
2025-12-31 00:14:38 -07:00
f1ab3b7b84 feat: Add custom video thumbnails for Drive uploads
- Implemented custom thumbnail injection in GASDriveService.getResumableUploadUrl.
- Fetches thumbnails from Google Photos using w320 size to avoid API limits.
- Added strict < 2MB size check for thumbnails.
- Updated mediaHandlers and MediaManager to pass sourceUrl to the backend.
- This allows Drive to display a visual cue immediately for video files still processing.
2025-12-30 00:38:57 -07:00
ebc1a39ce3 feat: Implement Server-Side Chunked Transfer for Drive Uploads
- Implemented 'Client-Orchestrated, Server-Side Chunked Transfer' to bypass CORS and 50MB limits for Google Photos.
- Added 'getResumableUploadUrl' to GASDriveService for high-priority video processing.
- Refactored 'MediaManager.html' to orchestrate uploads using 'transferRemoteChunk' loop.
- Added 'getRemoteFileSize' and 'transferRemoteChunk' to 'mediaHandlers.ts'.
- Updated 'global.ts' to expose new backend functions.
2025-12-29 22:08:21 -07:00
13 changed files with 1936 additions and 128 deletions

57
.eslintrc.js Normal file
View File

@ -0,0 +1,57 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: [
"@typescript-eslint",
"html",
],
globals: {
"google": "readonly",
"Logger": "readonly",
"item": "writable",
"Utilities": "readonly",
"state": "writable",
"ui": "writable",
"controller": "writable",
"gapi": "readonly",
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off", // Too noisy for existing codebase
"no-unused-vars": "off",
"prefer-const": "off",
"no-var": "off",
"no-undef": "off",
"no-redeclare": "off",
"no-empty": "warn",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-var-requires": "off",
"no-useless-escape": "off",
"no-extra-semi": "off",
"no-array-constructor": "off",
"@typescript-eslint/no-array-constructor": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off"
},
overrides: [
{
files: ["*.html"],
parser: "espree", // Use default parser for HTML scripts if TS parser fails, or just rely on plugin handling
// Actually plugin-html handles it. But we usually need to specify not to use TS rules that require type info if we don't have full project info for snippets.
}
]
};

4
.gitignore vendored
View File

@ -3,4 +3,6 @@ dist/**
desktop.ini desktop.ini
.continue/** .continue/**
.clasp.json .clasp.json
coverage/ coverage/
test_*.txt
.agent/

View File

@ -46,3 +46,6 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
- **Client-Side Syntax**: - **Client-Side Syntax**:
- **ES5 ONLY**: Do not use `class` in client-side HTML files. The Apps Script sanitizer often fails to parse them. Use `function` constructors. - **ES5 ONLY**: Do not use `class` in client-side HTML files. The Apps Script sanitizer often fails to parse them. Use `function` constructors.
## Troubleshooting
- **Test Output**: When running tests, use `npm run test:log` to capture full output to `test_output.txt`. This avoids terminal truncation and allows agents to read the full results without manual redirection.

1298
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,15 +6,21 @@
"global.ts" "global.ts"
], ],
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "npm run lint && webpack --mode production",
"lint": "eslint \"src/**/*.{ts,js,html}\"",
"deploy": "clasp push", "deploy": "clasp push",
"test": "jest", "test": "jest",
"test:log": "jest > test_output.txt 2>&1",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@types/google-apps-script": "^1.0.85", "@types/google-apps-script": "^1.0.85",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"copy-webpack-plugin": "^13.0.1", "copy-webpack-plugin": "^13.0.1",
"eslint": "^8.57.1",
"eslint-plugin-html": "^8.1.3",
"gas-webpack-plugin": "^2.6.0", "gas-webpack-plugin": "^2.6.0",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"husky": "^9.1.7", "husky": "^9.1.7",

View File

@ -164,25 +164,24 @@
/* Processing State */ /* Processing State */
.media-item.processing-card { .media-item.processing-card {
background-color: #334155 !important; background-color: #334155 !important;
position: relative; /* Ensure absolute children are contained */ position: relative;
/* Removed flex centering to let image stretch */
} }
.media-item.processing-card .media-content { .media-item.processing-card .media-content {
display: block !important; display: block !important;
opacity: 0.8; /* Lighter overlay (was 0.4) */ opacity: 0.8;
filter: grayscale(30%); /* Less grey (was 80%) */ filter: grayscale(30%);
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; /* Ensure it fills */ object-fit: contain;
} }
.processing-icon { .processing-icon {
position: absolute; position: absolute;
bottom: 6px; bottom: 6px;
right: 6px; right: 6px;
font-size: 20px; /* Smaller */ font-size: 20px;
z-index: 20; /* Above badges */ z-index: 20;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -380,6 +379,74 @@
transform: translateY(0); transform: translateY(0);
} }
} }
/* Log Card Styles */
.log-card {
background: var(--surface);
color: var(--text);
border-radius: 8px;
margin-top: 16px;
font-family: monospace;
font-size: 11px;
overflow: hidden;
transition: all 0.2s ease;
border: 1px solid var(--border);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
}
.log-header {
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-weight: 500;
}
.log-content {
padding: 12px;
/* ~1 line */
max-height: 16px;
overflow-y: auto;
transition: max-height 0.3s ease;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--surface);
}
.log-card.expanded .log-content {
/* ~20 lines */
max-height: 300px;
}
.log-entry {
line-height: 1.4;
border-bottom: 1px solid #f1f5f9;
padding-bottom: 2px;
}
.log-entry:last-child {
border-bottom: none;
}
/* Scrollbar for log */
.log-content::-webkit-scrollbar {
width: 6px;
}
.log-content::-webkit-scrollbar-track {
background: transparent;
}
.log-content::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
</style> </style>
</head> </head>
@ -399,42 +466,7 @@
</div> </div>
</div> </div>
<!-- Upload Options Card -->
<div class="card">
<div class="header" style="margin-bottom: 12px;">
<h3 style="margin:0; font-size:14px; color:var(--text);">Add Photos/Videos from...</h3>
</div>
<div style="display: flex; gap: 8px; width: 100%;">
<button onclick="controller.openPicker()" class="btn btn-secondary"
style="flex: 1; font-size: 13px; white-space: nowrap;">
Google Drive
</button>
<button onclick="controller.startPhotoSession()" class="btn btn-secondary"
style="flex: 1; font-size: 13px; white-space: nowrap;">
Google Photos
</button>
<button onclick="document.getElementById('file-input').click()" class="btn btn-secondary"
style="flex: 1; font-size: 13px;">
Your Computer
</button>
</div>
<input type="file" id="file-input" multiple style="display:none" onchange="controller.handleFiles(this.files)">
</div>
<!-- Photos Session UI -->
<div id="photos-session-ui"
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<span style="font-weight:600; font-size:12px; color:#0369a1;">Photo Picker Session</span>
<button onclick="ui.closePhotoSession()"
style="background:none; border:none; color:#0369a1; cursor:pointer; font-size:16px;">×</button>
</div>
<a id="photos-session-link" href="#" target="_blank" class="btn"
style="background:#0ea5e9; text-decoration:none; margin-bottom:8px;">
Open Google Photos ↗
</a>
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
</div>
@ -454,17 +486,12 @@
</div> </div>
</div> </div>
<!-- Status Log -->
<div id="status-log-container"
style="padding:16px; background:#f8fafc; border-bottom:1px solid var(--border); font-family:monospace; font-size:12px; line-height:1.6; display:none;">
</div>
<!-- Processing Warning Banner --> <!-- Processing Warning Banner -->
<div id="processing-banner" <div id="processing-banner"
style="display:none; background-color:#fffbeb; color:#92400e; padding:12px; border-radius:8px; margin: 0 16px 12px 16px; font-size:13px; border:1px solid #fcd34d; align-items:flex-start; gap:8px;"> style="display:none; background-color:#fffbeb; color:#92400e; padding:12px; border-radius:8px; margin: 0 16px 12px 16px; font-size:13px; border:1px solid #fcd34d; align-items:flex-start; gap:8px;">
<span style="font-size:16px; line-height:1;"></span> <span style="font-size:16px; line-height:1;"></span>
<div> <div>
Some videos are still being transcoded by Drive. The video preview might not work yet, but they can still be saved, Some videos are still being processed. The video preview might not work yet, but they can still be saved,
reordered, or deleted. reordered, or deleted.
</div> </div>
</div> </div>
@ -482,6 +509,55 @@
</button> </button>
</div> </div>
</div> </div>
<div id="upload-section" style="display:none;">
<!-- Upload Options Card -->
<div class="card">
<div class="header" style="margin-bottom: 12px;">
<h3 style="margin:0; font-size:14px; color:var(--text);">Add Photos/Videos from...</h3>
</div>
<div style="display: flex; gap: 8px; width: 100%;">
<button id="btn-upload-drive" onclick="controller.openPicker()" class="btn btn-secondary"
style="flex: 1; font-size: 13px; white-space: nowrap;">
Google Drive
</button>
<button id="btn-upload-photos" onclick="controller.startPhotoSession()" class="btn btn-secondary"
style="flex: 1; font-size: 13px; white-space: nowrap;">
Google Photos
</button>
<button id="btn-upload-computer" onclick="document.getElementById('file-input').click()"
class="btn btn-secondary" style="flex: 1; font-size: 13px;">
Your Computer
</button>
</div>
<input type="file" id="file-input" multiple style="display:none" onchange="controller.handleFiles(this.files)">
</div>
<!-- Photos Session UI -->
<div id="photos-session-ui"
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<span style="font-weight:600; font-size:12px; color:#0369a1;">Photo Picker Session</span>
<button onclick="ui.closePhotoSession()"
style="background:none; border:none; color:#0369a1; cursor:pointer; font-size:16px;">×</button>
</div>
<a id="photos-session-link" href="#" target="_blank" class="btn"
style="background:#0ea5e9; text-decoration:none; margin-bottom:8px;">
Open Google Photos ↗
</a>
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
</div>
</div>
<!-- Permanent Log Card -->
<div id="log-card" class="log-card">
<div class="log-header" onclick="ui.toggleLogExpand()">
<span style="font-weight:600;">Activity Log</span>
<span id="log-toggle-icon"></span>
</div>
<div id="status-log-container" class="log-content">
<div class="log-entry" style="color: #94a3b8;">Ready.</div>
</div>
</div>
</div> </div>
<!-- Loading Screen --> <!-- Loading Screen -->
@ -708,9 +784,10 @@
function UI() { function UI() {
this.grid = document.getElementById('media-grid'); this.grid = document.getElementById('media-grid');
this.saveBtn = document.getElementById('save-btn'); this.saveBtn = document.getElementById('save-btn');
this.toggleLogBtn = document.getElementById('toggle-log-btn'); // this.toggleLogBtn = document.getElementById('toggle-log-btn'); // Removed
this.logContainer = document.getElementById('status-log-container'); this.logContainer = document.getElementById('status-log-container');
this.linksContainer = document.getElementById('quick-links'); this.linksContainer = document.getElementById('quick-links');
this.logCard = document.getElementById('log-card');
this.sortable = null; this.sortable = null;
this.driveUrl = null; this.driveUrl = null;
this.shopifyUrl = null; this.shopifyUrl = null;
@ -725,10 +802,10 @@
if (this.shopifyUrl) this.linksContainer.innerHTML += '<a href="' + this.shopifyUrl + '" target="_blank" style="color:var(--primary); text-decoration:none; margin-left:8px;">Shopify ↗</a>'; if (this.shopifyUrl) this.linksContainer.innerHTML += '<a href="' + this.shopifyUrl + '" target="_blank" style="color:var(--primary); text-decoration:none; margin-left:8px;">Shopify ↗</a>';
}; };
UI.prototype.toggleLog = function (forceState) { UI.prototype.toggleLogExpand = function () {
var isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none'; this.logCard.classList.toggle('expanded');
this.logContainer.style.display = isVisible ? 'none' : 'block'; var icon = this.logCard.querySelector('#log-toggle-icon');
this.toggleLogBtn.innerText = isVisible ? "View Log" : "Hide Log"; icon.innerText = this.logCard.classList.contains('expanded') ? '▼' : '▲';
}; };
UI.prototype.updateSku = function (sku, title) { UI.prototype.updateSku = function (sku, title) {
@ -832,11 +909,21 @@
UI.prototype.logStatus = function (step, message, type) { UI.prototype.logStatus = function (step, message, type) {
if (!type) type = 'info'; if (!type) type = 'info';
var container = this.logContainer; var container = this.logContainer;
var icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
// Auto-clear "Ready"
if (container.children.length === 1 && container.children[0].innerText === "Ready.") {
container.innerHTML = "";
}
var icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '';
var el = document.createElement('div'); var el = document.createElement('div');
el.innerHTML = '<span style="margin-right:8px;">' + icon + '</span> ' + message; el.className = 'log-entry';
if (type === 'error') el.style.color = 'var(--error)'; el.innerHTML = '<span style="margin-right:8px; opacity:0.7;">' + icon + '</span> ' + message;
if (type === 'error') el.style.color = 'var(--danger)';
if (type === 'success') el.style.color = 'var(--success)';
container.appendChild(el); container.appendChild(el);
container.scrollTop = container.scrollHeight; // Auto-scroll
}; };
UI.prototype.createCard = function (item, index) { UI.prototype.createCard = function (item, index) {
@ -1047,10 +1134,9 @@
const sku = info ? info.sku : null; const sku = info ? info.sku : null;
if (sku && sku !== state.sku) { if (sku && sku !== state.sku) {
state.setSku(info); // Pass whole object state.setSku(info);
this.loadMedia(); this.loadMedia();
} else if (!sku && !state.sku) { } else if (!sku && !state.sku) {
// If we don't have a SKU and haven't shown error yet
if (document.getElementById('error-ui').style.display !== 'flex') { if (document.getElementById('error-ui').style.display !== 'flex') {
this.loadMedia(); this.loadMedia();
} }
@ -1060,7 +1146,10 @@
}, },
loadMedia(preserveLogs = false) { loadMedia(preserveLogs = false) {
// Resolve SKU/Title - prefer state, fallback to DOM // ... (Resolving SKU/Title Logic preserved below implicitly or we verify we didn't clip it)
// Actually, let's keep the resolving logic safe.
// We are replacing lines 1120-1191 roughly.
let sku = state.sku; let sku = state.sku;
let title = state.title; let title = state.title;
@ -1083,28 +1172,34 @@
if (domTitle && domTitle !== 'Loading...') title = domTitle; if (domTitle && domTitle !== 'Loading...') title = domTitle;
} }
// Show Main UI immediately so logs are visible
document.getElementById('loading-ui').style.display = 'none'; document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block'; document.getElementById('main-ui').style.display = 'block';
// Set Inline Loading State
ui.setLoadingState(true); ui.setLoadingState(true);
// Reset State (this calls ui.updateSku)
state.setSku({ sku, title }); state.setSku({ sku, title });
if (!preserveLogs) { if (!preserveLogs) {
document.getElementById('status-log-container').innerHTML = ''; document.getElementById('status-log-container').innerHTML = ''; // Reset log
ui.logStatus('ready', 'Ready.', 'info');
} else {
// We might want to clear "Ready" if we are preserving logs
} }
ui.toggleLogBtn.style.display = 'inline-block';
ui.toggleLog(true); // Force Show Log to see progress
// 1. Run Diagnostics // ui.toggleLogBtn.style.display = 'inline-block'; // Removed
// 1. Run Diagnostics
ui.logStatus('init', 'Initializing access...', 'info'); ui.logStatus('init', 'Initializing access...', 'info');
google.script.run google.script.run
.withSuccessHandler(function (diagnostics) { .withSuccessHandler((diagnostics) => { // Use arrow
// Check Resumption
if (diagnostics.activeJobId) {
ui.logStatus('resume', 'Resuming active background job...', 'info');
ui.toggleSave(false);
ui.saveBtn.innerText = "Saving in background...";
controller.startLogPolling(diagnostics.activeJobId);
if (!ui.logCard.classList.contains('expanded')) ui.toggleLogExpand();
}
// Drive Status // Drive Status
if (diagnostics.drive.status === 'ok') { if (diagnostics.drive.status === 'ok') {
ui.logStatus('drive', `Drive Folder: ok (${diagnostics.drive.fileCount} files) <a href="${diagnostics.drive.folderUrl}" target="_blank" style="margin-left:8px;">Open Folder ↗</a>`, 'success'); ui.logStatus('drive', `Drive Folder: ok (${diagnostics.drive.fileCount} files) <a href="${diagnostics.drive.folderUrl}" target="_blank" style="margin-left:8px;">Open Folder ↗</a>`, 'success');
@ -1116,6 +1211,7 @@
// Capture Token // Capture Token
if (diagnostics.token) state.token = diagnostics.token; if (diagnostics.token) state.token = diagnostics.token;
// Shopify Status // Shopify Status
if (diagnostics.shopify.status === 'ok') { if (diagnostics.shopify.status === 'ok') {
ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success'); ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success');
@ -1166,37 +1262,81 @@
ui.toggleSave(false); ui.toggleSave(false);
ui.saveBtn.innerText = "Saving..."; ui.saveBtn.innerText = "Saving...";
ui.saveBtn.innerText = "Saving..."; // Generate Job ID
const jobId = Math.random().toString(36).substring(2) + Date.now().toString(36);
// Filter out deleted items so they are actually removed // Start Polling
this.startLogPolling(jobId);
// Expand Log Card
if (!ui.logCard.classList.contains('expanded')) {
ui.toggleLogExpand();
}
// Filter out deleted items
const activeItems = state.items.filter(i => !i._deleted); const activeItems = state.items.filter(i => !i._deleted);
// Send final state array to backend // Send final state array to backend
google.script.run google.script.run
.withSuccessHandler((logs) => { .withSuccessHandler((logs) => {
ui.saveBtn.innerText = "Saved!"; ui.saveBtn.innerText = "Saved!";
this.stopLogPolling(); // Stop polling
// Final sync of logs (in case polling missed the very end)
// But usually the returned logs are the full set or summary?
// The backend returns the full array. Let's merge or just ensure we show "Complete".
// Since we were polling, we might have partials.
// Let's just trust the stream has been showing progress.
// We can log a completion message.
ui.logStatus('save', 'Process Completed Successfully.', 'success');
// Verify logs is an array (backward compatibility check)
if (Array.isArray(logs)) {
document.getElementById('status-log-container').innerHTML = '';
logs.forEach(l => ui.logStatus('save', l, 'info'));
ui.toggleLog(true); // Force show
} else {
// Fallback for old backend
alert("Changes Saved & Synced!");
}
// Reload to get fresh IDs/State, preserving the save logs // Reload to get fresh IDs/State, preserving the save logs
setTimeout(() => this.loadMedia(true), 1500); setTimeout(() => this.loadMedia(true), 1500);
}) })
.withFailureHandler(e => { .withFailureHandler(e => {
this.stopLogPolling();
alert(`Save Failed: ${e.message}`); alert(`Save Failed: ${e.message}`);
ui.logStatus('fatal', `Save Failed: ${e.message}`, 'error');
ui.toggleSave(true); ui.toggleSave(true);
}) })
.saveMediaChanges(state.sku, activeItems); .saveMediaChanges(state.sku, activeItems, jobId);
},
logPollInterval: null,
knownLogCount: 0,
startLogPolling(jobId) {
if (this.logPollInterval) clearInterval(this.logPollInterval);
this.knownLogCount = 0;
this.logPollInterval = setInterval(() => {
google.script.run
.withSuccessHandler(logs => {
if (!logs || logs.length === 0) return;
// Append ONLY new logs
// 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));
this.knownLogCount = logs.length;
}
})
.pollJobLogs(jobId);
}, 1000); // Poll every second
},
stopLogPolling() {
if (this.logPollInterval) {
clearInterval(this.logPollInterval);
this.logPollInterval = null;
}
}, },
handleFiles(fileList) { handleFiles(fileList) {
if (!fileList || fileList.length === 0) return;
this.setPickerState(true);
Array.from(fileList).forEach(file => { Array.from(fileList).forEach(file => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
@ -1205,6 +1345,12 @@
google.script.run google.script.run
.withSuccessHandler(() => { .withSuccessHandler(() => {
this.loadMedia(); this.loadMedia();
this.setPickerState(false);
})
.withFailureHandler(err => {
console.error(err);
alert("Upload failed: " + err.message);
this.setPickerState(false);
}) })
.saveFileToDrive(state.sku, file.name, file.type, data); .saveFileToDrive(state.sku, file.name, file.type, data);
}; };
@ -1212,27 +1358,57 @@
}); });
}, },
// --- Picker --- setPickerState(isActive) {
// --- Picker --- const btnDrive = document.getElementById('btn-upload-drive');
const btnPhotos = document.getElementById('btn-upload-photos');
const btnComp = document.getElementById('btn-upload-computer');
[btnDrive, btnPhotos, btnComp].forEach(btn => {
if (btn) {
btn.disabled = isActive;
btn.style.opacity = isActive ? '0.5' : '1';
btn.style.cursor = isActive ? 'not-allowed' : 'pointer';
}
});
},
openPicker() { openPicker() {
if (!pickerApiLoaded) return alert("API Loading..."); if (!pickerApiLoaded) return alert("API Loading...");
google.script.run.withSuccessHandler(c => createPicker(c)).getPickerConfig(); this.setPickerState(true);
google.script.run
.withSuccessHandler(c => createPicker(c))
.withFailureHandler(e => {
alert("Failed to load picker: " + e.message);
this.setPickerState(false);
})
.getPickerConfig();
}, },
importFromPicker(fileId, mime, name, url) { importFromPicker(fileId, mime, name, url) {
google.script.run google.script.run
.withSuccessHandler(() => this.loadMedia()) .withSuccessHandler(() => {
this.loadMedia();
this.setPickerState(false);
})
.withFailureHandler(e => {
alert("Import failed: " + e.message);
this.setPickerState(false);
})
.importFromPicker(state.sku, fileId, mime, name, url); .importFromPicker(state.sku, fileId, mime, name, url);
}, },
// --- Photos (Popup Flow) ---
startPhotoSession() { startPhotoSession() {
this.setPickerState(true);
ui.updatePhotoStatus("Starting session..."); ui.updatePhotoStatus("Starting session...");
google.script.run google.script.run
.withSuccessHandler(session => { .withSuccessHandler(session => {
ui.showPhotoSession(session.pickerUri); ui.showPhotoSession(session.pickerUri);
this.pollPhotoSession(session.id); this.pollPhotoSession(session.id);
}) })
.withFailureHandler(e => {
ui.updatePhotoStatus("Failed: " + e.message);
this.setPickerState(false);
})
.createPhotoSession(); .createPhotoSession();
}, },
@ -1245,13 +1421,18 @@
if (res.status === 'complete') { if (res.status === 'complete') {
processing = true; processing = true;
ui.updatePhotoStatus("Importing photos..."); ui.updatePhotoStatus("Importing photos...");
controller.processPhotoItems(res.mediaItems); this.processPhotoItems(res.mediaItems);
} else if (res.status === 'error') { } else if (res.status === 'error') {
ui.updatePhotoStatus("Error: " + res.message); ui.updatePhotoStatus("Error: " + res.message);
this.setPickerState(false);
} else { } else {
setTimeout(check, 2000); setTimeout(check, 2000);
} }
}) })
.withFailureHandler(e => {
ui.updatePhotoStatus("Error polling: " + e.message);
this.setPickerState(false);
})
.checkPhotoSession(sessionId); .checkPhotoSession(sessionId);
}; };
check(); check();
@ -1283,9 +1464,18 @@
if (done === items.length) { if (done === items.length) {
ui.updatePhotoStatus("Done!"); ui.updatePhotoStatus("Done!");
controller.loadMedia(); controller.loadMedia();
this.setPickerState(false);
setTimeout(() => ui.closePhotoSession(), 2000); setTimeout(() => ui.closePhotoSession(), 2000);
} }
}) })
.withFailureHandler(e => {
console.error("Import failed", e);
// If last one
done++;
if (done === items.length) {
this.setPickerState(false);
}
})
.importFromPicker(state.sku, null, mimeType, filename, url); .importFromPicker(state.sku, null, mimeType, filename, url);
}); });
}, },
@ -1394,11 +1584,11 @@
document.getElementById('btn-match-confirm').innerText = "Linking..."; document.getElementById('btn-match-confirm').innerText = "Linking...";
document.getElementById('btn-match-skip').disabled = true; document.getElementById('btn-match-skip').disabled = true;
// ui.logStatus('link', 'Linking ' + match.drive.filename + '...', 'info'); ui.logStatus('link', 'Linking ' + match.drive.filename + '...', 'info');
google.script.run google.script.run
.withSuccessHandler(function () { .withSuccessHandler(function () {
// ui.logStatus('link', 'Linked ' + match.drive.filename, 'success'); ui.logStatus('link', 'Linked ' + match.drive.filename, 'success');
_this.nextMatch(); _this.nextMatch();
}) })
.withFailureHandler(function (e) { .withFailureHandler(function (e) {
@ -1450,8 +1640,9 @@
showGallery() { showGallery() {
document.getElementById('loading-ui').style.display = 'none'; document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block'; document.getElementById('main-ui').style.display = 'block';
document.getElementById('upload-section').style.display = 'block';
ui.logStatus('done', 'Finished loading.', 'success'); ui.logStatus('done', 'Finished loading.', 'success');
setTimeout(function () { ui.toggleLog(false); }, 1000); // setTimeout(function () { ui.toggleLog(false); }, 1000); // Removed auto-hide
// Start Polling for Processing Items // Start Polling for Processing Items
this.pollProcessingItems(); this.pollProcessingItems();
@ -1516,6 +1707,8 @@
console.log("[MediaManager] Processing complete for " + item.filename); console.log("[MediaManager] Processing complete for " + item.filename);
item.isProcessing = false; item.isProcessing = false;
item.thumbnail = newItem.thumbnail; item.thumbnail = newItem.thumbnail;
item.contentUrl = newItem.contentUrl; // Propagate URL
item.source = newItem.source; // Propagate source update (synced)
changed = true; changed = true;
} }
} }
@ -1561,6 +1754,9 @@
// Drive File (Always, since we removed Photos view) // Drive File (Always, since we removed Photos view)
controller.importFromPicker(doc.id, doc.mimeType, doc.name, null); controller.importFromPicker(doc.id, doc.mimeType, doc.name, null);
} else if (data.action == google.picker.Action.CANCEL) {
console.log("Picker cancelled");
controller.setPickerState(false);
} }
}) })
.build() .build()

View File

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

View File

@ -23,8 +23,16 @@ jest.mock("./config", () => {
jest.mock("./services/GASNetworkService") jest.mock("./services/GASNetworkService")
jest.mock("./services/ShopifyMediaService") jest.mock("./services/ShopifyMediaService")
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() })) jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
jest.mock("./services/MediaService") jest.mock("./services/MediaService", () => {
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) })) return {
MediaService: jest.fn().mockImplementation(() => {
return {
getUnifiedMediaState: jest.fn().mockReturnValue([]),
processMediaChanges: jest.fn().mockReturnValue([])
}
})
}
})
// Mock GASDriveService // Mock GASDriveService
@ -49,10 +57,30 @@ jest.mock("./services/GASSpreadsheetService", () => {
GASSpreadsheetService: jest.fn().mockImplementation(() => { GASSpreadsheetService: jest.fn().mockImplementation(() => {
return { return {
getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => { getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => {
// console.log(`Mock GASSpreadsheetService getCellValueByColumnName called: ${col}`);
if (col === "sku") return "TEST-SKU" if (col === "sku") return "TEST-SKU"
if (col === "title") return "Test Product Title" if (col === "title") return "Test Product Title"
return null return null
}) }),
getRowNumberByColumnValue: jest.fn().mockReturnValue(5),
setCellValueByColumnName: jest.fn(),
getHeaders: jest.fn().mockReturnValue(["sku", "title", "thumbnail"]),
getRowData: jest.fn()
}
})
}
})
// Mock Product
jest.mock("./Product", () => {
return {
Product: jest.fn().mockImplementation((sku) => {
return {
sku: sku,
shopify_id: "shopify_id_123",
title: "Test Product Title",
MatchToShopifyProduct: jest.fn(),
ImportFromInventory: jest.fn()
} }
}) })
} }
@ -88,7 +116,13 @@ global.SpreadsheetApp = {
getName: jest.fn().mockReturnValue("product_inventory"), getName: jest.fn().mockReturnValue("product_inventory"),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 }) getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 })
}), }),
getActive: jest.fn() getActive: jest.fn(),
newCellImage: jest.fn().mockReturnValue({
setSourceUrl: jest.fn().mockReturnThis(),
setAltTextTitle: jest.fn().mockReturnThis(),
setAltTextDescription: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue("CELL_IMAGE_OBJECT")
})
} as any } as any
// UrlFetchApp // UrlFetchApp
@ -132,10 +166,25 @@ global.Session = {
global.HtmlService = { global.HtmlService = {
createHtmlOutputFromFile: jest.fn().mockReturnValue({ createHtmlOutputFromFile: jest.fn().mockReturnValue({
setTitle: jest.fn().mockReturnThis(), setTitle: jest.fn().mockReturnThis(),
setWidth: jest.fn().mockReturnThis() setWidth: jest.fn().mockReturnThis(),
setHeight: jest.fn().mockReturnThis()
}) })
} as any } as any
// MimeType
global.MimeType = {
JPEG: "image/jpeg",
PNG: "image/png"
} as any
// Mock CacheService for log streaming
global.CacheService = {
getDocumentCache: () => ({
get: (key) => null,
put: (k, v, t) => {},
remove: (k) => {}
})
} as any
describe("mediaHandlers", () => { describe("mediaHandlers", () => {
beforeEach(() => { beforeEach(() => {
@ -249,7 +298,8 @@ describe("mediaHandlers", () => {
// Get the instance that was created // Get the instance that was created
const MockMediaService = MediaService as unknown as jest.Mock const MockMediaService = MediaService as unknown as jest.Mock
const mockInstance = MockMediaService.mock.instances[MockMediaService.mock.instances.length - 1] expect(MockMediaService).toHaveBeenCalled()
const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
// Checking delegation // Checking delegation
expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", expect.anything()) expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", expect.anything())
@ -263,16 +313,55 @@ describe("mediaHandlers", () => {
saveMediaChanges("SKU123", finalState) saveMediaChanges("SKU123", finalState)
const MockMediaService = MediaService as unknown as jest.Mock const MockMediaService = MediaService as unknown as jest.Mock
const mockInstance = MockMediaService.mock.instances[MockMediaService.mock.instances.length - 1] const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything())
expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything(), null)
}) })
test("should throw if product not synced", () => { test("should throw if product not synced", () => {
const { Product } = require("./Product") const MockProduct = Product as unknown as jest.Mock
Product.mockImplementationOnce(() => ({ shopify_id: null, MatchToShopifyProduct: jest.fn() })) MockProduct.mockImplementationOnce(() => ({
shopify_id: null,
MatchToShopifyProduct: jest.fn(),
ImportFromInventory: jest.fn()
}))
expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced") expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced")
}) })
test("should update sheet thumbnail with first image", () => {
// Setup mock MediaService to NOT throw and just return logs
const MockMediaService = MediaService as unknown as jest.Mock
const mockGetUnifiedMediaState = jest.fn().mockReturnValue([
{ id: "2", driveId: "drive_file_2", galleryOrder: 1, contentUrl: "https://cdn.shopify.com/test.jpg", thumbnail: "https://cdn.shopify.com/test.jpg" }
])
MockMediaService.mockImplementation(() => ({
processMediaChanges: jest.fn().mockReturnValue(["Log 1"]),
getUnifiedMediaState: mockGetUnifiedMediaState
}))
const finalState = [
{ id: "1", driveId: "drive_file_1", galleryOrder: 10 },
{ id: "2", driveId: "drive_file_2", galleryOrder: 1 } // Should be first
]
const logs = saveMediaChanges("TEST-SKU", finalState)
// Logs are now just passed through from MediaService since we commented out local log appending
expect(logs).toEqual(["Log 1"])
// Verify spreadsheet service interaction
const MockSpreadsheet = GASSpreadsheetService as unknown as jest.Mock
expect(MockSpreadsheet).toHaveBeenCalled()
const mockSS = MockSpreadsheet.mock.results[MockSpreadsheet.mock.results.length - 1].value
expect(mockSS.setCellValueByColumnName).toHaveBeenCalledWith(
"product_inventory",
5,
"thumbnail",
"CELL_IMAGE_OBJECT"
)
})
}) })
describe("Photo Session API", () => { describe("Photo Session API", () => {

View File

@ -61,7 +61,7 @@ export function getMediaForSku(sku: string): any[] {
return mediaService.getUnifiedMediaState(sku, shopifyId) return mediaService.getUnifiedMediaState(sku, shopifyId)
} }
export function saveMediaChanges(sku: string, finalState: any[]) { export function saveMediaChanges(sku: string, finalState: any[], jobId: string | null = null) {
const config = new Config() const config = new Config()
const driveService = new GASDriveService() const driveService = new GASDriveService()
const shop = new Shop() const shop = new Shop()
@ -84,7 +84,64 @@ export function saveMediaChanges(sku: string, finalState: any[]) {
throw new Error("Product must be synced to Shopify before saving media changes.") throw new Error("Product must be synced to Shopify before saving media changes.")
} }
return mediaService.processMediaChanges(sku, finalState, product.shopify_id) const logs = mediaService.processMediaChanges(sku, finalState, product.shopify_id, jobId)
// Update Sheet Thumbnail (Top of Gallery)
try {
// Refresh state to get Shopify CDN URLs
const latestState = mediaService.getUnifiedMediaState(sku, product.shopify_id);
const sorted = latestState.sort((a, b) => (a.galleryOrder || 0) - (b.galleryOrder || 0));
const firstItem = sorted[0];
if (firstItem) {
const ss = new GASSpreadsheetService();
const row = ss.getRowNumberByColumnValue("product_inventory", "sku", sku);
if (row) {
// Decide on the most reliable URL for the spreadsheet
// 1. If it's a synced Shopify item, use the Shopify preview image URL (public)
// 2. Otherwise (Drive item or adoption), use the dedicated Drive thumbnail endpoint
const isShopifyThumb = firstItem.thumbnail && firstItem.thumbnail.startsWith('http');
const driveThumbUrl = `https://drive.google.com/thumbnail?id=${firstItem.driveId}&sz=w400`;
const thumbUrl = isShopifyThumb ? firstItem.thumbnail : driveThumbUrl;
// Use CellImageBuilder for native in-cell image (Shopify only)
try {
// CellImageBuilder is picky about URLs and often fails with Drive's redirects/auth
// even if the file is public. Formula-based IMAGE() is more robust for Drive.
if (!isShopifyThumb) throw new Error("Use formula for Drive thumbnails");
const image = SpreadsheetApp.newCellImage()
.setSourceUrl(thumbUrl)
.setAltTextTitle(sku)
.setAltTextDescription(`Thumbnail for ${sku}`)
.build();
ss.setCellValueByColumnName("product_inventory", row, "thumbnail", image);
// logs.push(`Updated sheet thumbnail for SKU ${sku}`); // Logs array is static now, won't stream this unless we refactor sheet update to use log() too. User cares mostly about main process.
} catch (builderErr) {
// Fallback to formula
ss.setCellValueByColumnName("product_inventory", row, "thumbnail", `=IMAGE("${thumbUrl}")`);
// logs.push(`Updated sheet thumbnail (Formula) for SKU ${sku}`);
}
} else {
// logs.push(`Warning: Could not find row for SKU ${sku} to update thumbnail.`);
}
}
} catch (e) {
console.warn("Failed to update sheet thumbnail", e);
// logs.push(`Warning: Failed to update sheet thumbnail: ${e.message}`);
}
return logs
}
export function pollJobLogs(jobId: string): string[] {
try {
const cache = CacheService.getDocumentCache();
const json = cache.get(`job_logs_${jobId}`);
return json ? JSON.parse(json) : [];
} catch(e) {
return [];
}
} }

View File

@ -48,6 +48,15 @@ describe("MediaService Robust Sync", () => {
removeFile: (f) => {} removeFile: (f) => {}
}) })
} as any } as any
// Mock CacheService for log streaming
global.CacheService = {
getDocumentCache: () => ({
get: (key) => null,
put: (k, v, t) => {},
remove: (k) => {}
})
} as any
}) })
test("Strict Matching: Only matches via property, ignores filename", () => { test("Strict Matching: Only matches via property, ignores filename", () => {
@ -304,4 +313,29 @@ describe("MediaService Robust Sync", () => {
expect(item.isProcessing).toBe(true) expect(item.isProcessing).toBe(true)
expect(item.thumbnail).toContain("data:image/svg+xml;base64") expect(item.thumbnail).toContain("data:image/svg+xml;base64")
}) })
test("Processing: Marks item as processing if Shopify status is PROCESSING", () => {
const folder = driveService.getOrCreateFolder("SKU_SHOP_PROCESS", "root")
// Drive File
const blob = { getName: () => "vid.mp4", getBytes: () => [], getMimeType: () => "video/mp4", getThumbnail: () => ({ getBytes: () => [] }) } as any
const f = driveService.saveFile(blob, folder.getId())
driveService.updateFileProperties(f.getId(), { shopify_media_id: "gid://shopify/Media/Proc1" })
// Shopify Media (Processing)
shopifyService.getProductMedia = jest.fn().mockReturnValue([
{
id: "gid://shopify/Media/Proc1",
filename: "vid.mp4",
mediaContentType: "VIDEO",
status: "PROCESSING",
preview: { image: { originalSrc: null } } // Preview might be missing during processing
}
])
const state = mediaService.getUnifiedMediaState("SKU_SHOP_PROCESS", "pid")
const item = state.find(s => s.id === f.getId())
expect(item.isProcessing).toBe(true)
})
}) })

View File

@ -24,11 +24,38 @@ export class MediaService {
private logToCache(jobId: string, message: string) {
if (!jobId) return;
try {
const cache = CacheService.getDocumentCache();
const key = `job_logs_${jobId}`;
const existing = cache.get(key);
let logs = existing ? JSON.parse(existing) : [];
logs.push(message);
// Expire in 10 minutes (plenty for a save operation)
cache.put(key, JSON.stringify(logs), 600);
} catch (e) {
console.warn("Retrying log to cache failed slightly", e);
}
}
getDiagnostics(sku: string, shopifyProductId: string) { getDiagnostics(sku: string, shopifyProductId: string) {
const results = { const results = {
drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null }, drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null },
shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null }, shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null },
matching: { status: 'pending', 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);
} }
// 1. Unsafe Drive Check // 1. Unsafe Drive Check
@ -106,6 +133,8 @@ export class MediaService {
if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id']; if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id'];
if (props['parent_video_id']) parentVideoId = props['parent_video_id']; if (props['parent_video_id']) parentVideoId = props['parent_video_id'];
console.log(`[DEBUG] File ${f.getName()} Props:`, JSON.stringify(props));
} catch (e) { } catch (e) {
console.warn(`Failed to get properties for ${f.getName()}`) console.warn(`Failed to get properties for ${f.getName()}`)
} }
@ -239,6 +268,21 @@ export class MediaService {
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`); console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
thumbnail = sidecarThumbMap.get(d.file.getId()) || ""; thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
isProcessing = true; // SHOW HOURGLASS (Request #3) isProcessing = true; // SHOW HOURGLASS (Request #3)
} else if (match && (
match.status === 'PROCESSING' ||
match.status === 'UPLOADED' ||
(match.mediaContentType === 'VIDEO' && (!match.sources || match.sources.length === 0) && match.status !== 'FAILED')
)) {
// Shopify Processing (Explicit Status OR Ready-but-missing-sources)
console.log(`[MediaService] Shopify Media is Processing: ${d.file.getName()} (Status: ${match.status}, Sources: ${match.sources ? match.sources.length : 0})`);
isProcessing = true;
// Use Drive thumb as fallback if Shopify preview not ready
if (!thumbnail) {
try {
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
if (nativeThumb.length > 100) thumbnail = nativeThumb;
} catch(e) {}
}
} else { } else {
// 2. Native / Fallback // 2. Native / Fallback
try { try {
@ -331,10 +375,24 @@ export class MediaService {
return { success: true }; return { success: true };
} }
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string): string[] { processMediaChanges(sku: string, finalState: any[], shopifyProductId: string, jobId: string | null = null): string[] {
const logs: string[] = [] const logs: string[] = []
logs.push(`Starting processing for SKU ${sku}`)
console.log(`MediaService: Processing changes for SKU ${sku}`) // Helper to log to both return array and cache
const log = (msg: string) => {
logs.push(msg);
console.log(msg);
if (jobId) this.logToCache(jobId, msg);
}
log(`Starting processing for SKU ${sku}`)
// Register Job
if (jobId) {
try {
CacheService.getDocumentCache().put(`active_job_${sku}`, jobId, 600);
} catch(e) { console.warn("Failed to register active job", e); }
}
// 0. Service Availability Check & Local Capture (Fixing 'undefined' context issues) // 0. Service Availability Check & Local Capture (Fixing 'undefined' context issues)
const shopifySvc = this.shopifyMediaService const shopifySvc = this.shopifyMediaService
@ -349,15 +407,14 @@ export class MediaService {
// 2. Process Deletions (Orphans not in final state are removed from Shopify) // 2. Process Deletions (Orphans not in final state are removed from Shopify)
const toDelete = currentState.filter(c => !finalIds.has(c.id)) const toDelete = currentState.filter(c => !finalIds.has(c.id))
if (toDelete.length === 0) logs.push("No deletions found.") if (toDelete.length === 0) log("No deletions found.")
toDelete.forEach(item => { toDelete.forEach(item => {
const msg = `Deleting item: ${item.filename}` const msg = `Deleting item: ${item.filename}`
logs.push(msg) log(msg)
console.log(msg)
if (item.shopifyId) { if (item.shopifyId) {
shopifySvc.productDeleteMedia(shopifyProductId, item.shopifyId) shopifySvc.productDeleteMedia(shopifyProductId, item.shopifyId)
logs.push(`- Deleted from Shopify (${item.shopifyId})`) log(`- Deleted from Shopify (${item.shopifyId})`)
} }
if (item.driveId) { if (item.driveId) {
// Check for Associated Sidecar Thumbs (Request #2) // Check for Associated Sidecar Thumbs (Request #2)
@ -372,14 +429,14 @@ export class MediaService {
const props = driveSvc.getFileProperties(item.driveId); const props = driveSvc.getFileProperties(item.driveId);
if (props && props['custom_thumbnail_id']) { if (props && props['custom_thumbnail_id']) {
driveSvc.trashFile(props['custom_thumbnail_id']); driveSvc.trashFile(props['custom_thumbnail_id']);
logs.push(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`); log(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`);
} }
} catch (ignore) { } catch (ignore) {
// If file already gone or other error // If file already gone or other error
} }
driveSvc.trashFile(item.driveId) driveSvc.trashFile(item.driveId)
logs.push(`- Trashed in Drive (${item.driveId})`) log(`- Trashed in Drive (${item.driveId})`)
} }
}) })
@ -389,8 +446,7 @@ export class MediaService {
finalState.forEach(item => { finalState.forEach(item => {
if (item.source === 'shopify_only' && item.shopifyId) { if (item.source === 'shopify_only' && item.shopifyId) {
const msg = `Adopting Orphan: ${item.filename}` const msg = `Adopting Orphan: ${item.filename}`
logs.push(msg) log(msg)
console.log(msg)
try { try {
// Download // Download
@ -416,9 +472,9 @@ export class MediaService {
// Update item refs for subsequent steps // Update item refs for subsequent steps
item.driveId = file.getId() item.driveId = file.getId()
item.source = 'synced' item.source = 'synced'
logs.push(`- Adopted to Drive (${file.getId()})`) log(`- Adopted to Drive (${file.getId()})`)
} catch (e) { } catch (e) {
logs.push(`- Failed to adopt ${item.filename}: ${e}`) log(`- Failed to adopt ${item.filename}: ${e}`)
} }
} }
}) })
@ -427,7 +483,7 @@ export class MediaService {
const toUpload = finalState.filter(item => item.source === 'drive_only' && item.driveId) const toUpload = finalState.filter(item => item.source === 'drive_only' && item.driveId)
if (toUpload.length > 0) { if (toUpload.length > 0) {
const msg = `Uploading ${toUpload.length} new items from Drive` const msg = `Uploading ${toUpload.length} new items from Drive`
logs.push(msg) log(msg)
const uploads = toUpload.map(item => { const uploads = toUpload.map(item => {
const f = driveSvc.getFileById(item.driveId) const f = driveSvc.getFileById(item.driveId)
return { return {
@ -454,7 +510,7 @@ export class MediaService {
if (stagedResp.userErrors && stagedResp.userErrors.length > 0) { if (stagedResp.userErrors && stagedResp.userErrors.length > 0) {
console.error("[MediaService] stagedUploadsCreate Errors:", JSON.stringify(stagedResp.userErrors)) console.error("[MediaService] stagedUploadsCreate Errors:", JSON.stringify(stagedResp.userErrors))
logs.push(`- Upload preparation failed: ${stagedResp.userErrors.map(e => e.message).join(', ')}`) log(`- Upload preparation failed: ${stagedResp.userErrors.map(e => e.message).join(', ')}`)
} }
const targets = stagedResp.stagedTargets const targets = stagedResp.stagedTargets
@ -463,7 +519,7 @@ export class MediaService {
uploads.forEach((u, i) => { uploads.forEach((u, i) => {
const target = targets[i] const target = targets[i]
if (!target || !target.url) { if (!target || !target.url) {
logs.push(`- Failed to get upload target for ${u.filename}: Invalid target`) log(`- Failed to get upload target for ${u.filename}: Invalid target`)
console.warn(`[MediaService] Missing target URL for ${u.filename}. Target:`, JSON.stringify(target)) console.warn(`[MediaService] Missing target URL for ${u.filename}. Target:`, JSON.stringify(target))
return return
} }
@ -490,7 +546,7 @@ export class MediaService {
driveSvc.updateFileProperties(originalItem.driveId, { shopify_media_id: m.id }) driveSvc.updateFileProperties(originalItem.driveId, { shopify_media_id: m.id })
originalItem.shopifyId = m.id originalItem.shopifyId = m.id
originalItem.source = 'synced' originalItem.source = 'synced'
logs.push(`- Created in Shopify (${m.id}) and linked`) log(`- Created in Shopify (${m.id}) and linked`)
} }
}) })
} }
@ -524,7 +580,7 @@ export class MediaService {
const timestamp = new Date().getTime() const timestamp = new Date().getTime()
const newName = `${sku}_${timestamp}.${ext}` const newName = `${sku}_${timestamp}.${ext}`
driveSvc.renameFile(item.driveId, newName) driveSvc.renameFile(item.driveId, newName)
logs.push(`- Renamed ${currentName} -> ${newName} (Non-conforming)`) log(`- Renamed ${currentName} -> ${newName} (Non-conforming)`)
} }
// C. Prepare Shopify Reorder // C. Prepare Shopify Reorder
@ -533,17 +589,25 @@ export class MediaService {
} }
} catch (e) { } catch (e) {
logs.push(`- Error updating ${item.filename}: ${e}`) log(`- Error updating ${item.filename}: ${e}`)
} }
}) })
// 6. Execute Shopify Reorder // 6. Execute Shopify Reorder
if (reorderMoves.length > 0) { if (reorderMoves.length > 0) {
shopifySvc.productReorderMedia(shopifyProductId, reorderMoves) shopifySvc.productReorderMedia(shopifyProductId, reorderMoves)
logs.push("Reordered media in Shopify.") log("Reordered media in Shopify.")
}
log("Processing Complete.")
// Clear Job (Success)
if (jobId) {
try {
CacheService.getDocumentCache().remove(`active_job_${sku}`);
} catch(e) {}
} }
logs.push("Processing Complete.")
return logs return logs
} }
} }

View File

@ -73,6 +73,7 @@ export class ShopifyMediaService implements IShopifyMediaService {
id id
alt alt
mediaContentType mediaContentType
status
preview { preview {
image { image {
originalSrc originalSrc

Binary file not shown.