← back to Marketing Command Center
Asset Library = Image Bank: add GDrive (folder rclone-import + file share-link), Harvest-from-campaign-HTML, Constant Contact library (Phase 2 gated) ingesters + sort + source filter
267a4bbc05ecb1748bd0e6e44cf0654896bd1ee6 · 2026-06-13 09:19:13 -0700 · Steve Abrams
Files touched
M modules/assets/index.jsM public/panels/assets.htmlM public/panels/assets.js
Diff
commit 267a4bbc05ecb1748bd0e6e44cf0654896bd1ee6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Jun 13 09:19:13 2026 -0700
Asset Library = Image Bank: add GDrive (folder rclone-import + file share-link), Harvest-from-campaign-HTML, Constant Contact library (Phase 2 gated) ingesters + sort + source filter
---
modules/assets/index.js | 116 +++++++++++++++++++++++++++++++++++++++++++++-
public/panels/assets.html | 59 ++++++++++++++++++++++-
public/panels/assets.js | 106 +++++++++++++++++++++++++++++++++++++-----
3 files changed, 266 insertions(+), 15 deletions(-)
diff --git a/modules/assets/index.js b/modules/assets/index.js
index bd09a39..25b0904 100644
--- a/modules/assets/index.js
+++ b/modules/assets/index.js
@@ -20,6 +20,16 @@
const fs = require('fs');
const path = require('path');
const express = require('express');
+const { execFileSync } = require('child_process');
+
+// Google Drive (info@ account) source — rclone remote is the configured `gdrive:`.
+const GDRIVE_REMOTE = process.env.GDRIVE_REMOTE || 'gdrive:';
+// A Drive FILE share-link → a direct-view image URL (works when the file is
+// shared "anyone with link"). Folder links are handled by the rclone importer.
+function driveDirect(link) {
+ const m = String(link).match(/\/file\/d\/([-\w]{20,})/) || String(link).match(/[?&]id=([-\w]{20,})/);
+ return m ? `https://drive.google.com/uc?export=view&id=${m[1]}` : null;
+}
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const FILES_DIR = path.join(DATA_DIR, 'assets');
@@ -64,7 +74,9 @@ function writeStore(arr) {
}
// `src` is what the front end actually points an <img> at.
function withSrc(a) {
- const src = a.kind === 'upload' ? `/api/assets/file/${a.filename}` : a.url;
+ // Anything with a locally-stored binary (uploads + downloaded Drive files)
+ // serves from /file/; link-only assets point at their remote url.
+ const src = a.filename ? `/api/assets/file/${a.filename}` : a.url;
return Object.assign({}, a, { src });
}
function seedIfEmpty() {
@@ -246,13 +258,113 @@ module.exports = {
res.json({ asset: withSrc(asset) });
});
+ // ── GDrive: single FILE share-link → direct image URL ──────────────────────
+ router.post('/add-drive', (req, res) => {
+ const { shareLink, name } = req.body || {};
+ if (!shareLink) return res.status(400).json({ error: 'shareLink is required' });
+ if (/\/folders\//.test(shareLink)) return res.status(400).json({ error: 'That is a FOLDER link — use “Import folder” (a Drive path) instead, or paste individual file links.' });
+ const direct = driveDirect(shareLink);
+ if (!direct) return res.status(400).json({ error: 'could not parse a Drive file id from that link' });
+ const asset = {
+ id: newId(), name: (name || 'Drive image').toString().slice(0, 120), kind: 'gdrive', url: direct,
+ mime: 'image/*', size: 0, tags: ['gdrive'], created_at: new Date().toISOString(),
+ };
+ const store = readStore(); store.push(asset); writeStore(store);
+ res.json({ asset: withSrc(asset) });
+ });
+
+ // ── GDrive: import images from a FOLDER PATH via rclone ─────────────────────
+ // Downloads each image INTO the library so private (non-public) Drive images
+ // still render. Body: { path, limit? }. path is relative to the info@ My Drive.
+ router.post('/import-gdrive', (req, res) => {
+ const folder = String((req.body && req.body.path) || '').trim().replace(/^\/+|\/+$/g, '');
+ if (!folder) return res.status(400).json({ error: 'path is required (e.g. "DW Corporate/Marketing Images")' });
+ const limit = Math.max(1, Math.min(200, parseInt(req.body && req.body.limit, 10) || 60));
+ const remote = `${GDRIVE_REMOTE}${folder}`;
+ let listing;
+ try {
+ listing = execFileSync('rclone', ['lsf', remote, '-R', '--max-depth', '4', '--files-only',
+ '--include', '*.{jpg,jpeg,png,gif,webp,avif,JPG,JPEG,PNG,GIF,WEBP,AVIF}'],
+ { encoding: 'utf8', timeout: 120000 });
+ } catch (e) {
+ const msg = e.code === 'ENOENT' ? 'rclone not found on the server PATH' : (e.stderr ? String(e.stderr).slice(0, 200) : e.message);
+ return res.status(502).json({ error: 'rclone list failed: ' + msg });
+ }
+ const files = listing.split('\n').map(s => s.trim()).filter(Boolean).slice(0, limit);
+ if (!files.length) return res.json({ ok: true, added: 0, note: `no images found under “${folder}” (depth ≤4)` });
+ const store = readStore(); const added = [];
+ for (const rel of files) {
+ try {
+ const ext = (rel.split('.').pop() || 'jpg').toLowerCase();
+ const id = newId(); const filename = `${id}.${ext}`;
+ execFileSync('rclone', ['copyto', `${remote}/${rel}`, path.join(FILES_DIR, filename), '--timeout', '60s'], { timeout: 90000 });
+ const asset = {
+ id, name: (rel.split('/').pop() || 'image').slice(0, 120), kind: 'gdrive', filename,
+ mime: 'image/' + ext, size: 0, tags: ['gdrive', folder.split('/')[0]], created_at: new Date().toISOString(),
+ };
+ store.push(asset); added.push(asset);
+ } catch { /* skip this file, keep going */ }
+ }
+ writeStore(store);
+ res.json({ ok: true, added: added.length, of: files.length, assets: added.map(withSrc) });
+ });
+
+ // ── Harvest image URLs from pasted (old Constant Contact) campaign HTML ─────
+ router.post('/harvest', express.json({ limit: '8mb' }), (req, res) => {
+ const html = String((req.body && req.body.html) || '');
+ const source = ((req.body && req.body.source) || 'constant-contact').toString().slice(0, 40);
+ if (!html.trim()) return res.status(400).json({ error: 'paste the campaign HTML' });
+ const urls = new Set();
+ let m;
+ const reExt = /https?:\/\/[^\s"'<>()]+\.(?:jpe?g|png|gif|webp|avif)(?:\?[^\s"'<>()]*)?/gi;
+ while ((m = reExt.exec(html))) urls.add(m[0]);
+ const reImg = /<img[^>]+src=["']([^"']+)["']/gi;
+ while ((m = reImg.exec(html))) { if (/^https?:\/\//i.test(m[1])) urls.add(m[1]); }
+ const store = readStore(); const have = new Set(store.map(a => a.url).filter(Boolean)); const added = [];
+ for (const u of urls) {
+ if (have.has(u)) continue;
+ const ext = (u.split('.').pop() || '').split('?')[0].toLowerCase();
+ const asset = {
+ id: newId(), name: (u.split('/').pop() || 'image').split('?')[0].slice(0, 120), kind: 'url', url: u,
+ mime: MIME_EXT['image/' + ext] ? 'image/' + ext : 'image/*', size: 0,
+ tags: [source, 'harvested'], created_at: new Date().toISOString(),
+ };
+ store.push(asset); added.push(asset); have.add(u);
+ }
+ writeStore(store);
+ res.json({ ok: true, found: urls.size, added: added.length, assets: added.map(withSrc) });
+ });
+
+ // ── Constant Contact image library (Phase 2 — OAuth-gated) ──────────────────
+ router.post('/import-cc-library', async (_req, res) => {
+ const token = (process.env.CTCT_ACCESS_TOKEN || '').trim();
+ if (!token) return res.status(409).json({ needsToken: true, error: 'Constant Contact isn’t connected yet — add the CC OAuth token (Phase 2) to import the image library.' });
+ try {
+ const r = await fetch('https://api.cc.email/v3/library/files?type=ALL&limit=100', { headers: { Authorization: `Bearer ${token}`, accept: 'application/json' } });
+ if (!r.ok) return res.status(502).json({ error: `CC library ${r.status}` });
+ const d = await r.json();
+ const items = d.library_files || d.files || [];
+ const store = readStore(); const have = new Set(store.map(a => a.url).filter(Boolean)); const added = [];
+ for (const it of items) {
+ const url = it.url; if (!url || have.has(url)) continue;
+ const asset = {
+ id: newId(), name: (it.name || 'CC image').toString().slice(0, 120), kind: 'url', url,
+ mime: 'image/*', size: it.size || 0, tags: ['constant-contact'], created_at: new Date().toISOString(),
+ };
+ store.push(asset); added.push(asset); have.add(url);
+ }
+ writeStore(store);
+ res.json({ ok: true, added: added.length, assets: added.map(withSrc) });
+ } catch (e) { res.status(502).json({ error: e.message }); }
+ });
+
// Delete an asset (and its file, if uploaded).
router.delete('/:id', (req, res) => {
const store = readStore();
const i = store.findIndex(a => a.id === req.params.id);
if (i === -1) return res.status(404).json({ error: 'not found' });
const [removed] = store.splice(i, 1);
- if (removed.kind === 'upload' && removed.filename) {
+ if (removed.filename) {
try { fs.unlinkSync(path.join(FILES_DIR, removed.filename)); } catch { /* already gone */ }
}
writeStore(store);
diff --git a/public/panels/assets.html b/public/panels/assets.html
index 881b561..ec7b817 100644
--- a/public/panels/assets.html
+++ b/public/panels/assets.html
@@ -11,10 +11,13 @@
</div>
<!-- mode tabs -->
- <div id="as-modes" style="display:flex;gap:6px;margin-bottom:14px;border-bottom:1px solid var(--line);">
+ <div id="as-modes" style="display:flex;gap:6px;margin-bottom:14px;border-bottom:1px solid var(--line);flex-wrap:wrap;">
<button class="as-mode btn ghost active" data-mode="upload" style="border:0;border-radius:0;border-bottom:2px solid var(--gold);">⬆ Upload</button>
<button class="as-mode btn ghost" data-mode="url" style="border:0;border-radius:0;border-bottom:2px solid transparent;">🔗 From URL</button>
<button class="as-mode btn ghost" data-mode="catalog" style="border:0;border-radius:0;border-bottom:2px solid transparent;">🏛 DW Catalog</button>
+ <button class="as-mode btn ghost" data-mode="gdrive" style="border:0;border-radius:0;border-bottom:2px solid transparent;">☁ GDrive</button>
+ <button class="as-mode btn ghost" data-mode="harvest" style="border:0;border-radius:0;border-bottom:2px solid transparent;">✂ Harvest</button>
+ <button class="as-mode btn ghost" data-mode="cc" style="border:0;border-radius:0;border-bottom:2px solid transparent;">📧 CC Library</button>
</div>
<!-- upload pane -->
@@ -55,13 +58,65 @@
<div id="as-cat-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;max-height:420px;overflow:auto;"></div>
</div>
+ <!-- gdrive pane -->
+ <div class="as-pane" data-pane="gdrive" style="display:none;">
+ <div class="muted" style="font-size:12px;margin-bottom:10px;">Pull images from the info@ Google Drive. Paste a <b>folder path</b> to bulk-import (downloaded into the library so private images still render), or a single <b>file share-link</b>.</div>
+ <div class="row" style="align-items:flex-end;margin-bottom:10px;">
+ <div style="flex:2;min-width:240px;">
+ <label for="as-gd-path">Drive folder path <span class="muted" style="font-weight:400;">(relative to My Drive)</span></label>
+ <input id="as-gd-path" type="text" placeholder="e.g. DW Corporate/Marketing Images">
+ </div>
+ <div style="flex:0;min-width:90px;">
+ <label for="as-gd-limit">Max</label>
+ <input id="as-gd-limit" type="number" value="60" min="1" max="200">
+ </div>
+ <button id="as-gd-import" class="btn gold" style="height:38px;">Import folder</button>
+ </div>
+ <div class="row" style="align-items:flex-end;">
+ <div style="flex:2;min-width:240px;">
+ <label for="as-gd-link">…or a single file share-link</label>
+ <input id="as-gd-link" type="url" placeholder="https://drive.google.com/file/d/…/view">
+ </div>
+ <button id="as-gd-add" class="btn ghost" style="height:38px;">Add file</button>
+ </div>
+ </div>
+
+ <!-- harvest pane -->
+ <div class="as-pane" data-pane="harvest" style="display:none;">
+ <div class="muted" style="font-size:12px;margin-bottom:8px;">Paste the HTML of an old Constant Contact campaign — every image URL inside it is extracted into the library.</div>
+ <textarea id="as-hv-html" rows="5" placeholder="Paste campaign HTML here…"></textarea>
+ <button id="as-hv-go" class="btn gold" style="margin-top:8px;">Harvest images</button>
+ </div>
+
+ <!-- cc library pane -->
+ <div class="as-pane" data-pane="cc" style="display:none;">
+ <div class="muted" style="font-size:12px;margin-bottom:8px;">Import your Constant Contact image library directly. Requires the CC connection (Phase 2 OAuth) — until then this stages.</div>
+ <button id="as-cc-go" class="btn gold">Import from Constant Contact</button>
+ </div>
+
<div id="as-add-status" style="font-size:12px;color:var(--mut);margin-top:10px;min-height:16px;"></div>
</div>
<!-- ── Library grid ────────────────────────────────────────────────────── -->
<div style="display:flex;align-items:center;justify-content:space-between;margin:0 2px 12px;">
<div style="font:600 16px/1.2 'Cormorant Garamond',Georgia,serif;">In your library <span id="as-count" class="pill">0</span></div>
- <div style="display:flex;align-items:center;gap:8px;">
+ <div style="display:flex;align-items:center;gap:10px;">
+ <label for="as-sort" style="margin:0;font-size:12px;">Sort</label>
+ <select id="as-sort" style="width:auto;padding:5px 8px;font-size:12px;">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="name">Name A→Z</option>
+ <option value="source">Source</option>
+ </select>
+ <label for="as-source" style="margin:0;font-size:12px;">Source</label>
+ <select id="as-source" style="width:auto;padding:5px 8px;font-size:12px;">
+ <option value="">All</option>
+ <option value="upload">Upload</option>
+ <option value="url">URL</option>
+ <option value="catalog">DW Catalog</option>
+ <option value="gdrive">GDrive</option>
+ <option value="constant-contact">Constant Contact</option>
+ </select>
<label for="as-density" style="margin:0;font-size:12px;">Size</label>
<input id="as-density" type="range" min="120" max="280" value="170" style="width:120px;padding:0;">
</div>
diff --git a/public/panels/assets.js b/public/panels/assets.js
index 3111c1e..2ba648a 100644
--- a/public/panels/assets.js
+++ b/public/panels/assets.js
@@ -40,21 +40,43 @@ window.MCC_PANELS['assets'] = {
applyDensity(density.value);
density.addEventListener('input', () => { applyDensity(density.value); localStorage.setItem(DKEY, density.value); });
- // ── library load + render ─────────────────────────────────────────────────
- async function loadLibrary() {
- let assets = [];
- try { assets = (await (await fetch('/api/assets/list')).json()).assets || []; }
- catch (e) { setStatus('Could not load library: ' + e.message, true); }
- countPill.textContent = assets.length;
- libEmpty.style.display = assets.length ? 'none' : 'block';
- libGrid.innerHTML = assets.map(cardHtml).join('');
+ // ── library load + render (with sort + source filter) ──────────────────────
+ let allAssets = [];
+ const sortSel = $('#as-sort');
+ const sourceSel = $('#as-source');
+ function assetSource(a) { // normalize to a filter key
+ if ((a.tags || []).includes('constant-contact')) return 'constant-contact';
+ return a.kind || 'url';
+ }
+ function renderLibrary() {
+ const src = sourceSel ? sourceSel.value : '';
+ const sort = sortSel ? sortSel.value : 'newest';
+ let rows = allAssets.slice();
+ if (src) rows = rows.filter(a => assetSource(a) === src);
+ rows.sort((a, b) => {
+ if (sort === 'name') return (a.name || '').localeCompare(b.name || '');
+ if (sort === 'source') return assetSource(a).localeCompare(assetSource(b)) || (b.created_at || '').localeCompare(a.created_at || '');
+ if (sort === 'oldest') return (a.created_at || '').localeCompare(b.created_at || '');
+ return (b.created_at || '').localeCompare(a.created_at || ''); // newest
+ });
+ countPill.textContent = rows.length;
+ libEmpty.style.display = rows.length ? 'none' : 'block';
+ libGrid.innerHTML = rows.map(cardHtml).join('');
libGrid.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => del(b.dataset.del)));
libGrid.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => copyUrl(b.dataset.copy)));
}
+ async function loadLibrary() {
+ try { allAssets = (await (await fetch('/api/assets/list')).json()).assets || []; }
+ catch (e) { setStatus('Could not load library: ' + e.message, true); }
+ renderLibrary();
+ }
+ if (sortSel) sortSel.addEventListener('change', renderLibrary);
+ if (sourceSel) sourceSel.addEventListener('change', renderLibrary);
- const KIND_LABEL = { upload: 'Upload', url: 'URL', catalog: 'DW Catalog' };
+ const KIND_LABEL = { upload: 'Upload', url: 'URL', catalog: 'DW Catalog', gdrive: 'GDrive' };
function cardHtml(a) {
- const abs = a.kind === 'upload' ? (location.origin + a.src) : a.src;
+ const abs = a.filename ? (location.origin + a.src) : a.src;
+ const ccTag = (a.tags || []).includes('constant-contact');
return `<div class="card" style="padding:0;overflow:hidden;margin:0;display:flex;flex-direction:column;">
<div style="aspect-ratio:1/1;background:#e8e3da;overflow:hidden;display:flex;align-items:center;justify-content:center;">
<img src="${esc(a.src)}" alt="${esc(a.name)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block;" onerror="this.style.opacity=.25;this.alt='⚠ image failed'">
@@ -62,7 +84,7 @@ window.MCC_PANELS['assets'] = {
<div style="padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px;">
<div style="font-size:12.5px;font-weight:600;line-height:1.25;max-height:2.6em;overflow:hidden;" title="${esc(a.name)}">${esc(a.name)}</div>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
- <span class="pill" style="font-size:9.5px;">${KIND_LABEL[a.kind] || a.kind}</span>
+ <span class="pill" style="font-size:9.5px;">${ccTag ? 'Constant Contact' : (KIND_LABEL[a.kind] || a.kind)}</span>
<span class="when" title="${esc(a.created_at)}" style="font-size:10.5px;color:var(--mut);">🕓 ${esc(fmtWhen(a.created_at))}</span>
</div>
<div style="display:flex;gap:6px;margin-top:3px;">
@@ -177,6 +199,68 @@ window.MCC_PANELS['assets'] = {
$('#as-cat-search').addEventListener('click', searchCatalog);
$('#as-cat-q').addEventListener('keydown', e => { if (e.key === 'Enter') searchCatalog(); });
+ // ── GDrive: import folder (rclone) ──────────────────────────────────────────
+ $('#as-gd-import').addEventListener('click', async () => {
+ const path = $('#as-gd-path').value.trim();
+ const limit = parseInt($('#as-gd-limit').value, 10) || 60;
+ if (!path) { setStatus('Enter a Drive folder path first.', true); return; }
+ setStatus(`Importing images from “${path}”… (this can take a minute)`);
+ try {
+ const r = await fetch('/api/assets/import-gdrive', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path, limit }),
+ });
+ const d = await r.json();
+ if (!r.ok) throw new Error(d.error || r.status);
+ setStatus(d.added ? `Imported ${d.added} of ${d.of} image(s) from Drive.` : (d.note || 'No images found.'), !d.added);
+ loadLibrary();
+ } catch (e) { setStatus('Drive import failed: ' + e.message, true); }
+ });
+ // ── GDrive: add single file share-link ──────────────────────────────────────
+ $('#as-gd-add').addEventListener('click', async () => {
+ const shareLink = $('#as-gd-link').value.trim();
+ if (!shareLink) { setStatus('Paste a Drive file share-link first.', true); return; }
+ try {
+ const r = await fetch('/api/assets/add-drive', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ shareLink }),
+ });
+ const d = await r.json();
+ if (!r.ok) throw new Error(d.error || r.status);
+ $('#as-gd-link').value = '';
+ setStatus('Added Drive image to library.');
+ loadLibrary();
+ } catch (e) { setStatus('Could not add Drive file: ' + e.message, true); }
+ });
+ // ── Harvest from campaign HTML ──────────────────────────────────────────────
+ $('#as-hv-go').addEventListener('click', async () => {
+ const html = $('#as-hv-html').value.trim();
+ if (!html) { setStatus('Paste campaign HTML first.', true); return; }
+ setStatus('Harvesting image URLs…');
+ try {
+ const r = await fetch('/api/assets/harvest', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ html }),
+ });
+ const d = await r.json();
+ if (!r.ok) throw new Error(d.error || r.status);
+ setStatus(`Found ${d.found} URL(s), added ${d.added} new.`, !d.added && !d.found);
+ $('#as-hv-html').value = '';
+ loadLibrary();
+ } catch (e) { setStatus('Harvest failed: ' + e.message, true); }
+ });
+ // ── Constant Contact library (Phase 2 gated) ────────────────────────────────
+ $('#as-cc-go').addEventListener('click', async () => {
+ setStatus('Importing from Constant Contact…');
+ try {
+ const r = await fetch('/api/assets/import-cc-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ const d = await r.json();
+ if (!r.ok) { setStatus((d.needsToken ? '🔒 ' : '✗ ') + (d.error || r.status), true); return; }
+ setStatus(`Imported ${d.added} image(s) from Constant Contact.`);
+ loadLibrary();
+ } catch (e) { setStatus('CC import failed: ' + e.message, true); }
+ });
+
// ── boot ───────────────────────────────────────────────────────────────────
loadLibrary();
},
← bdfc352 Channels: self-serve Activate page — paste app creds in-brow
·
back to Marketing Command Center
·
Image Bank: Instagram ingest — public-permalink og:image gra 961a1bb →