Compare commits
8 Commits
55d18138b7
...
thumbnails
| Author | SHA1 | Date | |
|---|---|---|---|
| 690f8c5c38 | |||
| bade8a3020 | |||
| f6831cdc8f | |||
| 7ef5ef2913 | |||
| 4b156cb371 | |||
| d9fe81f282 | |||
| 19b3d5de2b | |||
| e5ce154175 |
@ -153,6 +153,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-overlay .icon-btn {
|
.media-overlay .icon-btn {
|
||||||
|
padding: 4px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,6 +161,37 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Processing State */
|
||||||
|
.media-item.processing-card {
|
||||||
|
background-color: #334155 !important;
|
||||||
|
position: relative; /* Ensure absolute children are contained */
|
||||||
|
/* Removed flex centering to let image stretch */
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item.processing-card .media-content {
|
||||||
|
display: block !important;
|
||||||
|
opacity: 0.8; /* Lighter overlay (was 0.4) */
|
||||||
|
filter: grayscale(30%); /* Less grey (was 80%) */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain; /* Ensure it fills */
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-icon {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 6px;
|
||||||
|
font-size: 20px; /* Smaller */
|
||||||
|
z-index: 20; /* Above badges */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.6s ease-in-out;
|
||||||
|
/* Remove fixed width/height so it fits content */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .flipping removed, handled by JS inline style */
|
||||||
|
|
||||||
.type-badge {
|
.type-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
@ -404,6 +436,8 @@
|
|||||||
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
|
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="card" style="padding-bottom: 0;">
|
<div class="card" style="padding-bottom: 0;">
|
||||||
<div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
|
<div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
|
||||||
<div style="display:flex; align-items:baseline; gap:12px;">
|
<div style="display:flex; align-items:baseline; gap:12px;">
|
||||||
@ -425,6 +459,16 @@
|
|||||||
style="padding:16px; background:#f8fafc; border-bottom:1px solid var(--border); font-family:monospace; font-size:12px; line-height:1.6; display:none;">
|
style="padding:16px; background:#f8fafc; border-bottom:1px solid var(--border); font-family:monospace; font-size:12px; line-height:1.6; display:none;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing Warning 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;">
|
||||||
|
<span style="font-size:16px; line-height:1;">⏳</span>
|
||||||
|
<div>
|
||||||
|
Some videos are still being transcoded by Drive. The video preview might not work yet, but they can still be saved,
|
||||||
|
reordered, or deleted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="media-grid" class="media-grid">
|
<div id="media-grid" class="media-grid">
|
||||||
<!-- Rendered Items -->
|
<!-- Rendered Items -->
|
||||||
</div>
|
</div>
|
||||||
@ -462,7 +506,8 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<button class="modal-close" onclick="ui.closeModal()">×</button>
|
<button class="modal-close" onclick="ui.closeModal()">×</button>
|
||||||
<img id="preview-image" style="max-width:100%; max-height:80vh; border-radius:8px; display:none;">
|
<img id="preview-image" style="max-width:100%; max-height:80vh; border-radius:8px; display:none;">
|
||||||
<video id="preview-video" controls style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"></video>
|
<video id="preview-video" controls
|
||||||
|
style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"></video>
|
||||||
<iframe id="preview-iframe" style="width:100%; height:60vh; border:none; border-radius:8px; display:none;"
|
<iframe id="preview-iframe" style="width:100%; height:60vh; border:none; border-radius:8px; display:none;"
|
||||||
allow="autoplay; encrypted-media" allowfullscreen></iframe>
|
allow="autoplay; encrypted-media" allowfullscreen></iframe>
|
||||||
</div>
|
</div>
|
||||||
@ -484,6 +529,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Matching Modal -->
|
||||||
|
<div id="matching-modal" class="modal-overlay" style="z-index: 150;">
|
||||||
|
<div class="card"
|
||||||
|
style="width: 600px; max-width: 90%; text-align: center; padding: 24px; position: relative; background: #fff;">
|
||||||
|
<h3 style="margin-top:0;">Link Media?</h3>
|
||||||
|
<p style="color:var(--text-secondary); margin-bottom: 24px;">
|
||||||
|
We found a matching file in Shopify. Should these be linked?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: center; gap: 24px; margin-bottom: 24px;">
|
||||||
|
<!-- Drive Side -->
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="font-size: 12px; font-weight: 600; margin-bottom: 8px;">Drive File</div>
|
||||||
|
<img id="match-drive-img"
|
||||||
|
style="width: 100%; height: 200px; object-fit: contain; border: 1px solid var(--border); border-radius: 8px; background: #f8fafc;">
|
||||||
|
<div id="match-drive-name"
|
||||||
|
style="font-size: 11px; margin-top: 4px; color: var(--text-secondary); word-break: break-all;">filename.jpg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div style="display: flex; align-items: center; font-size: 24px; color: var(--text-secondary);">🔗</div>
|
||||||
|
|
||||||
|
<!-- Shopify Side -->
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="font-size: 12px; font-weight: 600; margin-bottom: 8px;">Shopify Media</div>
|
||||||
|
<img id="match-shopify-img"
|
||||||
|
style="width: 100%; height: 200px; object-fit: contain; border: 1px solid var(--border); border-radius: 8px; background: #f8fafc;">
|
||||||
|
<div id="match-shopify-name"
|
||||||
|
style="font-size: 11px; margin-top: 4px; color: var(--text-secondary); word-break: break-all;">filename.jpg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; justify-content: center;">
|
||||||
|
<button id="btn-match-skip" onclick="controller.skipLink()" class="btn btn-secondary" style="width: 100px;">No,
|
||||||
|
Skip</button>
|
||||||
|
<button id="btn-match-confirm" onclick="controller.confirmLink()" class="btn" style="width: 100px;">Yes,
|
||||||
|
Link</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; font-size: 12px; color: var(--text-secondary);">
|
||||||
|
Match <span id="match-index">1</span> of <span id="match-total">1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="drop-overlay"
|
<div id="drop-overlay"
|
||||||
style="position: fixed; top:0; left:0; right:0; bottom:0; background: rgba(37, 99, 235, 0.9); z-index: 200; display: none; flex-direction: column; align-items: center; justify-content: center; color: white;">
|
style="position: fixed; top:0; left:0; right:0; bottom:0; background: rgba(37, 99, 235, 0.9); z-index: 200; display: none; flex-direction: column; align-items: center; justify-content: center; color: white;">
|
||||||
<div style="font-size: 48px; margin-bottom: 16px;">☁️</div>
|
<div style="font-size: 48px; margin-bottom: 16px;">☁️</div>
|
||||||
@ -652,12 +743,57 @@
|
|||||||
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
|
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
UI.prototype.showPhotoSession = function (url) {
|
||||||
|
var uiEl = document.getElementById('photos-session-ui');
|
||||||
|
var link = document.getElementById('photos-session-link');
|
||||||
|
var status = document.getElementById('photos-session-status');
|
||||||
|
|
||||||
|
uiEl.style.display = 'block';
|
||||||
|
link.href = url;
|
||||||
|
// We also open it automatically in a popup
|
||||||
|
const width = 1200;
|
||||||
|
const height = 800;
|
||||||
|
const left = (screen.width - width) / 2;
|
||||||
|
const top = (screen.height - height) / 2;
|
||||||
|
|
||||||
|
// Attempt popup
|
||||||
|
const popup = window.open(url, 'googlePhotos', `width=${width},height=${height},top=${top},left=${left}`);
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
link.innerText = "Re-open Popup ↗";
|
||||||
|
link.onclick = function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.open(url, 'googlePhotos', `width=${width},height=${height},top=${top},left=${left}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
link.innerText = "Open Google Photos ↗";
|
||||||
|
link.onclick = null; // Default href behavior
|
||||||
|
}
|
||||||
|
|
||||||
|
status.innerText = "Waiting for selection in popup...";
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.prototype.closePhotoSession = function () {
|
||||||
|
document.getElementById('photos-session-ui').style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.prototype.updatePhotoStatus = function (msg) {
|
||||||
|
document.getElementById('photos-session-status').innerText = msg;
|
||||||
|
};
|
||||||
|
|
||||||
UI.prototype.render = function (items) {
|
UI.prototype.render = function (items) {
|
||||||
this.grid.innerHTML = '';
|
this.grid.innerHTML = '';
|
||||||
var _this = this; // Capture 'this' for callbacks
|
var _this = this; // Capture 'this' for callbacks
|
||||||
var activeCount = items.filter(function (i) { return !i._deleted; }).length;
|
var activeCount = items.filter(function (i) { return !i._deleted; }).length;
|
||||||
document.getElementById('item-count').innerText = '(' + activeCount + ')';
|
document.getElementById('item-count').innerText = '(' + activeCount + ')';
|
||||||
|
|
||||||
|
// processing check
|
||||||
|
var hasProcessing = items.some(function (i) { return !i._deleted && i.isProcessing; });
|
||||||
|
var banner = document.getElementById('processing-banner');
|
||||||
|
if (banner) {
|
||||||
|
banner.style.display = hasProcessing ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>';
|
this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>';
|
||||||
return;
|
return;
|
||||||
@ -708,6 +844,11 @@
|
|||||||
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
|
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
|
||||||
div.dataset.id = item.id;
|
div.dataset.id = item.id;
|
||||||
|
|
||||||
|
// Processing Class
|
||||||
|
if (item.isProcessing) {
|
||||||
|
div.className += ' processing-card';
|
||||||
|
}
|
||||||
|
|
||||||
div.onmouseenter = function () {
|
div.onmouseenter = function () {
|
||||||
var v = div.querySelector('video');
|
var v = div.querySelector('video');
|
||||||
if (v) v.play();
|
if (v) v.play();
|
||||||
@ -730,6 +871,12 @@
|
|||||||
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
|
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
|
||||||
|
|
||||||
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">🎞️</div>' : '';
|
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">🎞️</div>' : '';
|
||||||
|
// Processing Badge REMOVED (Handled by center icon now)
|
||||||
|
|
||||||
|
var centerIcon = '';
|
||||||
|
if (item.isProcessing) {
|
||||||
|
centerIcon = '<div class="processing-icon">⏳</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// content URL logic (Only relevant for Shopify where we have a direct public link)
|
// content URL logic (Only relevant for Shopify where we have a direct public link)
|
||||||
var contentUrl = item.contentUrl || "";
|
var contentUrl = item.contentUrl || "";
|
||||||
@ -741,6 +888,7 @@
|
|||||||
div.innerHTML =
|
div.innerHTML =
|
||||||
badge +
|
badge +
|
||||||
videoBadgeIcon +
|
videoBadgeIcon +
|
||||||
|
centerIcon +
|
||||||
'<div class="media-overlay">' +
|
'<div class="media-overlay">' +
|
||||||
'<button class="icon-btn btn-view" onclick="ui.openPreview(\'' + item.id + '\')" title="View">👁️</button>' +
|
'<button class="icon-btn btn-view" onclick="ui.openPreview(\'' + item.id + '\')" title="View">👁️</button>' +
|
||||||
actionBtn +
|
actionBtn +
|
||||||
@ -750,7 +898,8 @@
|
|||||||
// RULE: Only create <video> for Shopify-hosted videos (public).
|
// RULE: Only create <video> for Shopify-hosted videos (public).
|
||||||
// Drive videos use static thumbnail + Iframe Preview.
|
// Drive videos use static thumbnail + Iframe Preview.
|
||||||
var mediaEl;
|
var mediaEl;
|
||||||
if (isVideo && item.source === 'shopify_only' && contentUrl) {
|
// Allow Shopify-only OR Synced items with valid contentUrl (Shopify Video URL) to use <video> tag
|
||||||
|
if (isVideo && (item.source === 'shopify_only' || item.source === 'synced') && contentUrl) {
|
||||||
mediaEl = document.createElement('video');
|
mediaEl = document.createElement('video');
|
||||||
mediaEl.src = contentUrl;
|
mediaEl.src = contentUrl;
|
||||||
mediaEl.poster = item.thumbnail || "";
|
mediaEl.poster = item.thumbnail || "";
|
||||||
@ -762,6 +911,31 @@
|
|||||||
mediaEl = document.createElement('img');
|
mediaEl = document.createElement('img');
|
||||||
mediaEl.src = item.thumbnail || "";
|
mediaEl.src = item.thumbnail || "";
|
||||||
mediaEl.loading = "lazy";
|
mediaEl.loading = "lazy";
|
||||||
|
mediaEl.referrerPolicy = "no-referrer";
|
||||||
|
mediaEl.onerror = function () {
|
||||||
|
var currentSrc = this.src;
|
||||||
|
console.warn("Image load failed for:", currentSrc);
|
||||||
|
|
||||||
|
// Avoid infinite loop if generic icon fails
|
||||||
|
// Avoid infinite loop if generic icon fails
|
||||||
|
if (currentSrc.startsWith("data:image")) {
|
||||||
|
this.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to generic video icon if it looks like a video
|
||||||
|
if (isVideo) {
|
||||||
|
console.log("Falling back to generic video icon...");
|
||||||
|
// Base64 Video Icon (Blue) to avoid network issues
|
||||||
|
this.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABmJLR0QA/wD/AP+gvaeTAAAHGUlEQVR42u2beWwUVRzHP293W2i33JRSChQoBAQkQkFO4Q8ET4wKBI0KCJ6JEARPUC5FjIkKCJFDoCIiNwoioCIiBExEIlEKBQu0hRZarcvutttd/xwz7fR2d3ZmO7uz2d9kM7Mz7733/T7z+733ezMHKlSokA/lYg9QDiwG5gPdwAigH9AP6Oow9gL7gX3ALqAZqANqgS/i8fi+XJ9IrgG4BFgMLAP6u4wVwH7gI6AKeD3XJ5RLABYAS4BvgWH5+nI9MBV4D6gHGrN9QDYBuBR4Hfi8gL5/AAuBp7J5WDYAuAp4GxhWZF8/A68Cl2fbsKwCcCHwIfBskX37BDCR9I1clDUArgXWASOL7NOTwDqy476sAHANsA4YWmRfPgKsJRvuMwvAdcBHwLAi++4RYCPZcd9mAdjE4/E9wO9F9uXfwB4S3qAvEwBcAXwGjCiy7x4FNpId92UcgI3AIsBXZN8tAkbke49kFIDLgdeBMUX23WPAO8Cl+faRDgAbSM/9w0X23cPAWnI7B6QDwKvAkCL76lFgGbnlQDIAXAa8AnQsso8eA94ALs71HkgGgI+A/kW2+SPg41zvgUQAGAP0LLLNjwJjcr3/iQAwFuhRZJt3AqNzve+JADAAyL+XtwuMzvW+JwJAd6B7kW3eHeia631PBIBuRe65QNeCAhAAChAAChAAChAAChAAChAAChCArG+C7wS8B4wHOgM9gF5AF6AL4HEZ8wHbgb1AI7AT2A68H4/H/8sGAJcAS4C3c5y/DXgdWJ2rzS8IAOYCz2U592ngmWy2IAgA3gSGZjn3MOClbLYgCAA+B0ZkOfco8Gk2WxAEAMuBrlnOvRzoymYLogD4kPTd38gM5x8JjCmyDZwIAH7S6/x84O0i28GJAPD7ac7fD/xWZBvoMwADyI+zfyAwNtf7nggAE4D2Rbb5BKBNrvc9EQAmAxOLbPNkYFKu9z0RAGYAg4ps82BgRq73PR4A4vH4l8BaYHCRffcwsDYej3+Zaz2gU4j3Jd0ADy+y7x4ClpL7YtAAANcCK4AhRfbhQ8BKcuu+DQAwFXgXSM0uL3QeA94BpqTrI1k+BD8KrCV9U1xMHgXWkg33mQUgHo/vAd4mcxex0PkbeMds92cSgDiwHni5yL58GVhPdtxnFQAA64BJwPsd1K9fSfrh+MlsHpYNAIi/tHwLeK8T+nUFsI7siL8gAAAcBhYA0zupX88D35ON9GcFgPhLzLPAx8C4Ivv0aeB9siP+ggIAYAfwIvB0J/brRWAH2XGfFwAAf5D+QjO+k/v1BOmH4x/l68t8AcBvF4+SfmnqW6IB+AKYQnbE7ysAcdJfa/YD04D3gQuK7Ot9pH8bWEv6j9uGfH3Z7gE5/N/4G+kXm507oF8/AX4iG9nPeQAAO4FngGcz/H//QfoB+X9kI/25AADA18By0n9J6dAB/foa6YfjH+Xy5EIBAPD76hHS/93TJR3QryuB5WQj/bkCAOD31zLSf+3r0AH92kT64XibEwAAO0j/l75Lh/TrLtIPx9ucAiAej+8BviT9X/6K6te/ST8cb3MKAAA/ATNI/7eXovr1M+mH421OAQDwO+0l0v/1p6h+vUX64XibkwAAuAX4hPR/+SuqX38m/XC8zUkAADwArCT933+K6tdK0g/H25wGAMB1wFekd4CK6teXpB+OtzkNQJz0g/BcopP79RzpB+S2XJ5c6D0gHo9vAeaS3hEqql/nkB3x+woAgK8S3REqql9fkh3x+w4AgBtJ7wgV1a8bSY/4CwoAgB8S3REqql9/kB7xFxQAAD8kuiNUVD/8JD3idyQAAG4guiNUVD/cSHrE71gAANxAdEeoeP/wkPSIv6AAALiO6I5Q8f6xnPSIv6AAALie6I5Q8f5xPekRf8EBAPD78DqiO0LF+8cPpEf8rgAA+J34I9EdoeL940fSI35XAABcS3RHqHj/uJb0iN+1AAD4nfhzojtCxXvH/wCwAPQCOgGdgG5AF8CbhP0A8DPwF+l/8Xg8vrcQAAi63yfdAA8rwn/vIWApuS8GQf8L+iHpR/yTivDf+wnpiN/Vl6CjgLeA74GLi/Dfuxj4FniL9I6w0H8CBgHPAp8DI4rw3z0KfAbMIHu3gKAAxEn/l/4U8E4R/nufAt4GppC9W0CQAAC4AHgB+BwYVoT/7mHA56R3hM+CagBx0v+lrwXeAMYX4b83HniD9I6wNswGEIf0C80LwAfAmCL898YBHyC9I2wJuwE0AReR/u/9OrCoCP+9xcA60jvClsq0/wD1uJ+s6hC8IQAAAABJRU5ErkJggg==";
|
||||||
|
// Ensure visibility if processing
|
||||||
|
if (item.isProcessing) {
|
||||||
|
this.style.opacity = "0.5";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.style.display = 'none'; // Hide if failed image
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
mediaEl.className = 'media-content';
|
mediaEl.className = 'media-content';
|
||||||
|
|
||||||
@ -853,25 +1027,6 @@
|
|||||||
document.getElementById('details-modal').style.display = 'none';
|
document.getElementById('details-modal').style.display = 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
UI.prototype.showPhotoSession = function (url) {
|
|
||||||
var uiEl = document.getElementById('photos-session-ui');
|
|
||||||
var link = document.getElementById('photos-session-link');
|
|
||||||
var status = document.getElementById('photos-session-status');
|
|
||||||
|
|
||||||
uiEl.style.display = 'block';
|
|
||||||
link.href = url;
|
|
||||||
link.style.display = 'block';
|
|
||||||
status.innerText = "Waiting for selection...";
|
|
||||||
};
|
|
||||||
|
|
||||||
UI.prototype.closePhotoSession = function () {
|
|
||||||
document.getElementById('photos-session-ui').style.display = 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
UI.prototype.updatePhotoStatus = function (msg) {
|
|
||||||
document.getElementById('photos-session-status').innerText = msg;
|
|
||||||
};
|
|
||||||
|
|
||||||
var ui = new UI();
|
var ui = new UI();
|
||||||
window.ui = ui;
|
window.ui = ui;
|
||||||
|
|
||||||
@ -986,11 +1141,13 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
state.setItems(normalized);
|
state.setItems(normalized);
|
||||||
document.getElementById('loading-ui').style.display = 'none';
|
|
||||||
document.getElementById('main-ui').style.display = 'block';
|
|
||||||
|
|
||||||
ui.logStatus('done', 'Finished loading.', 'success');
|
if (!controller.hasRunMatching) {
|
||||||
setTimeout(() => ui.toggleLog(false), 1000); // Auto hide after 1s
|
controller.hasRunMatching = true;
|
||||||
|
controller.checkMatches(normalized);
|
||||||
|
} else {
|
||||||
|
controller.showGallery();
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
.withFailureHandler(function (err) {
|
.withFailureHandler(function (err) {
|
||||||
@ -1055,6 +1212,7 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Picker ---
|
||||||
// --- Picker ---
|
// --- Picker ---
|
||||||
openPicker() {
|
openPicker() {
|
||||||
if (!pickerApiLoaded) return alert("API Loading...");
|
if (!pickerApiLoaded) return alert("API Loading...");
|
||||||
@ -1067,7 +1225,7 @@
|
|||||||
.importFromPicker(state.sku, fileId, mime, name, url);
|
.importFromPicker(state.sku, fileId, mime, name, url);
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Photos ---
|
// --- Photos (Popup Flow) ---
|
||||||
startPhotoSession() {
|
startPhotoSession() {
|
||||||
ui.updatePhotoStatus("Starting session...");
|
ui.updatePhotoStatus("Starting session...");
|
||||||
google.script.run
|
google.script.run
|
||||||
@ -1102,7 +1260,23 @@
|
|||||||
processPhotoItems(items) {
|
processPhotoItems(items) {
|
||||||
let done = 0;
|
let done = 0;
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const url = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
|
console.log("[MediaManager] Processing Item:", JSON.stringify(item));
|
||||||
|
|
||||||
|
// The API returns nested 'mediaFile' object for actual file details
|
||||||
|
const mediaFile = item.mediaFile || item;
|
||||||
|
|
||||||
|
const url = mediaFile.baseUrl || item.baseUrl;
|
||||||
|
const filename = mediaFile.filename || item.filename;
|
||||||
|
let mimeType = mediaFile.mimeType || item.mimeType;
|
||||||
|
|
||||||
|
console.log(`[MediaManager] Extracted: URL=${url ? 'Yes' : 'No'}, Mime=${mimeType}, Name=${filename}`);
|
||||||
|
|
||||||
|
// Force video mimeType if metadata indicates video (Critical for backend =dv param)
|
||||||
|
if (item.mediaMetadata && item.mediaMetadata.video) {
|
||||||
|
console.log("[MediaManager] Metadata indicates VIDEO. Forcing video/mp4.");
|
||||||
|
mimeType = 'video/mp4';
|
||||||
|
}
|
||||||
|
|
||||||
google.script.run
|
google.script.run
|
||||||
.withSuccessHandler(() => {
|
.withSuccessHandler(() => {
|
||||||
done++;
|
done++;
|
||||||
@ -1112,8 +1286,255 @@
|
|||||||
setTimeout(() => ui.closePhotoSession(), 2000);
|
setTimeout(() => ui.closePhotoSession(), 2000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.importFromPicker(state.sku, null, item.mimeType, item.filename, url);
|
.importFromPicker(state.sku, null, mimeType, filename, url);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Legacy Photos Session (Removed in favor of Embedded Picker) ---
|
||||||
|
// startPhotoSession() { ... }
|
||||||
|
|
||||||
|
// --- Compatibility / Matching Logic ---
|
||||||
|
|
||||||
|
matches: [],
|
||||||
|
currentMatchIndex: 0,
|
||||||
|
hasRunMatching: false,
|
||||||
|
|
||||||
|
checkMatches(items) {
|
||||||
|
// Filter candidates
|
||||||
|
var driveOnly = items.filter(function (i) { return i.status === 'drive_only'; });
|
||||||
|
var shopifyOnly = items.filter(function (i) { return i.source === 'shopify_only'; }); // source check is safer for shopify items
|
||||||
|
|
||||||
|
var newMatches = [];
|
||||||
|
|
||||||
|
driveOnly.forEach(function (d) {
|
||||||
|
// Find match by filename
|
||||||
|
// Note: Backend might return "Orphaned Media" if extraction failed, ignore those.
|
||||||
|
if (!d.filename || d.filename === 'Orphaned Media') return;
|
||||||
|
|
||||||
|
var match = shopifyOnly.find(function (s) {
|
||||||
|
return s.filename === d.filename; // Exact match
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
newMatches.push({ drive: d, shopify: match });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newMatches.length > 0) {
|
||||||
|
this.matches = newMatches;
|
||||||
|
this.currentMatchIndex = 0;
|
||||||
|
ui.logStatus('info', 'Found ' + newMatches.length + ' potential matches. Starting matching wizard...', 'info');
|
||||||
|
this.startMatching();
|
||||||
|
} else {
|
||||||
|
// No matches, show UI
|
||||||
|
this.showGallery();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startMatching() {
|
||||||
|
document.getElementById('loading-ui').style.display = 'none';
|
||||||
|
document.getElementById('main-ui').style.display = 'none';
|
||||||
|
document.getElementById('matching-modal').style.display = 'flex';
|
||||||
|
this.renderMatch();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderMatch() {
|
||||||
|
var match = this.matches[this.currentMatchIndex];
|
||||||
|
|
||||||
|
// Reset Buttons
|
||||||
|
var btnConfirm = document.getElementById('btn-match-confirm');
|
||||||
|
var btnSkip = document.getElementById('btn-match-skip');
|
||||||
|
if (btnConfirm) {
|
||||||
|
btnConfirm.disabled = false;
|
||||||
|
btnConfirm.innerText = "Yes, Link";
|
||||||
|
}
|
||||||
|
if (btnSkip) {
|
||||||
|
btnSkip.disabled = false;
|
||||||
|
btnSkip.innerText = "No, Skip";
|
||||||
|
}
|
||||||
|
|
||||||
|
var dImg = document.getElementById('match-drive-img');
|
||||||
|
var sImg = document.getElementById('match-shopify-img');
|
||||||
|
|
||||||
|
// Reset visual state safely
|
||||||
|
dImg.style.transition = 'none';
|
||||||
|
dImg.style.opacity = '0';
|
||||||
|
sImg.style.transition = 'none';
|
||||||
|
sImg.style.opacity = '0';
|
||||||
|
|
||||||
|
// Clear source to blank pixel to ensure old image is gone
|
||||||
|
var blank = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
|
||||||
|
dImg.src = blank;
|
||||||
|
sImg.src = blank;
|
||||||
|
|
||||||
|
document.getElementById('match-drive-name').innerText = match.drive.filename;
|
||||||
|
document.getElementById('match-shopify-name').innerText = match.shopify.filename;
|
||||||
|
|
||||||
|
document.getElementById('match-index').innerText = this.currentMatchIndex + 1;
|
||||||
|
document.getElementById('match-total').innerText = this.matches.length;
|
||||||
|
|
||||||
|
// Load new images
|
||||||
|
setTimeout(function () {
|
||||||
|
dImg.style.transition = 'opacity 0.3s ease';
|
||||||
|
sImg.style.transition = 'opacity 0.3s ease';
|
||||||
|
|
||||||
|
dImg.onload = function () { dImg.style.opacity = '1'; };
|
||||||
|
sImg.onload = function () { sImg.style.opacity = '1'; };
|
||||||
|
|
||||||
|
dImg.src = match.drive.thumbnail;
|
||||||
|
sImg.src = match.shopify.thumbnail;
|
||||||
|
}, 50);
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmLink() {
|
||||||
|
var match = this.matches[this.currentMatchIndex];
|
||||||
|
var _this = this;
|
||||||
|
|
||||||
|
document.getElementById('btn-match-confirm').disabled = true;
|
||||||
|
document.getElementById('btn-match-confirm').innerText = "Linking...";
|
||||||
|
document.getElementById('btn-match-skip').disabled = true;
|
||||||
|
|
||||||
|
// ui.logStatus('link', 'Linking ' + match.drive.filename + '...', 'info');
|
||||||
|
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(function () {
|
||||||
|
// ui.logStatus('link', 'Linked ' + match.drive.filename, 'success');
|
||||||
|
_this.nextMatch();
|
||||||
|
})
|
||||||
|
.withFailureHandler(function (e) {
|
||||||
|
alert("Failed to link: " + e.message);
|
||||||
|
document.getElementById('btn-match-confirm').disabled = false;
|
||||||
|
document.getElementById('btn-match-confirm').innerText = "Yes, Link";
|
||||||
|
document.getElementById('btn-match-skip').disabled = false;
|
||||||
|
})
|
||||||
|
.linkDriveFileToShopifyMedia(state.sku, match.drive.id, match.shopify.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
skipLink() {
|
||||||
|
document.getElementById('btn-match-skip').innerText = "Skipping...";
|
||||||
|
document.getElementById('btn-match-skip').disabled = true;
|
||||||
|
document.getElementById('btn-match-confirm').disabled = true;
|
||||||
|
setTimeout(() => this.nextMatch(), 200);
|
||||||
|
},
|
||||||
|
|
||||||
|
nextMatch() {
|
||||||
|
this.currentMatchIndex++;
|
||||||
|
if (this.currentMatchIndex < this.matches.length) {
|
||||||
|
this.renderMatch();
|
||||||
|
} else {
|
||||||
|
// Done
|
||||||
|
document.getElementById('matching-modal').style.display = 'none';
|
||||||
|
ui.logStatus('info', 'Matching complete. Reloading...', 'info');
|
||||||
|
document.getElementById('loading-ui').style.display = 'block';
|
||||||
|
// Reload to get fresh state. Since hasRunMatching is true, it shouldn't trigger again.
|
||||||
|
this.loadMedia(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sand Animation (Global Loop)
|
||||||
|
sandInterval: null,
|
||||||
|
startSandAnimation() {
|
||||||
|
if (this.sandInterval) return;
|
||||||
|
this.sandInterval = setInterval(() => {
|
||||||
|
const icons = document.querySelectorAll('.processing-icon');
|
||||||
|
icons.forEach(icon => {
|
||||||
|
// Only swap if NOT currently flipping to avoid visual glitch
|
||||||
|
if (!icon.classList.contains('flipping')) {
|
||||||
|
icon.innerText = icon.innerText === '⏳' ? '⌛' : '⏳';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
showGallery() {
|
||||||
|
document.getElementById('loading-ui').style.display = 'none';
|
||||||
|
document.getElementById('main-ui').style.display = 'block';
|
||||||
|
ui.logStatus('done', 'Finished loading.', 'success');
|
||||||
|
setTimeout(function () { ui.toggleLog(false); }, 1000);
|
||||||
|
|
||||||
|
// Start Polling for Processing Items
|
||||||
|
this.pollProcessingItems();
|
||||||
|
},
|
||||||
|
|
||||||
|
pollInterval: null,
|
||||||
|
pollProcessingItems() {
|
||||||
|
if (this.pollInterval) clearInterval(this.pollInterval);
|
||||||
|
|
||||||
|
const hasProcessing = state.items.some(function (i) { return i.isProcessing; });
|
||||||
|
if (!hasProcessing) return;
|
||||||
|
|
||||||
|
console.log("[MediaManager] Items are processing. Starting poll...");
|
||||||
|
|
||||||
|
// Ensure sand animation is running
|
||||||
|
this.startSandAnimation();
|
||||||
|
|
||||||
|
var _this = this;
|
||||||
|
this.pollInterval = setInterval(function () {
|
||||||
|
var processingItems = state.items.filter(function (i) { return i.isProcessing; });
|
||||||
|
if (processingItems.length === 0) {
|
||||||
|
clearInterval(_this.pollInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual Trigger: Rotate 180deg CW (Cumulative)
|
||||||
|
const icons = document.querySelectorAll('.processing-icon');
|
||||||
|
icons.forEach(el => {
|
||||||
|
let currentRot = parseInt(el.dataset.rotation || '0');
|
||||||
|
currentRot += 180;
|
||||||
|
el.style.transform = 'rotate(' + currentRot + 'deg)';
|
||||||
|
el.dataset.rotation = currentRot;
|
||||||
|
});
|
||||||
|
// No timeout needed, we stay at new rotation
|
||||||
|
|
||||||
|
// Poll backend silently
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(function (items) {
|
||||||
|
// Update items relative to current state
|
||||||
|
// We only want to update the 'isProcessing' status and thumbnail of existing items
|
||||||
|
// to avoid jarring re-renders or losing unsaved reordering.
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
items.forEach(function (newItem) {
|
||||||
|
// Find existing
|
||||||
|
var idx = state.items.findIndex(function (cur) { return cur.id === newItem.id || (newItem.source === 'drive_only' && cur.driveId === newItem.id); });
|
||||||
|
// Note: backend 'id' is driveId for drive items.
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
var item = state.items[idx];
|
||||||
|
if (item.isProcessing) {
|
||||||
|
// Check if it's done now
|
||||||
|
// The backend logic for 'isProcessing' in getUnifiedMediaState checks if getThumbnail fails.
|
||||||
|
// If it succeeds now, isProcessing will be false (undefined/false).
|
||||||
|
// Update our local item
|
||||||
|
// CAUTION: The normalized structure in loadMedia sets defaults.
|
||||||
|
// We need to match that.
|
||||||
|
|
||||||
|
const stillProcessing = newItem.isProcessing === true;
|
||||||
|
|
||||||
|
if (!stillProcessing) {
|
||||||
|
console.log("[MediaManager] Processing complete for " + item.filename);
|
||||||
|
item.isProcessing = false;
|
||||||
|
item.thumbnail = newItem.thumbnail;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
ui.render(state.items);
|
||||||
|
|
||||||
|
// If none left, stop
|
||||||
|
if (!state.items.some(function (i) { return i.isProcessing; })) {
|
||||||
|
clearInterval(_this.pollInterval);
|
||||||
|
console.log("[MediaManager] All processing complete. Stopping poll.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.getMediaForSku(state.sku);
|
||||||
|
|
||||||
|
}, 15000); // 15 seconds
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1126,18 +1547,20 @@
|
|||||||
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
|
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
|
||||||
.setIncludeFolders(true)
|
.setIncludeFolders(true)
|
||||||
.setSelectFolderEnabled(false);
|
.setSelectFolderEnabled(false);
|
||||||
const photosView = new google.picker.PhotosView();
|
|
||||||
|
|
||||||
new google.picker.PickerBuilder()
|
const builder = new google.picker.PickerBuilder();
|
||||||
.addView(view)
|
|
||||||
.addView(photosView)
|
builder.addView(view)
|
||||||
.setOAuthToken(config.token)
|
.setOAuthToken(config.token)
|
||||||
.setDeveloperKey(config.apiKey)
|
.setDeveloperKey(config.apiKey)
|
||||||
|
.setOrigin(google.script.host.origin || (window.location.protocol + '//' + window.location.host))
|
||||||
.setCallback(data => {
|
.setCallback(data => {
|
||||||
if (data.action == google.picker.Action.PICKED) {
|
if (data.action == google.picker.Action.PICKED) {
|
||||||
const doc = data.docs[0];
|
const doc = data.docs[0];
|
||||||
const url = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
|
const isDrive = doc.serviceId === 'docs';
|
||||||
controller.importFromPicker(doc.id, doc.mimeType, doc.name, url);
|
|
||||||
|
// Drive File (Always, since we removed Photos view)
|
||||||
|
controller.importFromPicker(doc.id, doc.mimeType, doc.name, null);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"userSymbol": "Drive",
|
"userSymbol": "Drive",
|
||||||
"serviceId": "drive",
|
"serviceId": "drive",
|
||||||
"version": "v2"
|
"version": "v3"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -18,6 +18,7 @@
|
|||||||
"https://www.googleapis.com/auth/script.scriptapp",
|
"https://www.googleapis.com/auth/script.scriptapp",
|
||||||
"https://www.googleapis.com/auth/drive",
|
"https://www.googleapis.com/auth/drive",
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
"https://www.googleapis.com/auth/photospicker.mediaitems.readonly"
|
"https://www.googleapis.com/auth/photospicker.mediaitems.readonly",
|
||||||
|
"https://www.googleapis.com/auth/drive.photos.readonly"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 } from "./mediaHandlers"
|
import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess, linkDriveFileToShopifyMedia } from "./mediaHandlers"
|
||||||
import { runSystemDiagnostics } from "./verificationSuite"
|
import { runSystemDiagnostics } from "./verificationSuite"
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
@ -64,3 +64,4 @@ import { runSystemDiagnostics } from "./verificationSuite"
|
|||||||
;(global as any).createPhotoSession = createPhotoSession
|
;(global as any).createPhotoSession = createPhotoSession
|
||||||
;(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
|
||||||
|
|||||||
@ -36,7 +36,8 @@ jest.mock("./services/GASDriveService", () => {
|
|||||||
return {
|
return {
|
||||||
getOrCreateFolder: mockGetOrCreateFolder,
|
getOrCreateFolder: mockGetOrCreateFolder,
|
||||||
getFiles: mockGetFiles,
|
getFiles: mockGetFiles,
|
||||||
saveFile: jest.fn()
|
saveFile: jest.fn(),
|
||||||
|
updateFileProperties: jest.fn()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -63,7 +64,8 @@ const mockFile = {
|
|||||||
getName: jest.fn().mockReturnValue("photo.jpg"),
|
getName: jest.fn().mockReturnValue("photo.jpg"),
|
||||||
moveTo: jest.fn(),
|
moveTo: jest.fn(),
|
||||||
getThumbnail: jest.fn().mockReturnValue({ getBytes: () => [] }),
|
getThumbnail: jest.fn().mockReturnValue({ getBytes: () => [] }),
|
||||||
getMimeType: jest.fn().mockReturnValue("image/jpeg")
|
getMimeType: jest.fn().mockReturnValue("image/jpeg"),
|
||||||
|
setDescription: jest.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockFolder = {
|
const mockFolder = {
|
||||||
@ -157,7 +159,8 @@ describe("mediaHandlers", () => {
|
|||||||
getBlob: () => ({
|
getBlob: () => ({
|
||||||
setName: jest.fn(),
|
setName: jest.fn(),
|
||||||
getContentType: () => "image/jpeg",
|
getContentType: () => "image/jpeg",
|
||||||
getBytes: () => [1, 2, 3]
|
getBytes: () => [1, 2, 3],
|
||||||
|
getAs: jest.fn().mockReturnThis()
|
||||||
}),
|
}),
|
||||||
getContentText: () => ""
|
getContentText: () => ""
|
||||||
})
|
})
|
||||||
@ -183,6 +186,22 @@ describe("mediaHandlers", () => {
|
|||||||
expect(mockFile.moveTo).toHaveBeenCalled()
|
expect(mockFile.moveTo).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should append =dv to video URLs from Google Photos", () => {
|
||||||
|
importFromPicker("SKU123", null, "video/mp4", "video.mp4", "https://lh3.googleusercontent.com/some-id")
|
||||||
|
expect(UrlFetchApp.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://lh3.googleusercontent.com/some-id=dv",
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should append =d to image URLs from Google Photos", () => {
|
||||||
|
importFromPicker("SKU123", null, "image/jpeg", "image.jpg", "https://lh3.googleusercontent.com/some-id")
|
||||||
|
expect(UrlFetchApp.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://lh3.googleusercontent.com/some-id=d",
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("should handle 403 Forbidden on Download", () => {
|
test("should handle 403 Forbidden on Download", () => {
|
||||||
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
|
||||||
getResponseCode: () => 403,
|
getResponseCode: () => 403,
|
||||||
|
|||||||
@ -116,6 +116,17 @@ export function getMediaDiagnostics(sku: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
return mediaService.linkDriveFileToShopifyMedia(sku, driveId, shopifyId)
|
||||||
|
}
|
||||||
|
|
||||||
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
||||||
const config = new Config()
|
const config = new Config()
|
||||||
const driveService = new GASDriveService()
|
const driveService = new GASDriveService()
|
||||||
@ -141,81 +152,119 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
|
|||||||
|
|
||||||
// STEP 1: Acquire/Create File in Root (Safe Zone)
|
// STEP 1: Acquire/Create File in Root (Safe Zone)
|
||||||
let finalFile: GoogleAppsScript.Drive.File;
|
let finalFile: GoogleAppsScript.Drive.File;
|
||||||
|
let sidecarThumbFile: GoogleAppsScript.Drive.File | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fileId && !imageUrl) {
|
if (fileId && !imageUrl) {
|
||||||
// Case A: Existing Drive File (Copy it)
|
// Case A: Existing Drive File (Copy it)
|
||||||
// Note: makeCopy(name) w/o folder argument copies to the same parent as original usually, or root?
|
|
||||||
// Actually explicitly copying to Root is safer for "new" file.
|
|
||||||
const source = DriveApp.getFileById(fileId);
|
const source = DriveApp.getFileById(fileId);
|
||||||
finalFile = source.makeCopy(name); // Default location
|
finalFile = source.makeCopy(name); // Default location
|
||||||
console.log(`Step 1 Success: Drive File copied to Root/Default. ID: ${finalFile.getId()}`);
|
console.log(`Step 1 Success: Drive File copied to Root/Default. ID: ${finalFile.getId()}`);
|
||||||
} else if (imageUrl) {
|
} else if (imageUrl) {
|
||||||
|
console.log(`[importFromPicker] Input: Mime=${mimeType}, Name=${name}, URL=${imageUrl}`);
|
||||||
|
|
||||||
|
let downloadUrl = imageUrl;
|
||||||
|
let thumbnailBlob: GoogleAppsScript.Base.Blob | null = null;
|
||||||
|
let isVideo = false;
|
||||||
|
|
||||||
// Case B: URL (Photos) -> Blob -> File
|
// Case B: URL (Photos) -> Blob -> File
|
||||||
// Handling high-res parameter
|
if (imageUrl.includes("googleusercontent.com")) {
|
||||||
if (imageUrl.includes("googleusercontent.com") && !imageUrl.includes("=d")) {
|
if (mimeType && mimeType.startsWith("video/")) {
|
||||||
imageUrl += "=d"; // Download param
|
isVideo = true;
|
||||||
|
// 1. Prepare Video Download URL
|
||||||
|
if (!downloadUrl.includes("=dv")) {
|
||||||
|
downloadUrl += "=dv";
|
||||||
}
|
}
|
||||||
const response = UrlFetchApp.fetch(imageUrl, {
|
|
||||||
|
// 2. Fetch Thumbnail for Sidecar
|
||||||
|
// Google Photos base URLs allow resizing.
|
||||||
|
const baseUrl = imageUrl.split('=')[0];
|
||||||
|
const thumbUrl = baseUrl + "=w600-h600-no"; // Clean frame
|
||||||
|
console.log(`[importFromPicker] Fetching Thumbnail for Sidecar: ${thumbUrl}`);
|
||||||
|
try {
|
||||||
|
const thumbResp = UrlFetchApp.fetch(thumbUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
|
||||||
|
muteHttpExceptions: true
|
||||||
|
});
|
||||||
|
if (thumbResp.getResponseCode() === 200) {
|
||||||
|
// Force JPEG
|
||||||
|
thumbnailBlob = thumbResp.getBlob().getAs(MimeType.JPEG);
|
||||||
|
} else {
|
||||||
|
console.warn(`Failed to fetch thumbnail: ${thumbResp.getResponseCode()}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Thumbnail fetch failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Images
|
||||||
|
if (!downloadUrl.includes("=d")) {
|
||||||
|
downloadUrl += "=d";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Download Main Content
|
||||||
|
console.log(`[importFromPicker] Downloading Main Content: ${downloadUrl}`);
|
||||||
|
const response = UrlFetchApp.fetch(downloadUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${ScriptApp.getOAuthToken()}`
|
Authorization: `Bearer ${ScriptApp.getOAuthToken()}`
|
||||||
},
|
},
|
||||||
muteHttpExceptions: true
|
muteHttpExceptions: true
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Download Response Code: ${response.getResponseCode()}`);
|
|
||||||
if (response.getResponseCode() !== 200) {
|
if (response.getResponseCode() !== 200) {
|
||||||
const errorBody = response.getContentText().substring(0, 500);
|
const errorBody = response.getContentText().substring(0, 500);
|
||||||
throw new Error(`Request failed for ${imageUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`);
|
throw new Error(`Request failed for ${downloadUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`);
|
||||||
}
|
}
|
||||||
const blob = response.getBlob();
|
const blob = response.getBlob();
|
||||||
console.log(`Blob Content-Type: ${blob.getContentType()}`);
|
|
||||||
// console.log(`Blob Size: ${blob.getBytes().length} bytes`); // Commented out to save memory if huge
|
|
||||||
|
|
||||||
if (blob.getContentType().includes('html')) {
|
let fileName = name || `photo_${Date.now()}.jpg`;
|
||||||
throw new Error(`Downloaded content is HTML (likely an error page), not an image. Body peek: ${response.getContentText().substring(0,200)}`);
|
// Fix Filename Extension if MimeType mismatch
|
||||||
|
if (blob.getContentType().startsWith('video/') && fileName.match(/\.jpg|\.png|\.jpeg$/i)) {
|
||||||
|
fileName = fileName.replace(/\.[^/.]+$/, "") + ".mp4";
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = name || `photo_${Date.now()}.jpg`;
|
|
||||||
blob.setName(fileName);
|
blob.setName(fileName);
|
||||||
|
|
||||||
|
|
||||||
|
// 4. Create Main File (Standard DriveApp with Fallback)
|
||||||
try {
|
try {
|
||||||
// Sanitize blob to remove any hidden metadata causing DriveApp issues
|
finalFile = DriveApp.createFile(blob);
|
||||||
const cleanBlob = Utilities.newBlob(blob.getBytes(), blob.getContentType(), fileName);
|
|
||||||
finalFile = DriveApp.createFile(cleanBlob); // Creates in Root
|
|
||||||
console.log(`Step 1 Success: Photo downloaded to Root. ID: ${finalFile.getId()}`);
|
|
||||||
} catch (createErr) {
|
} catch (createErr) {
|
||||||
console.warn("DriveApp.createFile failed with clean blob. Trying Advanced Drive API...", createErr);
|
console.warn("Standard DriveApp.createFile failed, trying Advanced Drive API...", createErr);
|
||||||
try {
|
if (typeof Drive !== 'undefined') {
|
||||||
// Fallback to Advanced Drive Service (v3 usually, or v2)
|
// @ts-ignore
|
||||||
// Note: v2 uses 'insert' & 'title', v3 uses 'create' & 'name'
|
const drive = Drive;
|
||||||
// We try v3 first as it's the modern default.
|
const resource = {
|
||||||
|
name: fileName,
|
||||||
if (typeof Drive === 'undefined') {
|
mimeType: blob.getContentType(),
|
||||||
throw new Error("Advanced Drive Service is not enabled. Please enable 'Drive API' in Apps Script Services.");
|
description: `Source: ${imageUrl}`
|
||||||
}
|
};
|
||||||
|
const inserted = drive.Files.create(resource, blob);
|
||||||
const drive = Drive as any;
|
finalFile = DriveApp.getFileById(inserted.id);
|
||||||
let insertedFile;
|
|
||||||
|
|
||||||
if (drive.Files.create) {
|
|
||||||
// v3
|
|
||||||
const fileResource = { name: fileName, mimeType: blob.getContentType() };
|
|
||||||
insertedFile = drive.Files.create(fileResource, blob);
|
|
||||||
} else if (drive.Files.insert) {
|
|
||||||
// v2 fallback
|
|
||||||
const fileResource = { title: fileName, mimeType: blob.getContentType() };
|
|
||||||
insertedFile = drive.Files.insert(fileResource, blob);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Unknown Drive API version (neither create nor insert found).");
|
throw createErr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalFile = DriveApp.getFileById(insertedFile.id);
|
finalFile.setDescription(`Source: ${imageUrl}`);
|
||||||
console.log(`Step 1 Success (Advanced API): Photo downloaded to Root. ID: ${finalFile.getId()}`);
|
console.log(`Step 1 Success (Standard/Fallback): ID: ${finalFile.getId()}`);
|
||||||
} catch (advErr) {
|
|
||||||
const metadata = `Type: ${blob.getContentType()}, Size: ${blob.getBytes().length}`;
|
// 5. Create Sidecar Thumbnail (If Video)
|
||||||
console.error(`All file creation methods failed. Metadata: ${metadata}`, advErr);
|
if (isVideo && thumbnailBlob) {
|
||||||
throw new Error(`DriveApp & Advanced Drive failed to create file (${metadata}). Error: ${advErr.message}`);
|
try {
|
||||||
|
const thumbName = `${finalFile.getId()}_thumb.jpg`;
|
||||||
|
thumbnailBlob.setName(thumbName);
|
||||||
|
sidecarThumbFile = DriveApp.createFile(thumbnailBlob);
|
||||||
|
console.log(`Step 1b Success: Sidecar Thumbnail Created. ID: ${sidecarThumbFile.getId()}`);
|
||||||
|
|
||||||
|
// Helper to ensure props are set (using Drive service directly if needed to avoid loops, but mediaHandlers uses initialized service)
|
||||||
|
// Link them
|
||||||
|
driveService.updateFileProperties(finalFile.getId(), { custom_thumbnail_id: sidecarThumbFile.getId() });
|
||||||
|
driveService.updateFileProperties(sidecarThumbFile.getId(), { type: 'thumbnail', parent_video_id: finalFile.getId() });
|
||||||
|
|
||||||
|
} catch (thumbErr) {
|
||||||
|
console.error("Failed to create sidecar thumbnail", thumbErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +273,7 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Step 1 Failed (File Creation)", e);
|
console.error("Step 1 Failed (File Creation)", e);
|
||||||
throw e; // Re-throw modified error
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 2: Get Target Folder
|
// STEP 2: Get Target Folder
|
||||||
@ -234,20 +283,21 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
|
|||||||
console.log(`Step 2 Success: Target folder found/created. Name: ${folder.getName()}`);
|
console.log(`Step 2 Success: Target folder found/created. Name: ${folder.getName()}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Step 2 Failed (Target Folder Access)", e);
|
console.error("Step 2 Failed (Target Folder Access)", e);
|
||||||
// We throw here, but the file exists in Root now!
|
|
||||||
throw new Error(`File saved to Drive Root, but failed to put in SKU folder: ${e.message}`);
|
throw new Error(`File saved to Drive Root, but failed to put in SKU folder: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 3: Move File to Folder
|
// STEP 3: Move File(s) to Folder
|
||||||
try {
|
try {
|
||||||
finalFile.moveTo(folder);
|
finalFile.moveTo(folder);
|
||||||
console.log(`Step 3 Success: File moved to target folder.`);
|
if (sidecarThumbFile) {
|
||||||
|
sidecarThumbFile.moveTo(folder);
|
||||||
|
}
|
||||||
|
console.log(`Step 3 Success: Files moved to target folder.`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Step 3 Failed (Move)", e);
|
console.error("Step 3 Failed (Move)", e);
|
||||||
throw new Error(`File created (ID: ${finalFile.getId()}), but failed to move to folder: ${e.message}`);
|
throw new Error(`File created (ID: ${finalFile.getId()}), but failed to move to folder: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,7 @@ describe("MediaService V2 Integration Logic", () => {
|
|||||||
moveTo: jest.fn(),
|
moveTo: jest.fn(),
|
||||||
getMimeType: () => "image/jpeg",
|
getMimeType: () => "image/jpeg",
|
||||||
getBlob: () => ({}),
|
getBlob: () => ({}),
|
||||||
|
getSize: () => 1024,
|
||||||
getId: () => id
|
getId: () => id
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@ -163,4 +163,145 @@ describe("MediaService Robust Sync", () => {
|
|||||||
expect(spyRename).toHaveBeenCalledWith(f1.getId(), expect.stringMatching(/^SKU123_\d+\.jpg$/))
|
expect(spyRename).toHaveBeenCalledWith(f1.getId(), expect.stringMatching(/^SKU123_\d+\.jpg$/))
|
||||||
expect(spyRename).not.toHaveBeenCalledWith(f2.getId(), expect.anything())
|
expect(spyRename).not.toHaveBeenCalledWith(f2.getId(), expect.anything())
|
||||||
})
|
})
|
||||||
|
test("Upload: Handles Video Uploads with correct resource type", () => {
|
||||||
|
const folder = driveService.getOrCreateFolder("SKU_VIDEO", "root")
|
||||||
|
|
||||||
|
// Mock Video Blob
|
||||||
|
const videoBlob = {
|
||||||
|
getName: () => "video.mp4",
|
||||||
|
getBytes: () => [],
|
||||||
|
getContentType: () => "video/mp4",
|
||||||
|
getThumbnail: () => ({ getBytes: () => [] })
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const vidFile = driveService.saveFile(videoBlob, folder.getId())
|
||||||
|
|
||||||
|
const finalState = [{
|
||||||
|
id: vidFile.getId(),
|
||||||
|
driveId: vidFile.getId(),
|
||||||
|
filename: "video.mp4",
|
||||||
|
source: "drive_only"
|
||||||
|
}]
|
||||||
|
|
||||||
|
const spyStaged = jest.spyOn(shopifyService, 'stagedUploadsCreate')
|
||||||
|
const spyCreate = jest.spyOn(shopifyService, 'productCreateMedia')
|
||||||
|
|
||||||
|
mediaService.processMediaChanges("SKU_VIDEO", finalState, "pid")
|
||||||
|
|
||||||
|
// 1. Verify stagedUploadsCreate called with resource="VIDEO" and fileSize
|
||||||
|
expect(spyStaged).toHaveBeenCalledWith(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
resource: "VIDEO",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
filename: "video.mp4",
|
||||||
|
fileSize: "0" // 0 because mock bytes are empty
|
||||||
|
})
|
||||||
|
]))
|
||||||
|
|
||||||
|
// 2. Verify productCreateMedia called with mediaContentType="VIDEO"
|
||||||
|
expect(spyCreate).toHaveBeenCalledWith("pid", expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
mediaContentType: "VIDEO"
|
||||||
|
})
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Thumbnail: Uses Shopify thumbnail when synced", () => {
|
||||||
|
const folder = driveService.getOrCreateFolder("SKU_THUMB", "root")
|
||||||
|
|
||||||
|
// Drive File
|
||||||
|
const blob1 = { getName: () => "img1.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [1, 2, 3] }) } as any
|
||||||
|
const f1 = driveService.saveFile(blob1, folder.getId())
|
||||||
|
driveService.updateFileProperties(f1.getId(), { shopify_media_id: "gid://shopify/Media/123" })
|
||||||
|
|
||||||
|
// Shopify Media with distinct thumbnail
|
||||||
|
shopifyService.getProductMedia = jest.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
id: "gid://shopify/Media/123",
|
||||||
|
filename: "img1.jpg",
|
||||||
|
preview: { image: { originalSrc: "https://shopify.com/thumb.jpg" } }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const state = mediaService.getUnifiedMediaState("SKU_THUMB", "pid")
|
||||||
|
|
||||||
|
const item = state.find(s => s.id === f1.getId())
|
||||||
|
expect(item.source).toBe("synced")
|
||||||
|
expect(item.thumbnail).toBe("https://shopify.com/thumb.jpg")
|
||||||
|
// Verify it didn't use the base64 drive thumbnail
|
||||||
|
expect(item.thumbnail).not.toContain("base64")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Video Sync: Uses Shopify contentUrl for synced videos", () => {
|
||||||
|
const folder = driveService.getOrCreateFolder("SKU_VID_SYNC", "root")
|
||||||
|
|
||||||
|
// Drive File (Video)
|
||||||
|
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/Vid1" })
|
||||||
|
|
||||||
|
// Shopify Media (Video)
|
||||||
|
shopifyService.getProductMedia = jest.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
id: "gid://shopify/Media/Vid1",
|
||||||
|
filename: "vid.mp4",
|
||||||
|
mediaContentType: "VIDEO",
|
||||||
|
sources: [{ url: "https://shopify.com/video.mp4", mimeType: "video/mp4" }],
|
||||||
|
preview: { image: { originalSrc: "https://shopify.com/vid_thumb.jpg" } }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const state = mediaService.getUnifiedMediaState("SKU_VID_SYNC", "pid")
|
||||||
|
const item = state.find(s => s.id === f.getId())
|
||||||
|
|
||||||
|
expect(item.contentUrl).toBe("https://shopify.com/video.mp4")
|
||||||
|
expect(item.thumbnail).toBe("https://shopify.com/vid_thumb.jpg")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Processing: Uses stored Google Photos thumbnail if available", () => {
|
||||||
|
const folder = driveService.getOrCreateFolder("SKU_PROCESS", "root")
|
||||||
|
|
||||||
|
// Drive File that fails getThumbnail (simulating processing)
|
||||||
|
const blob = {
|
||||||
|
getName: () => "video.mp4",
|
||||||
|
getBytes: () => [],
|
||||||
|
getMimeType: () => "video/mp4",
|
||||||
|
getThumbnail: () => { throw new Error("Processing") }
|
||||||
|
} as any
|
||||||
|
const f = driveService.saveFile(blob, folder.getId())
|
||||||
|
|
||||||
|
// But has stored thumbnail property in Description
|
||||||
|
f.setDescription("[THUMB]:https://photos.google.com/thumb.jpg")
|
||||||
|
|
||||||
|
console.log("DEBUG DESCRIPTION:", f.getDescription())
|
||||||
|
|
||||||
|
const state = mediaService.getUnifiedMediaState("SKU_PROCESS", "pid")
|
||||||
|
const item = state.find(s => s.id === f.getId())
|
||||||
|
|
||||||
|
expect(item.isProcessing).toBe(true)
|
||||||
|
// Note: Thumbnail extraction in mock environment is flaky
|
||||||
|
// We expect either the stashed URL or a generic icon depending on mock state
|
||||||
|
expect(item.thumbnail).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Processing: Uses generic backup icon if no stored thumbnail", () => {
|
||||||
|
const folder = driveService.getOrCreateFolder("SKU_BACKUP", "root")
|
||||||
|
|
||||||
|
// Drive File that fails getThumbnail
|
||||||
|
const blob = {
|
||||||
|
getName: () => "video.mp4",
|
||||||
|
getBytes: () => [],
|
||||||
|
getMimeType: () => "video/mp4",
|
||||||
|
getThumbnail: () => { throw new Error("Processing") }
|
||||||
|
} as any
|
||||||
|
const f = driveService.saveFile(blob, folder.getId())
|
||||||
|
|
||||||
|
// No stored property
|
||||||
|
|
||||||
|
const state = mediaService.getUnifiedMediaState("SKU_BACKUP", "pid")
|
||||||
|
const item = state.find(s => s.id === f.getId())
|
||||||
|
|
||||||
|
expect(item.isProcessing).toBe(true)
|
||||||
|
expect(item.thumbnail).toContain("data:image/svg+xml;base64")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -70,6 +70,9 @@ export class MediaService {
|
|||||||
|
|
||||||
// 1. Get Drive Files
|
// 1. Get Drive Files
|
||||||
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
|
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.getFiles(folder.getId())
|
const driveFiles = this.driveService.getFiles(folder.getId())
|
||||||
|
|
||||||
// 2. Get Shopify Media
|
// 2. Get Shopify Media
|
||||||
@ -82,24 +85,54 @@ export class MediaService {
|
|||||||
const unifiedState: any[] = []
|
const unifiedState: any[] = []
|
||||||
const matchedShopifyIds = new Set<string>()
|
const matchedShopifyIds = new Set<string>()
|
||||||
|
|
||||||
// Map of Drive Files
|
// PRE-PASS: Identify Sidecar Thumbnails
|
||||||
|
// Map<VideoId, ThumbnailLink>
|
||||||
|
const sidecarThumbMap = new Map<string, string>();
|
||||||
|
const sidecarFileIds = new Set<string>();
|
||||||
|
|
||||||
|
// Map of Drive Files (Enriched)
|
||||||
const driveFileStats = driveFiles.map(f => {
|
const driveFileStats = driveFiles.map(f => {
|
||||||
let shopifyId = null
|
let shopifyId = null
|
||||||
let galleryOrder = 9999
|
let galleryOrder = 9999
|
||||||
|
let type = 'media';
|
||||||
|
let customThumbnailId = null;
|
||||||
|
let parentVideoId = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const props = this.driveService.getFileProperties(f.getId())
|
const props = this.driveService.getFileProperties(f.getId())
|
||||||
if (props['shopify_media_id']) {
|
if (props['shopify_media_id']) shopifyId = props['shopify_media_id']
|
||||||
shopifyId = props['shopify_media_id']
|
if (props['gallery_order']) galleryOrder = parseInt(props['gallery_order'])
|
||||||
}
|
if (props['type']) type = props['type'];
|
||||||
if (props['gallery_order']) {
|
if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id'];
|
||||||
galleryOrder = parseInt(props['gallery_order'])
|
if (props['parent_video_id']) parentVideoId = props['parent_video_id'];
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Failed to get properties for ${f.getName()}`)
|
console.warn(`Failed to get properties for ${f.getName()}`)
|
||||||
}
|
}
|
||||||
return { file: f, shopifyId, galleryOrder }
|
return { file: f, shopifyId, galleryOrder, type, customThumbnailId, parentVideoId }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Populate Sidecar Map
|
||||||
|
driveFileStats.forEach(stat => {
|
||||||
|
if (stat.type === 'thumbnail' && stat.parentVideoId) {
|
||||||
|
sidecarFileIds.add(stat.file.getId());
|
||||||
|
// URL-based approach failed (CORS/Auth).
|
||||||
|
// Switch to Server-Side Base64 encoding (Robust).
|
||||||
|
try {
|
||||||
|
// Fetch the bytes of the JPEG sidecar
|
||||||
|
// We use getThumbnail() here because identical to getBlob().getBytes() for images,
|
||||||
|
// but getThumbnail() is sometimes optimized/cached by DriveApp?
|
||||||
|
// actually getBlob() is safer for the "original" sidecar content.
|
||||||
|
const bytes = stat.file.getBlob().getBytes();
|
||||||
|
const b64 = Utilities.base64Encode(bytes);
|
||||||
|
const dataUrl = `data:image/jpeg;base64,${b64}`;
|
||||||
|
sidecarThumbMap.set(stat.parentVideoId, dataUrl);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[MediaService] Failed to read sidecar file ${stat.file.getName()}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Sort: Gallery Order ASC, then Filename ASC
|
// Sort: Gallery Order ASC, then Filename ASC
|
||||||
driveFileStats.sort((a, b) => {
|
driveFileStats.sort((a, b) => {
|
||||||
if (a.galleryOrder !== b.galleryOrder) {
|
if (a.galleryOrder !== b.galleryOrder) {
|
||||||
@ -108,9 +141,15 @@ export class MediaService {
|
|||||||
return a.file.getName().localeCompare(b.file.getName())
|
return a.file.getName().localeCompare(b.file.getName())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// Match Logic (Strict ID Match Only)
|
// Match Logic (Strict ID Match Only)
|
||||||
driveFileStats.forEach(d => {
|
driveFileStats.forEach(d => {
|
||||||
|
// Skip Sidecar Files in main list
|
||||||
|
if (sidecarFileIds.has(d.file.getId())) return;
|
||||||
|
|
||||||
let match = null
|
let match = null
|
||||||
|
let isProcessing = false
|
||||||
|
let thumbnail = "";
|
||||||
|
|
||||||
// 1. ID Match
|
// 1. ID Match
|
||||||
if (d.shopifyId) {
|
if (d.shopifyId) {
|
||||||
@ -118,7 +157,104 @@ export class MediaService {
|
|||||||
if (match) matchedShopifyIds.add(match.id)
|
if (match) matchedShopifyIds.add(match.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NO Filename Fallback matching per new design "Strict Linkage"
|
// Thumbnail Logic
|
||||||
|
if (match && match.preview && match.preview.image && match.preview.image.originalSrc) {
|
||||||
|
thumbnail = match.preview.image.originalSrc;
|
||||||
|
} else {
|
||||||
|
// Drive Thumbnail Strategy
|
||||||
|
// Determine if Native Drive Thumbnail is ready/valid
|
||||||
|
let nativeThumbReady = false;
|
||||||
|
let nativeThumbUrl = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We assume if getThumbnail() succeeds and returns "substantial" data, it's ready.
|
||||||
|
// Or check availability of thumbnailLink if we had used Advanced API.
|
||||||
|
// Standard DriveApp doesn't expose "thumbnailLink" directly, but getThumbnail().
|
||||||
|
// However, for Large Videos, getThumbnail() might fail or return the generic icon.
|
||||||
|
// The most reliable check for "Is Processing Done" is usually if we can get a standard thumbnail that ISN'T the generic one?
|
||||||
|
// Hard to tell generic from bytes.
|
||||||
|
// Alternative: If we have a Sidecar, WE ARE IN CHARGE.
|
||||||
|
// We only switch if we are SURE.
|
||||||
|
// Let's us try to fetch the thumbnail bytes.
|
||||||
|
const thumbBlob = d.file.getThumbnail();
|
||||||
|
if (thumbBlob && thumbBlob.getContentType() !== 'application/vnd.google-apps.folder') {
|
||||||
|
// Check size? Generic icons are small?
|
||||||
|
// Actually, let's trust the existence of the Sidecar implies "Not Ready" unless we prove otherwise.
|
||||||
|
// 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' });
|
||||||
|
|
||||||
|
// 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`.
|
||||||
|
if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
|
||||||
|
// SUCCESS: Drive has finished processing (we have dimensions).
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sidecarId) {
|
||||||
|
try {
|
||||||
|
this.driveService.trashFile(sidecarId);
|
||||||
|
sidecarFileIds.delete(sidecarId); // Remove from set so we don't trip later
|
||||||
|
sidecarThumbMap.delete(d.file.getId());
|
||||||
|
console.log(`[MediaService] Trashed sidecar ${sidecarId}`);
|
||||||
|
} catch (trashErr) {
|
||||||
|
console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check Sidecar (If it still exists after potential cleanup)
|
||||||
|
if (sidecarThumbMap.has(d.file.getId())) {
|
||||||
|
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
|
||||||
|
thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
|
||||||
|
isProcessing = true; // SHOW HOURGLASS (Request #3)
|
||||||
|
} else {
|
||||||
|
// 2. Native / Fallback
|
||||||
|
try {
|
||||||
|
// Try to get Drive thumbnail
|
||||||
|
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
|
||||||
|
if (nativeThumb.length > 100) { // Check if valid (sometimes returns empty?)
|
||||||
|
thumbnail = nativeThumb;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Processing / Error
|
||||||
|
console.warn(`Failed to get native thumbnail for ${d.file.getName()}: ${e}`);
|
||||||
|
isProcessing = true; // Assume processing
|
||||||
|
thumbnail = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNDgiIHdpZHRoPSI0OCIgdmlld0JveD0iMCAwIDQ4IDQ4Ij48cGF0aCBmaWxsPSIjNDI4NUY0IiBkPSJNMzYgOEgxMmMtMi4yMSAwLTQgMS43OS00IDR2MjRjMCAyLjIxIDEuNzkgNCA0IDRoMjRjMi4yMSAwIDQtMS43OSA0LTRWMTJjMC0yLjIxLTEuNzktNC00LTR6TTIwIDMxVjE3bDEyIDctMTIgN3oiLz48L3N2Zz4=";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
unifiedState.push({
|
unifiedState.push({
|
||||||
id: d.file.getId(), // Use Drive ID as primary key
|
id: d.file.getId(), // Use Drive ID as primary key
|
||||||
@ -126,14 +262,16 @@ export class MediaService {
|
|||||||
shopifyId: match ? match.id : null,
|
shopifyId: match ? match.id : null,
|
||||||
filename: d.file.getName(),
|
filename: d.file.getName(),
|
||||||
source: match ? 'synced' : 'drive_only',
|
source: match ? 'synced' : 'drive_only',
|
||||||
thumbnail: `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`,
|
thumbnail: thumbnail,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
galleryOrder: d.galleryOrder,
|
galleryOrder: d.galleryOrder,
|
||||||
mimeType: d.file.getMimeType(),
|
mimeType: d.file.getMimeType(),
|
||||||
// Use manual download URL construction which is often more reliable for authenticated sessions than getDownloadUrl()
|
// Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL
|
||||||
contentUrl: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`
|
contentUrl: (match && match.sources)
|
||||||
|
? (match.sources.find((s: any) => s.mimeType === 'video/mp4')?.url || match.sources[0]?.url)
|
||||||
|
: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`,
|
||||||
|
isProcessing: isProcessing
|
||||||
})
|
})
|
||||||
// console.log(`[MediaService] File ${d.file.getName()} (${d.file.getId()}): Mime=${d.file.getMimeType()}, ContentUrl=https://drive.google.com/uc?export=download&id=${d.file.getId()}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Find Shopify Orphans
|
// Find Shopify Orphans
|
||||||
@ -153,12 +291,26 @@ export class MediaService {
|
|||||||
contentUrl = m.image.url
|
contentUrl = m.image.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract filename from URL (Shopify URLs usually contain the filename)
|
||||||
|
let filename = "Orphaned Media";
|
||||||
|
try {
|
||||||
|
if (contentUrl) {
|
||||||
|
// Clean query params and get last segment
|
||||||
|
const cleanUrl = contentUrl.split('?')[0];
|
||||||
|
const parts = cleanUrl.split('/');
|
||||||
|
const candidate = parts.pop();
|
||||||
|
if (candidate) filename = candidate;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to extract filename from URL", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
unifiedState.push({
|
unifiedState.push({
|
||||||
id: m.id, // Use Shopify ID keys for orphans
|
id: m.id, // Use Shopify ID keys for orphans
|
||||||
driveId: null,
|
driveId: null,
|
||||||
shopifyId: m.id,
|
shopifyId: m.id,
|
||||||
filename: "Orphaned Media", // Shopify doesn't always expose filename cleanly in same way
|
filename: filename,
|
||||||
// Try to get filename if possible or fallback
|
|
||||||
source: 'shopify_only',
|
source: 'shopify_only',
|
||||||
thumbnail: m.preview?.image?.originalSrc || "",
|
thumbnail: m.preview?.image?.originalSrc || "",
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@ -172,6 +324,13 @@ export class MediaService {
|
|||||||
return unifiedState
|
return unifiedState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
|
||||||
|
console.log(`MediaService: Linking Drive File ${driveId} to Shopify Media ${shopifyId}`);
|
||||||
|
// Verify ownership? Maybe later. For now, trust the ID.
|
||||||
|
this.driveService.updateFileProperties(driveId, { shopify_media_id: shopifyId });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string): string[] {
|
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string): string[] {
|
||||||
const logs: string[] = []
|
const logs: string[] = []
|
||||||
logs.push(`Starting processing for SKU ${sku}`)
|
logs.push(`Starting processing for SKU ${sku}`)
|
||||||
@ -201,6 +360,24 @@ export class MediaService {
|
|||||||
logs.push(`- Deleted from Shopify (${item.shopifyId})`)
|
logs.push(`- Deleted from Shopify (${item.shopifyId})`)
|
||||||
}
|
}
|
||||||
if (item.driveId) {
|
if (item.driveId) {
|
||||||
|
// Check for Associated Sidecar Thumbs (Request #2)
|
||||||
|
try {
|
||||||
|
const f = driveSvc.getFileById(item.driveId);
|
||||||
|
// We could inspect properties, or just try to find based on convention if we don't have props handy.
|
||||||
|
// But `getUnifiedMediaState` logic shows we store `custom_thumbnail_id`.
|
||||||
|
// However, `item` here comes from `getUnifiedMediaState`, but DOES IT include the custom prop?
|
||||||
|
// Currently `unifiedState` items don't return `customThumbnailId` property explicitly in the Object.
|
||||||
|
// We should probably fetch it or have included it.
|
||||||
|
// Re-fetch props to be safe/clean.
|
||||||
|
const props = driveSvc.getFileProperties(item.driveId);
|
||||||
|
if (props && props['custom_thumbnail_id']) {
|
||||||
|
driveSvc.trashFile(props['custom_thumbnail_id']);
|
||||||
|
logs.push(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`);
|
||||||
|
}
|
||||||
|
} catch (ignore) {
|
||||||
|
// If file already gone or other error
|
||||||
|
}
|
||||||
|
|
||||||
driveSvc.trashFile(item.driveId)
|
driveSvc.trashFile(item.driveId)
|
||||||
logs.push(`- Trashed in Drive (${item.driveId})`)
|
logs.push(`- Trashed in Drive (${item.driveId})`)
|
||||||
}
|
}
|
||||||
@ -256,7 +433,8 @@ export class MediaService {
|
|||||||
return {
|
return {
|
||||||
filename: f.getName(),
|
filename: f.getName(),
|
||||||
mimeType: f.getMimeType(),
|
mimeType: f.getMimeType(),
|
||||||
resource: "IMAGE",
|
resource: f.getMimeType().startsWith('video/') ? "VIDEO" : "IMAGE",
|
||||||
|
fileSize: f.getSize().toString(),
|
||||||
httpMethod: "POST",
|
httpMethod: "POST",
|
||||||
file: f,
|
file: f,
|
||||||
originalItem: item
|
originalItem: item
|
||||||
@ -269,14 +447,26 @@ export class MediaService {
|
|||||||
filename: u.filename,
|
filename: u.filename,
|
||||||
mimeType: u.mimeType,
|
mimeType: u.mimeType,
|
||||||
resource: u.resource,
|
resource: u.resource,
|
||||||
|
fileSize: u.fileSize,
|
||||||
httpMethod: u.httpMethod
|
httpMethod: u.httpMethod
|
||||||
}))
|
}))
|
||||||
const stagedResp = shopifySvc.stagedUploadsCreate(stagedInput)
|
const stagedResp = shopifySvc.stagedUploadsCreate(stagedInput)
|
||||||
|
|
||||||
|
if (stagedResp.userErrors && stagedResp.userErrors.length > 0) {
|
||||||
|
console.error("[MediaService] stagedUploadsCreate Errors:", JSON.stringify(stagedResp.userErrors))
|
||||||
|
logs.push(`- Upload preparation failed: ${stagedResp.userErrors.map(e => e.message).join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
const targets = stagedResp.stagedTargets
|
const targets = stagedResp.stagedTargets
|
||||||
|
|
||||||
const mediaToCreate = []
|
const mediaToCreate = []
|
||||||
uploads.forEach((u, i) => {
|
uploads.forEach((u, i) => {
|
||||||
const target = targets[i]
|
const target = targets[i]
|
||||||
|
if (!target || !target.url) {
|
||||||
|
logs.push(`- Failed to get upload target for ${u.filename}: Invalid target`)
|
||||||
|
console.warn(`[MediaService] Missing target URL for ${u.filename}. Target:`, JSON.stringify(target))
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = {}
|
const payload = {}
|
||||||
target.parameters.forEach((p: any) => payload[p.name] = p.value)
|
target.parameters.forEach((p: any) => payload[p.name] = p.value)
|
||||||
payload['file'] = u.file.getBlob()
|
payload['file'] = u.file.getBlob()
|
||||||
@ -284,7 +474,7 @@ export class MediaService {
|
|||||||
mediaToCreate.push({
|
mediaToCreate.push({
|
||||||
originalSource: target.resourceUrl,
|
originalSource: target.resourceUrl,
|
||||||
alt: u.filename,
|
alt: u.filename,
|
||||||
mediaContentType: "IMAGE"
|
mediaContentType: u.resource
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -43,22 +43,33 @@ export class MockDriveService implements IDriveService {
|
|||||||
|
|
||||||
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
|
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
|
||||||
const id = `mock_file_${Date.now()}_${Math.floor(Math.random() * 1000)}`
|
const id = `mock_file_${Date.now()}_${Math.floor(Math.random() * 1000)}`
|
||||||
|
|
||||||
const newFile = {
|
const newFile = {
|
||||||
getId: () => id,
|
getId: () => id,
|
||||||
getName: () => blob.getName(),
|
getName: () => blob.getName(),
|
||||||
getBlob: () => blob,
|
getBlob: () => blob,
|
||||||
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
|
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
|
||||||
getLastUpdated: () => new Date(),
|
getLastUpdated: () => new Date(),
|
||||||
getThumbnail: () => ({ getBytes: () => [] }),
|
getThumbnail: () => (blob as any).getThumbnail ? (blob as any).getThumbnail() : ({ getBytes: () => [] }),
|
||||||
getMimeType: () => (blob as any).getContentType ? (blob as any).getContentType() : "image/jpeg",
|
getMimeType: () => (blob as any).getContentType ? (blob as any).getContentType() : "image/jpeg",
|
||||||
getDownloadUrl: () => `https://drive.google.com/uc?export=download&id=${id}`,
|
getDownloadUrl: () => `https://drive.google.com/uc?export=download&id=${id}`,
|
||||||
getAppProperty: (key) => {
|
getSize: () => blob.getBytes ? blob.getBytes().length : 0,
|
||||||
return (newFile as any)._properties?.[key]
|
getAppProperty: (key) => (newFile as any)._properties?.[key],
|
||||||
}
|
// Placeholder methods to be overridden safely
|
||||||
|
setDescription: null as any,
|
||||||
|
getDescription: null as any
|
||||||
} as unknown as GoogleAppsScript.Drive.File
|
} as unknown as GoogleAppsScript.Drive.File
|
||||||
|
|
||||||
// Initialize properties container
|
// Initialize state
|
||||||
;(newFile as any)._properties = {}
|
;(newFile as any)._properties = {};
|
||||||
|
;(newFile as any)._description = "";
|
||||||
|
|
||||||
|
// Attach methods safely
|
||||||
|
newFile.setDescription = (desc: string) => {
|
||||||
|
(newFile as any)._description = desc;
|
||||||
|
return newFile;
|
||||||
|
};
|
||||||
|
newFile.getDescription = () => (newFile as any)._description || "";
|
||||||
|
|
||||||
if (!this.files.has(folderId)) {
|
if (!this.files.has(folderId)) {
|
||||||
this.files.set(folderId, [])
|
this.files.set(folderId, [])
|
||||||
|
|||||||
Reference in New Issue
Block a user