[object Object]

← back to Mfr Review Viewer Corruption

mfr-review-viewer: gated delete-record (keep-rest) staging with reversible FM snapshots

8e3663b333bf14d1f95312343706f8e63fcc4b9a · 2026-08-27 08:48:02 -0700 · Steve Abrams

Files touched

Diff

commit 8e3663b333bf14d1f95312343706f8e63fcc4b9a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 27 08:48:02 2026 -0700

    mfr-review-viewer: gated delete-record (keep-rest) staging with reversible FM snapshots
---
 .gitignore                 |   1 +
 lib/fm-record-snapshot.mjs |  58 +++++++++++++++++
 public/index.html          | 145 +++++++++++++++++++++++++++++++++++++----
 server.js                  | 157 ++++++++++++++++++++++++++++++++++++++-------
 4 files changed, 327 insertions(+), 34 deletions(-)

diff --git a/.gitignore b/.gitignore
index 4a0b3ea..d3f1ef2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ tmp/
 dist/
 build/
 .next/
+data/restore/
diff --git a/lib/fm-record-snapshot.mjs b/lib/fm-record-snapshot.mjs
new file mode 100644
index 0000000..6d4d8b2
--- /dev/null
+++ b/lib/fm-record-snapshot.mjs
@@ -0,0 +1,58 @@
+// fm-record-snapshot.mjs — READ-ONLY full-field snapshot of ONE WALLPAPER master.
+//
+// Given a FileMaker recordId, returns its COMPLETE fieldData from the WALLPAPER db
+// via the filemaker-mcp getRecord (import-only; does NOT modify that project). Used
+// two ways:
+//   • /api/fm-record-snapshot — the viewer shows the full record before proposing a delete.
+//   • /api/stage — the server writes each delete-candidate's full field snapshot to
+//     data/restore/<recordId>-<ISO>.json so an APPROVED delete is reversible (recreatable).
+//
+// PURELY READ-ONLY: FileMaker record GET only. Never writes to FileMaker. Loads the
+// filemaker-mcp .env for FM Cloud creds. Same layout the candidates resolver uses.
+
+import { readFileSync, existsSync } from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+
+const FM_PROJECT = path.join(os.homedir(), 'Projects', 'filemaker-mcp');
+const FM_ENV = path.join(FM_PROJECT, '.env');
+const FM_CLIENT = path.join(FM_PROJECT, 'src', 'fm-client.js');
+const FM_DB = 'WALLPAPER';
+const FM_LAYOUT = '*List Wallpapers - Full View';
+
+// ---- load the filemaker-mcp .env (read-only) so FM Cloud creds are present ----
+function loadFmEnv() {
+  if (!existsSync(FM_ENV)) return;
+  for (const line of readFileSync(FM_ENV, 'utf8').split('\n')) {
+    const m = line.match(/^([A-Z_]+)=(.*)$/);
+    if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
+  }
+}
+loadFmEnv();
+
+// lazy-import getRecord (ES module) once, cached
+let _getRecord = null;
+async function getGetRecord() {
+  if (_getRecord) return _getRecord;
+  const mod = await import('file://' + FM_CLIENT);
+  _getRecord = mod.getRecord;
+  return _getRecord;
+}
+
+// Return { recordId, db, layout, captured_at, fieldData, modId } for one master.
+// Throws on FM/creds error (caller catches + degrades).
+export async function snapshotRecord(recordId) {
+  const rid = String(recordId || '').trim();
+  if (!rid) throw new Error('recordId required');
+  const getRecord = await getGetRecord();
+  const rec = await getRecord(FM_DB, FM_LAYOUT, rid);
+  if (!rec) throw new Error(`WALLPAPER master ${rid} not found`);
+  return {
+    recordId: rec.recordId || rid,
+    db: FM_DB,
+    layout: FM_LAYOUT,
+    modId: rec.modId || null,
+    captured_at: new Date().toISOString(),
+    fieldData: rec.fieldData || {},
+  };
+}
diff --git a/public/index.html b/public/index.html
index 6530c63..0a8e8dc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -101,6 +101,29 @@
   .cand-spin { color:var(--muted); font-size:11px; padding:4px 0; }
   .cand-err { color:var(--amber); font-size:11px; }
 
