← 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
M .gitignoreA lib/fm-record-snapshot.mjsM public/index.htmlM server.js
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 →