[object Object]

← 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

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 →