[object Object]

← back to Wallco Ai

rescue-curator: add bulk 'Move to species category' → POST /api/admin/recategorize/bulk (animals · <species>, sanitized+pgEsc, updates all_designs+spoon+in-memory)

447b0a04f681b3d9264a172c65797d5aef7d42d3 · 2026-05-31 20:04:57 -0700 · Steve Abrams

Files touched

Diff

commit 447b0a04f681b3d9264a172c65797d5aef7d42d3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 31 20:04:57 2026 -0700

    rescue-curator: add bulk 'Move to species category' → POST /api/admin/recategorize/bulk (animals · <species>, sanitized+pgEsc, updates all_designs+spoon+in-memory)
---
 public/admin/rescue-curator.html | 20 +++++++++++++++++++
 server.js                        | 43 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 63 insertions(+)

diff --git a/public/admin/rescue-curator.html b/public/admin/rescue-curator.html
index da2e914..4994671 100644
--- a/public/admin/rescue-curator.html
+++ b/public/admin/rescue-curator.html
@@ -46,6 +46,7 @@ label.f{display:flex;gap:6px;align-items:center;color:#9a8f7e;font-size:12px}
 <div id="out">
   <span class="sub" id="selinfo">0 selected</span>
   <span style="flex:1"></span>
+  <button class="ghost" id="bMove" disabled onclick="moveSpecies()">→ Move to species category</button>
   <button class="pub" id="bPub" disabled onclick="bulk('live')">▲ Publish selected</button>
   <button class="unpub" id="bUnpub" disabled onclick="bulk('unpublish')">▼ Unpublish selected</button>
 </div>
@@ -87,6 +88,7 @@ function upd(shown){
   selinfo.textContent=sel.size+" selected";
   document.getElementById("bPub").disabled=!sel.size;
   document.getElementById("bUnpub").disabled=!sel.size;
+  document.getElementById("bMove").disabled=!sel.size;
 }
 function selAll(v){curView().forEach(d=>{v?sel.add(d.id):sel.delete(d.id);});render();}
 function toast(msg,ms){const t=document.getElementById("toast");t.textContent=msg;t.style.display="block";clearTimeout(window._tt);if(ms)window._tt=setTimeout(()=>t.style.display="none",ms);}
@@ -124,5 +126,23 @@ async function bulk(action){
     toast("✓ "+verb.toLowerCase()+"ed "+(j.applied!=null?j.applied:ids.length)+extra,4000);
   }catch(e){toast("✗ "+e.message,6000);upd();}
 }
+// ── move selected out of drunk-animals into per-species "animals · <species>" ──
+async function moveSpecies(){
+  const ids=[...sel];
+  if(!ids.length)return;
+  const moves=ids.map(id=>({id,species:(byId[id]||{}).species})).filter(m=>m.species);
+  const cats=[...new Set(moves.map(m=>"animals · "+String(m.species).toLowerCase().replace(/[^a-z ]/g,"").trim().replace(/\s+/g,"-")))];
+  if(!confirm("Move "+moves.length+" design"+(moves.length>1?"s":"")+" into per-species categories?\n\n"+cats.slice(0,8).join("\n")+(cats.length>8?("\n…+"+(cats.length-8)+" more"):"")))return;
+  document.getElementById("bMove").disabled=true;
+  toast("moving "+moves.length+"…");
+  try{
+    const r=await fetch("/api/admin/recategorize/bulk",{method:"POST",headers:{"Content-Type":"application/json"},credentials:"same-origin",body:JSON.stringify({moves,group:"animals"})});
+    const j=await r.json();
+    if(!r.ok||!j.ok)throw new Error(j.error||("HTTP "+r.status));
+    moves.forEach(m=>{if(byId[m.id])byId[m.id].category="animals · "+m.species;});
+    sel.clear();render();
+    toast("✓ moved "+j.applied+" into "+j.categories+" categories",4000);
+  }catch(e){toast("✗ "+e.message,6000);upd();}
+}
 render();
 </script>
diff --git a/server.js b/server.js
index 5edec7f..1f88e21 100644
--- a/server.js
+++ b/server.js
@@ -2468,6 +2468,49 @@ app.post('/api/cactus-decision/bulk', express.json({ limit: '256kb' }), (req, re
   }
 });
 
+// POST /api/admin/recategorize/bulk — move designs into per-species categories.
+//   body: { moves: [ { id: <int>, species: <string> }, ... ], group?: <string> }
+// Used by /admin/rescue-curator to move recognizable-animal drunk-animals designs
+// out of "drunk-animals" into "<group> · <species>" (default group "animals",
+// matching the established "dogs · beagle" convention). Updates BOTH all_designs
+// and spoon_all_designs (one UPDATE per distinct target category, no fork storm)
+// + patches the in-memory DESIGNS array so /api/design + grid reflect immediately.
+// Species is hard-sanitized to [a-z -] before building the category (injection-safe;
+// also pgEsc'd). Reversible — category-only, no publish/quarantine side effects.
+app.post('/api/admin/recategorize/bulk', express.json({ limit: '256kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const moves = Array.isArray(req.body && req.body.moves) ? req.body.moves : [];
+  if (!moves.length) return res.status(400).json({ error: 'moves[] required' });
+  const group = String(req.body && req.body.group || 'animals').toLowerCase().replace(/[^a-z -]/g, '').trim() || 'animals';
+  // Build category → [ids] map, sanitizing species hard.
+  const byCat = {};
+  for (const m of moves) {
+    const id = parseInt(m && m.id, 10);
+    if (!Number.isFinite(id) || id < 1) continue;
+    const sp = String(m && m.species || '').toLowerCase().replace(/[^a-z ]/g, '').trim().replace(/\s+/g, '-');
+    if (!sp || sp.length > 40) continue;
+    const cat = group + ' · ' + sp;          // e.g. "animals · orangutan"
+    (byCat[cat] = byCat[cat] || []).push(id);
+  }
+  const cats = Object.keys(byCat);
+  if (!cats.length) return res.status(400).json({ error: 'no valid moves after sanitize' });
+  let applied = 0;
+  try {
+    for (const cat of cats) {
+      const idList = byCat[cat].join(',');
+      const c = pgEsc(cat);                    // safe-quoted literal
+      psqlExecLocal(`UPDATE all_designs   SET category=${c} WHERE id IN (${idList});`);
+      psqlExecLocal(`UPDATE spoon_all_designs SET category=${c} WHERE id IN (${idList});`);
+      // reflect in the live in-memory catalog immediately
+      for (const id of byCat[cat]) { const d = DESIGNS.find(x => x.id === id); if (d) d.category = cat; }
+      applied += byCat[cat].length;
+    }
+    return res.json({ ok: true, applied, categories: cats.length, byCategory: Object.fromEntries(cats.map(c => [c, byCat[c].length])) });
+  } catch (e) {
+    return res.status(500).json({ ok: false, error: e.message.slice(0, 240) });
+  }
+});
+
 // POST /api/admin/titles/bulk — bulk rename design titles.
 //   body: { updates: [ { id: <int>, title: <string> }, ... ] }
 // Used 2026-05-31 to fix the 303 stoned-animals title drifts (auto-titler had

← 549ea46 Color-targeted llama+crane colorways (54992-54998) replace r  ·  back to Wallco Ai  ·  More animals x more textures (55008-55020, 12 new species on d1deeb6 →