← back to Filemaker Mcp
GRS dedup review viewer + client-safe scoping + FM flag/archive path; lint, 0.3.2 (session close)
2dd6a25aee1af7a93431fcce9a0bca1d84c09e1d · 2026-08-19 12:37:11 -0700 · steve
Files touched
A dedup-review-server.mjsM grs-dedup-delete.mjsM package-lock.jsonM package.jsonA pull-grs-client.mjs
Diff
commit 2dd6a25aee1af7a93431fcce9a0bca1d84c09e1d
Author: steve <steve@designerwallcoverings.com>
Date: Wed Aug 19 12:37:11 2026 -0700
GRS dedup review viewer + client-safe scoping + FM flag/archive path; lint, 0.3.2 (session close)
---
dedup-review-server.mjs | 122 ++++++++++++++++++++++++++++++++++++++++++++++++
grs-dedup-delete.mjs | 9 ++--
package-lock.json | 4 +-
package.json | 2 +-
pull-grs-client.mjs | 57 ++++++++++++++++++++++
5 files changed, 188 insertions(+), 6 deletions(-)
diff --git a/dedup-review-server.mjs b/dedup-review-server.mjs
new file mode 100644
index 0000000..3b606a8
--- /dev/null
+++ b/dedup-review-server.mjs
@@ -0,0 +1,122 @@
+// GRS dedup review + selection viewer. Basic auth admin/DW2024!. Lists the 111 CLIENT-SAFE
+// duplicate masters; Steve selects 1..all and deletes. Saves each record BEFORE delete (reversible).
+import http from 'http';
+import https from 'https';
+import fs from 'fs';
+import path from 'path';
+const env = JSON.parse(fs.readFileSync('/tmp/fmenv.json', 'utf8'));
+for (const [k, v] of Object.entries(env)) process.env[k] = v;
+// Shopify admin token (for archiving products on delete)
+for (const l of fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8').split('\n')) { const m = l.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); if (m && !(m[1] in process.env)) process.env[m[1]] = m[2]; }
+const SHOP_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN, SHOP_DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const fm = await import('./src/fm-client.js');
+const DB = 'WALLPAPER', LAYOUT = '*List Wallpapers - Full View';
+const rows = JSON.parse(fs.readFileSync('./data/grs-dedup-review.json', 'utf8'));
+const RESTORE = process.env.HOME + '/Desktop/GRS_dedup_viewer_RESTORE_20260819.jsonl';
+const ARCHIVE_RESTORE = process.env.HOME + '/Desktop/GRS_dedup_viewer_ARCHIVE_RESTORE_20260819.jsonl';
+
+function shopGql(query, variables) {
+ return new Promise((res, rej) => { const b = JSON.stringify({ query, variables });
+ const r = https.request({ host: SHOP_DOMAIN, path: '/admin/api/2024-10/graphql.json', method: 'POST', headers: { 'X-Shopify-Access-Token': SHOP_TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(b) } },
+ x => { let d = ''; x.on('data', c => d += c); x.on('end', () => { try { res(JSON.parse(d)); } catch (e) { rej(new Error(d)); } }); });
+ r.on('error', rej); r.write(b); r.end(); });
+}
+// archive a Shopify product, saving its old status first (reversible)
+async function archiveProduct(pid) {
+ const gid = `gid://shopify/Product/${pid}`;
+ const cur = await shopGql(`{ product(id:"${gid}"){ id status } }`);
+ const old = cur?.data?.product?.status;
+ if (!old) return { pid, ok: false, err: 'product not found' };
+ if (old === 'ARCHIVED') return { pid, ok: true, already: true };
+ fs.appendFileSync(ARCHIVE_RESTORE, JSON.stringify({ ts: new Date().toISOString(), product_id: pid, old_status: old }) + '\n');
+ const up = await shopGql(`mutation($id:ID!){ productUpdate(input:{id:$id,status:ARCHIVED}){ userErrors{ message } } }`, { id: gid });
+ const ue = up?.data?.productUpdate?.userErrors;
+ if (ue && ue.length) return { pid, ok: false, err: ue[0].message };
+ return { pid, ok: true, from: old };
+}
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+const PORT = 9799;
+
+const page = () => `<!doctype html><html><head><meta charset=utf8><title>GRS Dedup Review</title>
+<style>
+body{font:14px/1.4 -apple-system,system-ui,sans-serif;margin:0;background:#faf9f7;color:#222}
+header{position:sticky;top:0;background:#1a1a1a;color:#fff;padding:12px 18px;display:flex;gap:16px;align-items:center;flex-wrap:wrap;z-index:5}
+header h1{font-size:16px;margin:0;font-weight:600}
+.pill{background:#333;border-radius:20px;padding:4px 12px;font-size:13px}
+button{font:inherit;padding:7px 14px;border:0;border-radius:7px;cursor:pointer}
+.go{background:#c0392b;color:#fff;font-weight:600}.go:disabled{opacity:.4;cursor:not-allowed}
+.sel{background:#eee}
+table{border-collapse:collapse;width:100%;background:#fff}
+th,td{padding:7px 10px;border-bottom:1px solid #eee;text-align:left;font-size:13px}
+th{background:#f0efec;position:sticky;top:52px;cursor:pointer}
+tr:hover{background:#fcfbf9}
+.del{color:#c0392b;font-family:ui-monospace,monospace}.keep{color:#27ae60;font-family:ui-monospace,monospace}
+code{font-family:ui-monospace,monospace;background:#f3f2ef;padding:1px 5px;border-radius:4px}
+#log{padding:10px 18px;font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap;max-height:220px;overflow:auto;background:#111;color:#0f0;display:none}
+</style></head><body>
+<header>
+<h1>GRS Duplicate Masters — Client-Safe</h1>
+<span class=pill id=total>${rows.length} rows</span>
+<span class=pill id=count>0 selected</span>
+<button class=sel onclick="all(true)">Select all</button>
+<button class=sel onclick="all(false)">Clear</button>
+<button class=go id=go disabled onclick="del()">Flag dup + Archive on Shopify</button>
+<span style="font-size:12px;opacity:.7">No client acct# / no sample date. API delete is blocked by FileMaker — so this FLAGS the dup master (Special Notes “DUP-REMOVE”) + archives its Shopify product. Both reversible.</span>
+</header>
+<div id=log></div>
+<table><thead><tr>
+<th><input type=checkbox onclick="all(this.checked)"></th>
+<th>DW SKU</th><th>Mfr code</th><th>Pattern name</th><th>Net</th>
+<th>Delete record</th><th>Keeper (kept)</th><th>Shopify</th></tr></thead><tbody id=tb></tbody></table>
+<script>
+const rows=${JSON.stringify(rows)};
+const tb=document.getElementById('tb');
+rows.forEach((r,i)=>{const tr=document.createElement('tr');
+tr.innerHTML='<td><input type=checkbox class=cb data-i="'+i+'"></td>'+
+'<td><b>'+r.dw_sku+'</b></td><td><code>'+(r.mfr||'')+'</code></td><td>'+(r.name||'—')+'</td>'+
+'<td>'+(r.net||'')+'</td><td class=del>'+r.delete_record_id+'</td><td class=keep>'+r.keeper_record_id+' <code>'+(r.keeper_mfr||'')+'</code></td>'+
+'<td>'+(r.shopify_product_ids&&r.shopify_product_ids.length?('<span style="color:#c0392b">will archive '+r.shopify_product_ids.length+'</span>'):'<span style="opacity:.4">none</span>')+'</td>';
+tb.appendChild(tr);});
+const cbs=[...document.querySelectorAll('.cb')];
+function upd(){const n=cbs.filter(c=>c.checked).length;document.getElementById('count').textContent=n+' selected';document.getElementById('go').disabled=!n;}
+cbs.forEach(c=>c.onchange=upd);
+function all(v){cbs.forEach(c=>c.checked=v);upd();}
+async function del(){const sel=cbs.filter(c=>c.checked).map(c=>rows[+c.dataset.i]);
+const arch=sel.reduce((n,s)=>n+(s.shopify_product_ids?s.shopify_product_ids.length:0),0);
+if(!confirm('Flag '+sel.length+' duplicate master(s) as DUP-REMOVE and archive '+arch+' Shopify product(s)? Reversible.'))return;
+const log=document.getElementById('log');log.style.display='block';log.textContent='Processing '+sel.length+'...\\n';
+const res=await fetch('/api/delete',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({items:sel.map(s=>({recId:s.delete_record_id,keeper:s.keeper_record_id,productIds:s.shopify_product_ids||[]}))})});
+const j=await res.json();
+log.textContent+='\\nDONE: '+j.ok+' flagged, '+j.fail+' failed.\\n'+j.results.map(r=>(r.ok?' OK ':' FAIL ')+r.id+(r.ok?(' (Shopify archived: '+r.archived+')'):'')+(r.err?' '+r.err:'')).join('\\n');
+sel.forEach((s,k)=>{if(j.results.find(r=>r.id===s.delete_record_id&&r.ok)){cbs.find(c=>+c.dataset.i===rows.indexOf(s)).closest('tr').style.opacity=.3;}});
+}
+</script></body></html>`;
+
+http.createServer(async (req, res) => {
+ if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm=dedup' }); return res.end('auth'); }
+ if (req.method === 'GET' && req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page()); }
+ if (req.method === 'POST' && req.url === '/api/delete') {
+ let body = ''; req.on('data', c => body += c); req.on('end', async () => {
+ const { items } = JSON.parse(body || '{}'); const results = [];
+ const today = '2026-08-19';
+ for (const it of (items || [])) {
+ const id = it.recId; let archived = [];
+ try {
+ // API DELETE is blocked by FileMaker's [110] cascade, so we MARK the dup master
+ // (reversible: clear Special Notes) instead of deleting, then archive its Shopify product.
+ const g = await fm.getRecord(DB, LAYOUT, id).catch(() => null);
+ fs.appendFileSync(RESTORE, JSON.stringify({ ts: new Date().toISOString(), recId: id, old_special_notes: g?.fieldData?.['Special Notes'] ?? '', fieldData: g?.fieldData || null }) + '\n');
+ await fm.updateRecord(DB, LAYOUT, id, { 'Special Notes': `DUP-REMOVE keeper:${it.keeper || ''} ${today}` }, { dryRun: false });
+ // then archive the Shopify product(s) for this SKU ("if deleted, archive too")
+ for (const pid of (it.productIds || [])) { archived.push(await archiveProduct(pid)); }
+ const archOk = archived.filter(a => a.ok).length;
+ results.push({ id, ok: true, archived: archOk, archErr: archived.filter(a => !a.ok).map(a => a.err) });
+ } catch (e) { results.push({ id, ok: false, err: String(e.message || e).slice(0, 80) }); }
+ }
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ ok: results.filter(r => r.ok).length, fail: results.filter(r => !r.ok).length, results }));
+ });
+ return;
+ }
+ res.writeHead(404); res.end('nf');
+}).listen(PORT, '127.0.0.1', () => console.log(`dedup review viewer -> http://127.0.0.1:${PORT} (admin/DW2024!)`));
diff --git a/grs-dedup-delete.mjs b/grs-dedup-delete.mjs
index 4cea7ab..457f306 100644
--- a/grs-dedup-delete.mjs
+++ b/grs-dedup-delete.mjs
@@ -7,13 +7,15 @@ import path from 'path';
const env = JSON.parse(fs.readFileSync('/tmp/fmenv.json', 'utf8'));
for (const [k, v] of Object.entries(env)) process.env[k] = v;
const fm = await import('./src/fm-client.js');
-const DB = 'WALLPAPER', LAYOUT = 'Basic List of Fields';
+const DB = 'WALLPAPER', LAYOUT = '*List Wallpapers - Full View'; // delete-safe TO (Basic List of Fields hits [110] cascade)
const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const Q = String.fromCharCode(34);
function P(t) { const R = []; let i = 0, f = '', r = [], q = false; while (i < t.length) { const c = t[i]; if (q) { if (c === Q) { if (t[i + 1] === Q) { f += Q; i++; } else q = false; } else f += c; } else { if (c === Q) q = true; else if (c === ',') { r.push(f); f = ''; } else if (c === '\n') { r.push(f); R.push(r); r = []; f = ''; } else if (c !== '\r') f += c; } i++; } if (f.length || r.length) { r.push(f); R.push(r); } return R; }
const raw = P(fs.readFileSync(process.env.HOME + '/Desktop/GRS_dedup_DELETE_plan_20260819.csv', 'utf8'));
const h = raw.shift(); const ix = Object.fromEntries(h.map((x, i) => [x, i]));
-const targets = raw.filter(r => r.length && r[ix.disposition] === 'safe-delete');
+let targets = raw.filter(r => r.length && r[ix.disposition] === 'safe-delete');
+if (LIMIT) targets = targets.slice(0, LIMIT);
const RESTORE = process.env.HOME + '/Desktop/GRS_dedup_RESTORE_' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + '.jsonl';
console.log(`safe-delete targets: ${targets.length} (${APPLY ? 'LIVE DELETE' : 'DRY-RUN'})`);
if (!APPLY) { console.log(' [dry-run] no deletions. Re-run with --apply.'); process.exit(0); }
@@ -26,6 +28,7 @@ for (const r of targets) {
fs.appendFileSync(RESTORE, JSON.stringify({ recId, combo: r[ix.combo], keeper: r[ix.keeper_record_id], fieldData: fd }) + '\n');
await fm.deleteRecord(DB, LAYOUT, recId);
done++; if (done % 25 === 0) console.log(` ...deleted ${done}/${targets.length}`);
- } catch (e) { fail++; console.error(` FAIL rec ${recId}: ${e.message}`); }
+ await new Promise(r => setTimeout(r, 120));
+ } catch (e) { fail++; console.error(` FAIL rec ${recId}: ${e.message}`); await new Promise(r => setTimeout(r, 300)); }
}
console.log(`\nLIVE dedup: deleted ${done}, failed ${fail}. Restore -> ${RESTORE}`);
diff --git a/package-lock.json b/package-lock.json
index 25702fd..8008f6b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "filemaker-mcp",
- "version": "0.3.1",
+ "version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "filemaker-mcp",
- "version": "0.3.1",
+ "version": "0.3.2",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"amazon-cognito-identity-js": "^6.3.12",
diff --git a/package.json b/package.json
index 20b026d..dbdcf2f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "filemaker-mcp",
- "version": "0.3.1",
+ "version": "0.3.2",
"private": true,
"type": "module",
"description": "MCP server + CLI for reading and (confirmed) updating Designer Wallcoverings' FileMaker Cloud files (Clients, Invoice, WALLPAPER) via the FileMaker Data API.",
diff --git a/pull-grs-client.mjs b/pull-grs-client.mjs
new file mode 100644
index 0000000..0b5e250
--- /dev/null
+++ b/pull-grs-client.mjs
@@ -0,0 +1,57 @@
+// Pull GRS via Full View, capturing CLIENT-ACTIVITY signals, then intersect with the dedup
+// plan to produce a CLIENT-SAFE deletable list: duplicate masters that NO client ever touched.
+// Rule (Steve): do NOT delete if it has a client account number OR any sample-request/sent date.
+// Read-only.
+import fs from 'fs';
+const env = JSON.parse(fs.readFileSync('/tmp/fmenv.json', 'utf8'));
+for (const [k, v] of Object.entries(env)) process.env[k] = v;
+const fm = await import('./src/fm-client.js');
+const DB = 'WALLPAPER', LAYOUT = '*List Wallpapers - Full View', PAGE = 400;
+const Q = String.fromCharCode(34);
+
+// Any of these populated => a client touched this record => NEVER delete.
+const ACCOUNT_FIELDS = ['account', 'Clients::Company', 'Sidemark', 'project name', 'Client Notes'];
+const DATE_FIELDS = ['today for client', 'Date WP Sample Sent', 'Date Sample Request printed for vendor', 'Date Email Sent to Vendor after 10 Days'];
+
+const touched = new Map(); // recordId -> reason string ('' if clean)
+let offset = 1, total = null;
+while (true) {
+ let res, tries = 0;
+ while (true) { try { res = await fm.findRecords(DB, LAYOUT, [{ Series: 'GRS' }], { limit: PAGE, offset }); break; } catch (e) { if (String(e.message).includes('[401]')) { res = { records: [] }; break; } if (++tries >= 4) throw e; await new Promise(r => setTimeout(r, 1500 * tries)); } }
+ total = res?.dataInfo?.totalRecordCount ?? total;
+ const recs = res.records || []; if (!recs.length) break;
+ for (const r of recs) {
+ const f = r.fieldData || {};
+ const acct = ACCOUNT_FIELDS.filter(k => String(f[k] ?? '').trim() !== '');
+ const dates = DATE_FIELDS.filter(k => String(f[k] ?? '').trim() !== '');
+ const reasons = [...acct.map(k => 'acct:' + k), ...dates.map(k => 'date:' + k)];
+ touched.set(r.recordId, reasons.join(';'));
+ }
+ offset += PAGE; if (total && offset > total) break;
+}
+
+// intersect with dedup safe-delete plan
+function P(t){const R=[];let i=0,f='',r=[],q=false;while(i<t.length){const c=t[i];if(q){if(c===Q){if(t[i+1]===Q){f+=Q;i++;}else q=false;}else f+=c;}else{if(c===Q)q=true;else if(c===','){r.push(f);f='';}else if(c==='\n'){r.push(f);R.push(r);r=[];f='';}else if(c!=='\r')f+=c;}i++;}if(f.length||r.length){r.push(f);R.push(r);}return R;}
+const plan = P(fs.readFileSync(process.env.HOME + '/Desktop/GRS_dedup_DELETE_plan_20260819.csv', 'utf8'));
+const ph = plan.shift(); const pi = Object.fromEntries(ph.map((x, i) => [x, i]));
+const safeDel = plan.filter(r => r.length && r[pi.disposition] === 'safe-delete');
+
+let clientSafe = [], held = [];
+for (const r of safeDel) {
+ const rid = r[pi.delete_record_id];
+ const reason = touched.get(rid);
+ if (reason === undefined) { held.push([...r.slice(0,7), 'NOT-FOUND-IN-FULLVIEW']); continue; } // conservative: hold
+ if (reason) held.push([r[pi.combo], rid, r[pi.mfr_pattern], reason]);
+ else clientSafe.push([r[pi.combo], rid, r[pi.mfr_pattern], r[pi.keeper_record_id]]);
+}
+const qq = x => Q + String(x ?? '').replace(/"/g, Q + Q) + Q;
+const outSafe = process.env.HOME + '/Desktop/GRS_dedup_CLIENT_SAFE_delete_20260819.csv';
+const outHeld = process.env.HOME + '/Desktop/GRS_dedup_HELD_client_touched_20260819.csv';
+fs.writeFileSync(outSafe, 'combo,delete_record_id,mfr_pattern,keeper_record_id\n' + clientSafe.map(r => r.map(qq).join(',')).join('\n') + '\n');
+fs.writeFileSync(outHeld, 'combo,delete_record_id,mfr_pattern,hold_reason\n' + held.map(r => r.map(qq).join(',')).join('\n') + '\n');
+
+console.log('=== CLIENT-SAFE dedup scope (Steve rule: no acct#, no sample date) ===');
+console.log(' dedup safe-delete targets (pre-client-check):', safeDel.length);
+console.log(' -> CLIENT-SAFE to delete (never client-touched):', clientSafe.length, '->', outSafe);
+console.log(' -> HELD (has client acct# or a sample date):', held.length, '->', outHeld);
+console.log(' GRS records scanned for client activity:', touched.size);
← 0ac2c19 fm-client: dedupe guard on createRecord — block duplicate WA
·
back to Filemaker Mcp
·
Otto importer: guard against DW#==mfr placeholders; read rea e1069c1 →