+  /* ---- Delete-marking (duplicate-master pruning) ---- */
+  .cand-group-head { display:flex; align-items:center; justify-content:space-between; gap:6px;
+    margin:0 0 4px; }
+  .cand-group-head h4 { margin:0; }
+  .keep-del-btn { font-size:10px; padding:2px 7px; border-radius:6px; cursor:pointer;
+    background:#2a1417; color:#fca5a5; border:1px solid var(--red); white-space:nowrap; }
+  .keep-del-btn:hover { background:#3a1a1f; }
+  .keep-del-btn:disabled { opacity:.4; cursor:not-allowed; }
+  .cand-row .del-wrap { display:flex; align-items:center; gap:3px; margin-left:auto; }
+  .cand-row .del-chk { transform:scale(1.05); cursor:pointer; accent-color:var(--red); }
+  .cand-row .del-lbl { font-size:9px; color:var(--red); text-transform:uppercase; letter-spacing:.03em; }
+  .cand-row.marked-del { border-color:var(--red); background:#1a1113;
+    box-shadow:0 0 0 1px var(--red) inset; }
+  .cand-row.marked-del .cand-rid,
+  .cand-row.marked-del .cand-struct,
+  .cand-row.marked-del .cand-meta,
+  .cand-row.marked-del .cand-realmfr { text-decoration:line-through; opacity:.65; }
+  .del-tag { font-size:9px; padding:1px 5px; border-radius:4px; background:var(--red); color:#2a0a0a;
+    font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
+  .prune-summary { font-size:11px; color:var(--muted); margin-top:2px; }
+  .prune-summary .n-del { color:var(--red); font-weight:700; }
+  .prune-summary .n-keep { color:var(--teal); font-weight:700; }
+
   /* ---- Sticky action bar ---- */
   .action { position:fixed; left:280px; right:0; bottom:0; background:var(--panel);
     border-top:1px solid var(--line); padding:12px 16px; display:flex; align-items:center;
@@ -156,7 +179,7 @@
 </div>
 
 <div class="action">
-  <span class="selcount"><span id="selN">0</span> selected</span>
+  <span class="selcount"><span id="selN">0</span> selected · <span id="delN" style="color:var(--red)">0</span> to delete</span>
   <button class="stage" id="stageBtn" disabled>Stage selected for approval</button>
   <span class="msg" id="stageMsg"></span>
 </div>
@@ -185,6 +208,8 @@ const state = {
   edits: {},               // dw_sku -> edited guess
   cand: {},                // dw_sku -> {filemaker,unified,fmError} (cached per SKU)
   candPick: {},            // dw_sku -> chosen candidate key (for radio state)
+  keepRid: {},             // dw_sku -> FmPro recordId marked "correct/keep"
+  delRids: {},             // dw_sku -> Set of FmPro recordIds marked for deletion
   fields: JSON.parse(localStorage.getItem('mfr.fields')||'{}'),
   sort: localStorage.getItem('mfr.sort')||'newest',
   density: +(localStorage.getItem('mfr.density')||4),
@@ -317,9 +342,17 @@ function apply(){
   updateSel();
 }
 
+function totalDeleteCount(){
+  let n = 0;
+  for(const s of Object.values(state.delRids)) n += (s ? s.size : 0);
+  return n;
+}
+
 function updateSel(){
+  const dels = totalDeleteCount();
   $('#selN').textContent = state.sel.size;
-  $('#stageBtn').disabled = state.sel.size===0;
+  $('#delN').textContent = dels;
+  $('#stageBtn').disabled = (state.sel.size===0 && dels===0);
 }
 
 // ---- Candidates drill-down (lazy per chip) ----
@@ -367,7 +400,15 @@ function renderCandidates(sku, panel){
   let h = '';
 
   // ---- FmPro group ----
-  h += `<div class="cand-group"><h4>FmPro records (${fm.length})</h4>`;
+  const keepRid = state.keepRid[sku] || '';
+  const delSet = state.delRids[sku] || new Set();
+  const otherCount = fm.filter(c=>String(c.recordId)!==String(keepRid)).length;
+  h += `<div class="cand-group">`;
+  h += `<div class="cand-group-head"><h4>FmPro records (${fm.length})</h4>`;
+  if(fm.length>1){
+    h += `<button class="keep-del-btn" data-keepdel="${esc(sku)}" ${keepRid?'':'disabled'} title="Mark every OTHER FmPro record of this SKU for deletion (keeps the one you chose)">🗑 Keep selected · delete the other ${otherCount}</button>`;
+  }
+  h += `</div>`;
   if(!fm.length){
     h += `<div class="cand-meta">no FileMaker master found`+(j.fmError?` — <span class="cand-err">${esc(j.fmError)}</span>`:'')+`</div>`;
   } else {
@@ -377,14 +418,17 @@ function renderCandidates(sku, panel){
       const structNorm = norm(c.mfrPattern);
       const isDw = c.mfrPattern && (structNorm===norm(sku) || (skuTail && structNorm===skuTail) || /^\d+$/.test(c.mfrPattern) && c.mfrPattern===skuTail);
       const real = c.noteMfr || c.mfrPattern || '';
-      const key = candKey(c);
+      const rid = String(c.recordId);
+      const isKeep = keepRid && rid===String(keepRid);
+      const isDel = delSet.has(rid);
       const picked = pick!=null && pick===real && real!=='';
-      h += `<label class="cand-row${picked?' picked':''}">
-        <input type="radio" name="cand-${esc(sku)}" ${picked?'checked':''} data-real="${esc(real)}">
+      h += `<label class="cand-row${picked?' picked':''}${isDel?' marked-del':''}">
+        <input type="radio" name="cand-${esc(sku)}" ${picked?'checked':''} data-real="${esc(real)}" data-rid="${esc(rid)}" data-keepmfr="${esc(real)}" data-keepnote="${esc(c.mfrNote||'')}">
         <span class="cand-body">
           <span class="cand-line"><span class="cand-rid">#${esc(c.recordId)}</span>`+
           (c.series||c.jsPattern?`<span class="cand-meta">${esc(c.series)}|${esc(c.jsPattern)}</span>`:'')+
           (c.mfrPattern?`<span class="cand-struct${isDw?' isdw':''}" title="Mfr Pattern (structured)">${esc(c.mfrPattern)}</span>`:'')+
+          (isDel?`<span class="del-tag">will be staged for deletion</span>`:'')+
           `</span>`+
           (c.mfrNote && c.mfrNote!==c.mfrPattern?`<span class="cand-line"><span class="cand-note" title="mfr note (real code lives here)">${esc(c.mfrNote)}</span></span>`:'')+
           (c.mfrNote===c.mfrPattern && /#|net|\$/i.test(c.mfrNote||'')?`<span class="cand-line"><span class="cand-note" title="real code">${esc(c.mfrNote)}</span></span>`:'')+
@@ -392,8 +436,14 @@ function renderCandidates(sku, panel){
             (c.name?`${esc(c.name)} · `:'')+(c.color?`${esc(c.color)} · `:'')+(c.vid?`vid ${esc(c.vid)}`:'')+
           `</span>`+
           (real?`<span class="cand-line">→ real mfr: <span class="cand-realmfr">${esc(real)}</span></span>`:'')+
-        `</span></label>`;
+        `</span>
+        <span class="del-wrap" title="Mark THIS master for deletion (keep the rest)">
+          <input type="checkbox" class="del-chk" data-delsku="${esc(sku)}" data-delrid="${esc(rid)}" ${isDel?'checked':''}>
+          <span class="del-lbl">🗑 delete<br>(keep rest)</span>
+        </span></label>`;
     }
+    // per-SKU running summary
+    h += `<div class="prune-summary" data-prune="${esc(sku)}">${pruneSummaryText(sku)}</div>`;
   }
   h += `</div>`;
 
@@ -431,17 +481,69 @@ function renderCandidates(sku, panel){
 
   panel.innerHTML = h;
 
-  // wire radios
+  // wire radios (choose real mfr + remember which FmPro record is the KEEP)
   for(const rb of panel.querySelectorAll('input[type=radio]')){
     rb.onchange = ()=>{
       const real = rb.dataset.real;
       if(real==='__none__'){ state.candPick[sku]=''; /* leave edits as-is (manual) */ }
       else chooseMfr(sku, real);
-      // update picked outline
+      // If this radio is a FmPro record (has data-rid), it is the "correct/keep" record.
+      if(rb.dataset.rid){
+        state.keepRid[sku] = rb.dataset.rid;
+        // A record can't be both keep and delete — un-mark it from delete if needed.
+        if(state.delRids[sku]) state.delRids[sku].delete(rb.dataset.rid);
+        renderCandidates(sku, panel);   // re-render so keep/del/button reflect the pick
+        return;
+      }
+      // update picked outline (non-FmPro radios: unified / none)
       for(const row of panel.querySelectorAll('.cand-row')) row.classList.remove('picked');
       rb.closest('.cand-row').classList.add('picked');
     };
   }
+
+  // wire per-record "🗑 delete (keep rest)" checkboxes
+  for(const cb of panel.querySelectorAll('.del-chk')){
+    cb.onchange = ()=>{
+      const rid = cb.dataset.delrid;
+      if(!state.delRids[sku]) state.delRids[sku] = new Set();
+      if(cb.checked){
+        state.delRids[sku].add(rid);
+        // marking a record for delete clears it as the keep (mutually exclusive)
+        if(String(state.keepRid[sku])===String(rid)) state.keepRid[sku]='';
+      } else {
+        state.delRids[sku].delete(rid);
+      }
+      renderCandidates(sku, panel);
+      updateSel();
+    };
+  }
+
+  // wire the group-header "Keep selected · delete the other N" convenience button
+  const kdBtn = panel.querySelector('.keep-del-btn');
+  if(kdBtn){
+    kdBtn.onclick = (ev)=>{
+      ev.preventDefault();
+      const keep = state.keepRid[sku];
+      if(!keep) return;                 // button is disabled until a keep is chosen
+      const set = new Set();
+      for(const c of (state.cand[sku]?.filemaker||[])){
+        if(String(c.recordId)!==String(keep)) set.add(String(c.recordId));
+      }
+      state.delRids[sku] = set;
+      renderCandidates(sku, panel);
+      updateSel();
+    };
+  }
+}
+
+// Per-SKU running "N to delete, 1 to keep" summary line.
+function pruneSummaryText(sku){
+  const del = (state.delRids[sku]||new Set()).size;
+  const keep = state.keepRid[sku] ? 1 : 0;
+  if(!del && !keep) return 'no keep/delete marks yet — pick a keep, then mark or bulk-delete the rest';
+  return `<span class="n-del">${del} to delete</span>, <span class="n-keep">${keep} to keep</span>` +
+         (del && !keep ? ' — ⚠ mark a keep before staging' : '') +
+         ' · staged for approval only, not deleted';
 }
 
 $('#search').oninput = (e)=>{ state.search=e.target.value; apply(); };
@@ -470,11 +572,30 @@ $('#stageBtn').onclick = async ()=>{
     const confirmed = (state.edits[id]!==undefined)?state.edits[id]:(r.best_guess_real_mfr||'');
     return { dw_sku:r.dw_sku, bad_mfr:r.bad_mfr, confirmed_real_mfr:confirmed, vendor:r.vendor, source:r.source };
   });
-  $('#stageBtn').disabled=true; $('#stageMsg').textContent='Staging…';
+
+  // Build the per-SKU duplicate-master deletion proposals (keep + delete ids).
+  const deletions = [];
+  for(const [sku, set] of Object.entries(state.delRids)){
+    if(!set || !set.size) continue;
+    const keepRid = state.keepRid[sku] || '';
+    // pull keep's mfr/note from the cached FmPro candidates so the memo can cite them
+    let keepMfr='', keepNote='';
+    const fm = (state.cand[sku]?.filemaker)||[];
+    const keepRec = fm.find(c=>String(c.recordId)===String(keepRid));
+    if(keepRec){ keepMfr = keepRec.noteMfr || keepRec.mfrPattern || ''; keepNote = keepRec.mfrNote || ''; }
+    deletions.push({ dw_sku:sku, keepRecordId:keepRid, keepMfr, keepNote,
+      deleteRecordIds:[...set] });
+  }
+
+  $('#stageBtn').disabled=true; $('#stageMsg').textContent='Staging (snapshotting delete candidates)…';
   const r = await fetch('api/stage',{method:'POST',headers:{'Content-Type':'application/json'},
-    body:JSON.stringify({selected})});
+    body:JSON.stringify({selected, deletions})});
   const j = await r.json();
-  if(j.ok) $('#stageMsg').textContent = `✅ Staged ${j.staged} row(s) for approval → ${j.memoName} (gated only — NOT applied).`;
+  if(j.ok){
+    let msg = `✅ Staged for approval → ${j.memoName} (gated only — NOT applied, NOTHING deleted).`;
+    if(j.deletions) msg = `✅ Staged ${j.staged} repair(s) + ${j.deletions} deletion(s) across ${j.skuWithDeletes} SKU(s) → ${j.memoName}. Restore snapshots: ${j.snapshotsOk} saved${j.snapshotsFailed?`, ⚠ ${j.snapshotsFailed} FAILED`:''}. Gated only — NOTHING deleted.`;
+    $('#stageMsg').textContent = msg;
+  }
   else $('#stageMsg').textContent = '❌ '+j.error;
   $('#stageBtn').disabled=false;
 };
diff --git a/server.js b/server.js
index 65caa06..60fac87 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,7 @@ const ROOT = __dirname;
 const QUEUE_FILE = path.join(ROOT, 'data', 'queue.jsonl');
 const LOADER = path.join(ROOT, 'scripts', 'build-queue.mjs');
 const PENDING_DIR = path.join(os.homedir(), '.claude', 'yolo-queue', 'pending-approval');
+const RESTORE_DIR = path.join(ROOT, 'data', 'restore');
 
 // Basic Auth — admin / DW2024!, overridable via BASIC_AUTH="user:pass".
 const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
@@ -85,6 +86,22 @@ app.get('/api/candidates', async (req, res) => {
   }
 });
 
+// GET /api/fm-record-snapshot?recordId=<id> — READ-ONLY full-field dump of ONE
+// WALLPAPER master, used to snapshot a record before proposing its deletion. Never
+// writes to FileMaker. Lazy ES-module import (a FM/creds hiccup can't break startup).
+let _snapMod = null;
+app.get('/api/fm-record-snapshot', async (req, res) => {
+  const recordId = String((req.query && req.query.recordId) || '').trim();
+  if (!recordId) return res.status(400).json({ ok: false, error: 'recordId required' });
+  try {
+    if (!_snapMod) _snapMod = await import('./lib/fm-record-snapshot.mjs');
+    const snap = await _snapMod.snapshotRecord(recordId);
+    res.json({ ok: true, ...snap });
+  } catch (e) {
+    res.json({ ok: false, error: e.message, recordId });
+  }
+});
+
 // POST /api/rebuild — re-run the loader (behind auth). READ-ONLY vs dw_unified.
 app.post('/api/rebuild', (_req, res) => {
   execFile('node', [LOADER], { maxBuffer: 1024 * 1024 * 256 }, (err, stdout, stderr) => {
@@ -94,36 +111,123 @@ app.post('/api/rebuild', (_req, res) => {
   });
 });
 
-// POST /api/stage — THE ONLY WRITE. Gated memo, never canonical.
-app.post('/api/stage', (req, res) => {
+// POST /api/stage — THE ONLY WRITE. Gated memo (+ reversible restore snapshots),
+// never a canonical/live delete. Accepts:
+//   selected:  [ { dw_sku, bad_mfr, confirmed_real_mfr, vendor, source } ]  (mfr-repair rows)
+//   deletions: [ { dw_sku, keepRecordId, keepMfr, keepNote, deleteRecordIds:[...] } ]
+//              (per-SKU duplicate-master pruning proposals)
+//
+// For EACH delete recordId the server FIRST writes a full-field snapshot to
+// data/restore/<recordId>-<ISO>.json (READ-ONLY FM read) so an approved delete is
+// REVERSIBLE (recreatable). The viewer performs ZERO FileMaker deletes and ZERO
+// canonical writes — it only reads FM + writes the restore JSON + the memo. The actual
+// delete is a SEPARATE Steve-approved step (there is no auto-executor here).
+async function snapshotForRestore(recordId, iso) {
+  try {
+    if (!_snapMod) _snapMod = await import('./lib/fm-record-snapshot.mjs');
+    const snap = await _snapMod.snapshotRecord(recordId);
+    if (!existsSync(RESTORE_DIR)) mkdirSync(RESTORE_DIR, { recursive: true });
+    const file = `${recordId}-${iso}.json`;
+    writeFileSync(path.join(RESTORE_DIR, file), JSON.stringify(snap, null, 2));
+    return { recordId, restorePath: path.join('data', 'restore', file), ok: true,
+      fieldCount: Object.keys(snap.fieldData || {}).length };
+  } catch (e) {
+    // A snapshot must NOT be silently skipped — a delete without a saved snapshot is
+    // NOT reversible, so we record the failure and refuse to list it as restorable.
+    return { recordId, restorePath: null, ok: false, error: e.message };
+  }
+}
+
+app.post('/api/stage', async (req, res) => {
   const selected = Array.isArray(req.body && req.body.selected) ? req.body.selected : [];
-  if (!selected.length) return res.status(400).json({ ok: false, error: 'no rows selected' });
+  const deletions = Array.isArray(req.body && req.body.deletions) ? req.body.deletions : [];
+  if (!selected.length && !deletions.length) {
+    return res.status(400).json({ ok: false, error: 'nothing selected (no rows and no deletions)' });
+  }
 
   const iso = new Date().toISOString().replace(/[:.]/g, '-');
   const memoName = `mfr-repair-${iso}.md`;
   const memoPath = path.join(PENDING_DIR, memoName);
 
   const esc = (s) => String(s == null ? '' : s).replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
-  const header =
-    '| DW SKU | Current Bad Mfr | Confirmed Real Mfr | Vendor | Source |\n' +
-    '|---|---|---|---|---|';
-  const bodyRows = selected.map((r) =>
-    `| ${esc(r.dw_sku)} | ${esc(r.bad_mfr)} | ${esc(r.confirmed_real_mfr)} | ${esc(r.vendor)} | ${esc(r.source)} |`
-  ).join('\n');
-
-  const memo = `# MFR SKU Repair — staged for approval
-
-**Staged:** ${new Date().toISOString()}
-**Count:** ${selected.length} row(s)
-**Summary:** ${selected.length} broken "DW# == Mfr SKU" catalog row(s) selected for mfr-code repair. Each row's "Confirmed Real Mfr" is the reviewer-edited value from mfr-review-viewer.
-
-${header}
-${bodyRows}
+  const inline = (s) => String(s == null ? '' : s).replace(/[\r\n]+/g, ' ').trim();
+
+  // ---- snapshot every delete-candidate FIRST (reversibility record) ----------
+  // Map: recordId -> { restorePath, ok, error } so the memo can cite each snapshot.
+  const snapMap = {};
+  let totalDeletes = 0, snapOk = 0, snapFail = 0;
+  for (const d of deletions) {
+    const ids = Array.isArray(d.deleteRecordIds) ? d.deleteRecordIds.map(String) : [];
+    for (const rid of ids) {
+      totalDeletes++;
+      if (snapMap[rid]) continue;                 // dedupe across SKUs (shouldn't overlap, but safe)
+      const s = await snapshotForRestore(rid, iso);
+      snapMap[rid] = s;
+      s.ok ? snapOk++ : snapFail++;
+    }
+  }
+  const skuWithDeletes = deletions.filter((d) => Array.isArray(d.deleteRecordIds) && d.deleteRecordIds.length).length;
+
+  // ---- build the memo --------------------------------------------------------
+  const parts = [];
+  parts.push('# MFR SKU Repair — staged for approval');
+  parts.push('');
+  parts.push(`**Staged:** ${new Date().toISOString()}`);
+  if (selected.length) parts.push(`**Mfr-repair rows:** ${selected.length}`);
+  if (skuWithDeletes) {
+    parts.push(`**Proposes ${totalDeletes} master deletion(s) across ${skuWithDeletes} SKU(s) — REVERSIBLE via saved field snapshots; APPROVE to execute.**`);
+    if (snapFail) parts.push(`> ⚠ ${snapFail} of ${totalDeletes} delete snapshot(s) FAILED to capture — those deletes are NOT reversible and must NOT be executed (see per-SKU notes).`);
+  }
+  parts.push('');
+  parts.push('**Staged by mfr-review-viewer — NOT deleted. Nothing was written to dw_unified, FileMaker, or Shopify by the viewer.** The actual delete is a SEPARATE Steve-approved step.');
+  parts.push('');
+
+  // -- mfr-repair table (unchanged behavior) --
+  if (selected.length) {
+    parts.push('## Mfr-code repairs');
+    parts.push('');
+    parts.push(`${selected.length} broken "DW# == Mfr SKU" catalog row(s) selected for mfr-code repair. Each row's "Confirmed Real Mfr" is the reviewer-edited value from mfr-review-viewer.`);
+    parts.push('');
+    parts.push('| DW SKU | Current Bad Mfr | Confirmed Real Mfr | Vendor | Source |');
+    parts.push('|---|---|---|---|---|');
+    for (const r of selected) {
+      parts.push(`| ${esc(r.dw_sku)} | ${esc(r.bad_mfr)} | ${esc(r.confirmed_real_mfr)} | ${esc(r.vendor)} | ${esc(r.source)} |`);
+    }
+    parts.push('');
+    parts.push('**APPROVE to apply mfr-code repairs to dw_unified/FileMaker/Shopify — reversible via saved old→new map** (old mfr = "Current Bad Mfr"; new mfr = "Confirmed Real Mfr").');
+    parts.push('');
+  }
 
----
+  // -- duplicate-master pruning proposals (KEEP / DELETE per SKU) --
+  if (skuWithDeletes) {
+    parts.push('## Duplicate-master pruning');
+    parts.push('');
+    parts.push('Per SKU: KEEP the one correct master, DELETE the duplicate masters. Each DELETE has a saved full-field restore snapshot so an approved delete is reversible (recreatable from the snapshot).');
+    parts.push('');
+    for (const d of deletions) {
+      const ids = Array.isArray(d.deleteRecordIds) ? d.deleteRecordIds.map(String) : [];
+      if (!ids.length) continue;
+      parts.push(`### ${esc(d.dw_sku)}`);
+      const keepBits = [];
+      if (d.keepRecordId) keepBits.push(`master ${inline(d.keepRecordId)}`);
+      if (d.keepMfr) keepBits.push(`confirmed mfr: ${inline(d.keepMfr)}`);
+      if (d.keepNote) keepBits.push(`note: ${inline(d.keepNote)}`);
+      parts.push(`KEEP  ${keepBits.length ? keepBits.join('  ') : '(none chosen — reviewer did not mark a keep)'}`);
+      const delLine = ids.map((rid) => inline(rid)).join(', ');
+      parts.push(`DELETE masters: ${delLine}  (duplicates)`);
+      parts.push('  restore snapshots:');
+      for (const rid of ids) {
+        const s = snapMap[rid] || {};
+        if (s.ok) parts.push(`  - ${inline(rid)} → ${s.restorePath}  (${s.fieldCount} fields)`);
+        else parts.push(`  - ${inline(rid)} → ⚠ SNAPSHOT FAILED (${inline(s.error) || 'unknown'}) — NOT reversible, do NOT delete`);
+      }
+      parts.push('');
+    }
+    parts.push('**APPROVE to execute the DELETEs against FileMaker (WALLPAPER) — each is REVERSIBLE by re-creating the record from its saved data/restore/<recordId>-<ISO>.json snapshot.**');
+    parts.push('');
+  }
 
-**APPROVE to apply to dw_unified/FileMaker/Shopify — reversible via saved old→new map** (old mfr = each row's "Current Bad Mfr"; new mfr = "Confirmed Real Mfr"). This memo only STAGES the selection; nothing was written to dw_unified, FileMaker, or Shopify by the viewer.
-`;
+  const memo = parts.join('\n');
 
   try {
     if (!existsSync(PENDING_DIR)) mkdirSync(PENDING_DIR, { recursive: true });
@@ -131,7 +235,16 @@ ${bodyRows}
   } catch (e) {
     return res.status(500).json({ ok: false, error: e.message });
   }
-  res.json({ ok: true, staged: selected.length, memo: memoPath, memoName });
+  res.json({
+    ok: true,
+    staged: selected.length,
+    deletions: totalDeletes,
+    skuWithDeletes,
+    snapshotsOk: snapOk,
+    snapshotsFailed: snapFail,
+    memo: memoPath,
+    memoName,
+  });
 });
 
 app.use(express.static(path.join(ROOT, 'public')));

← b16f1e1 auto-data-snapshot: 2026-08-27T08:19:36 (1 data files) — dat  ·  back to Mfr Review Viewer Corruption  ·  viewer: auto-apply keep/delete rule (delete broken no-sample 8c74f84 →