← back to Rentv Sheet Enrich Refine
Left-panel Sheets selector: check 1..all sheets by name (with row counts); multi-select shows COMBINED data via /api/combined (union columns by header, Sheet Name leads, green remapped); click a name = only that sheet; All/None; layout persists per-view
775dcabb805d80e4c6ef639bb460645d777cb3c9 · 2026-08-13 15:08:13 -0700 · Steve Abrams
Files touched
M live_server.pyM public_live/index.html
Diff
commit 775dcabb805d80e4c6ef639bb460645d777cb3c9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 13 15:08:13 2026 -0700
Left-panel Sheets selector: check 1..all sheets by name (with row counts); multi-select shows COMBINED data via /api/combined (union columns by header, Sheet Name leads, green remapped); click a name = only that sheet; All/None; layout persists per-view
---
live_server.py | 34 +++++++++++++++++++++++++++
public_live/index.html | 64 +++++++++++++++++++++++++++++++++-----------------
2 files changed, 77 insertions(+), 21 deletions(-)
diff --git a/live_server.py b/live_server.py
index a9dd189..510fc2e 100644
--- a/live_server.py
+++ b/live_server.py
@@ -87,6 +87,38 @@ def green_map(gid):
t = SNAP["_by_gid"][int(gid)]
return {"gid": t["gid"], "green": t["green"]}
+def combined(gids):
+ """Merge several tabs into ONE view: columns = union of their headers (by name),
+ rows concatenated, each already carrying its Sheet Name. Green coords remapped."""
+ tabs = [SNAP["_by_gid"][int(g)] for g in gids if int(g) in SNAP["_by_gid"]]
+ order = []; seen = set()
+ for t in tabs:
+ for h in t["headers"]:
+ if h and h not in seen:
+ seen.add(h); order.append(h)
+ if "Sheet Name" in order:
+ order.remove("Sheet Name"); order.insert(0, "Sheet Name")
+ colidx = {h: i for i, h in enumerate(order)}
+ rows = []; green = []
+ for t in tabs:
+ hmap = [colidx.get(h) for h in t["headers"]] # tab column -> unified column
+ gset = set(t.get("green", []))
+ base = len(rows)
+ for ri, r in enumerate(t["rows"]):
+ newrow = [""] * len(order)
+ for ci, val in enumerate(r):
+ ui = hmap[ci] if ci < len(hmap) else None
+ if ui is not None:
+ newrow[ui] = val
+ rows.append(newrow)
+ for ci in range(len(t["headers"])):
+ if f"{ri},{ci}" in gset:
+ ui = hmap[ci]
+ if ui is not None:
+ green.append(f"{base+ri},{ui}")
+ return {"gid": "combined", "title": f"{len(tabs)} sheets combined",
+ "headers": order, "rows": rows, "green": green, "generated": SNAP["generated"]}
+
def write_cell(gid, row0, col0, value, formula=False):
# 1) LIVE write to the sheet (row0 is 0-based over DATA rows; +1 for the header)
cell = {"row0": int(row0) + 1, "col0": int(col0), "value": value}
@@ -170,6 +202,8 @@ class H(BaseHTTPRequestHandler):
self._json(tab(q.get("gid", "0")))
elif path == "/api/format":
self._json(green_map(q.get("gid", "0")))
+ elif path == "/api/combined":
+ self._json(combined([g for g in q.get("gids", "").split(",") if g.strip()]))
elif "/../" not in path and (path.startswith("/nav-agent/") or path.endswith((".css", ".js"))):
fp = os.path.join(HERE, "public_live", path.lstrip("/"))
if os.path.isfile(fp):
diff --git a/public_live/index.html b/public_live/index.html
index 40d1bbc..91a27d5 100644
--- a/public_live/index.html
+++ b/public_live/index.html
@@ -150,6 +150,8 @@ const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s);
const LS={get:(k,d)=>{try{return JSON.parse(localStorage.getItem('rc2.'+k))??d}catch(e){return d}},set:(k,v)=>localStorage.setItem('rc2.'+k,JSON.stringify(v))};
let DATA={gid:0,title:'',headers:[],rows:[],green:[]}, GREENSET=new Set(), ROLE={}, META=null;
let VIEW=LS.get('view','table'), GID=LS.get('gid',null), editing=false, curRow=null, curChan='email';
+let SELECTED=new Set(LS.get('sheets',[])); // selected sheet gids (1..all) shown combined
+function curKey(){ return SELECTED.size===1 ? String([...SELECTED][0]) : ('combo.'+[...SELECTED].sort().join('-')); }
let FILTERS={}, SEARCH='', SORT=LS.get('sort',null), GROUP=LS.get('group',''), HIDDEN=new Set(LS.get('hidden.'+(GID||''),[])), COLW={}, ORDER=[], dragCol=null;
const MAXROWS=2500;
function pk(){return 'p'+GID} // per-tab localStorage namespace
@@ -171,22 +173,28 @@ async function loadMeta(){
const sel=$('#tab');sel.innerHTML='';
META.tabs.forEach(t=>{const o=document.createElement('option');o.value=t.gid;o.textContent=`${t.title} (${t.rows})`;sel.appendChild(o)});
if(GID==null||!META.tabs.find(t=>t.gid==GID)){
- // default to the main contacts tab — NOT tabs[0] (which is the tiny Color Legend)
const primary = META.tabs.find(t=>/unique contacts/i.test(t.title))
- || META.tabs.slice().sort((a,b)=>b.rows-a.rows)[0] // else the biggest data tab
- || META.tabs[0];
+ || META.tabs.slice().sort((a,b)=>b.rows-a.rows)[0] || META.tabs[0];
GID=primary.gid;
}
+ // keep the selection valid; default to the primary tab
+ SELECTED=new Set([...SELECTED].filter(g=>META.tabs.find(t=>t.gid==g)));
+ if(!SELECTED.size) SELECTED.add(GID);
sel.value=GID;
}
-async function loadTab(){
+async function loadData(){
setSync('load');
- const d=await (await fetch('/api/tab?gid='+GID)).json();
+ const gids=[...SELECTED];
+ let d;
+ if(gids.length<=1){ GID=gids[0]??GID; LS.set('gid',GID); d=await (await fetch('/api/tab?gid='+GID)).json(); }
+ else { d=await (await fetch('/api/combined?gids='+gids.join(','))).json(); }
+ LS.set('sheets',[...SELECTED]);
DATA=d; GREENSET=new Set(d.green||[]); detectRoles();
FILTERS={}; SEARCH=''; $('#search')&&($('#search').value='');
- HIDDEN=new Set(LS.get('hidden.'+GID,[]));
- COLW=LS.get('colw.'+GID,{});
- ORDER=LS.get('order.'+GID,[]); // saved column order (per tab, per user)
+ const K=curKey();
+ HIDDEN=new Set(LS.get('hidden.'+K,[]));
+ COLW=LS.get('colw.'+K,{});
+ ORDER=LS.get('order.'+K,[]); // saved column order (per view, per user)
if(!ORDER.length){ // default: pull First/Last Name up next to Contact Name
const find=re=>DATA.headers.findIndex(h=>re.test(String(h).trim()));
const nc=find(/^contact name/i), fn=find(/^first name$/i), ln=find(/^last name$/i);
@@ -225,7 +233,9 @@ function filtered(){ let rows=DATA.rows.map((r,i)=>[r,i]).filter(([r])=>passes(r
function buildRail(){
const rail=$('#rail'); if(rail.classList.contains('hide')){return}
const H=DATA.headers, opts=H.map((h,i)=>h?`<option value="${i}">${esc(h)}</option>`:'').join('');
- let html=`<input type="search" id="search" placeholder="Search all fields…" value="${esc(SEARCH)}">
+ let html=`<details class="sec" open><summary>Sheets (${META?META.tabs.length:0}) · <span class="mini" id="alls">all</span> / <span class="mini" id="nones">none</span></summary>
+ <div id="sheets"></div></details>
+ <input type="search" id="search" placeholder="Search all fields…" value="${esc(SEARCH)}">
<div class="rrow"><label>Sort</label><select id="sortsel"><option value="">— none —</option>${opts}</select>
<button id="sortdir" title="asc/desc">${SORT&&SORT.dir<0?'▼':'▲'}</button></div>
<div class="rrow"><label>Group</label><select id="groupsel"><option value="">— none —</option>${opts}</select></div>
@@ -236,6 +246,7 @@ function buildRail(){
<details class="sec" open><summary>Filters — click a value</summary><div id="facets"></div></details>`;
rail.innerHTML=html;
$('#sortsel').value=SORT?SORT.col:''; $('#groupsel').value=GROUP;
+ renderSheets(); // sheet checklist: pick 1..all, shown combined
renderFields(); // field list = show/hide + move up/down (reflects & drives column order)
buildFacets();
wireRail();
@@ -272,8 +283,10 @@ function wireRail(){
$('#groupsel').onchange=e=>{GROUP=e.target.value;LS.set('group',GROUP);render()};
$('#dens').oninput=e=>{document.documentElement.style.setProperty('--fs',e.target.value+'px')};
$('#fitcols').onclick=fitAllCols; $('#resetcols').onclick=resetLayout;
- $('#allf').onclick=()=>{HIDDEN.clear();LS.set('hidden.'+GID,[]);buildRail();render()};
- $('#nonef').onclick=()=>{HIDDEN=new Set(DATA.headers.map((h,i)=>h?String(i):null).filter(x=>x));LS.set('hidden.'+GID,[...HIDDEN]);buildRail();render()};
+ $('#alls').onclick=()=>{SELECTED=new Set(META.tabs.map(t=>t.gid));loadData()};
+ $('#nones').onclick=()=>{SELECTED=new Set([GID]);loadData()};
+ $('#allf').onclick=()=>{HIDDEN.clear();LS.set('hidden.'+curKey(),[]);buildRail();render()};
+ $('#nonef').onclick=()=>{HIDDEN=new Set(DATA.headers.map((h,i)=>h?String(i):null).filter(x=>x));LS.set('hidden.'+curKey(),[...HIDDEN]);buildRail();render()};
}
// ---- render: table | list | grid --------------------------------------------
@@ -289,11 +302,20 @@ function orderedFields(){ // all fields, in the current column order (hidden i
all.sort((a,b)=>(pos.has(a.i)?pos.get(a.i):1e4+a.i)-(pos.has(b.i)?pos.get(b.i):1e4+b.i));}
return all;
}
+function renderSheets(){
+ const host=$('#sheets'); if(!host||!META)return;
+ host.innerHTML=META.tabs.map(t=>`<div class="ftog" data-g="${t.gid}"><input type="checkbox" ${SELECTED.has(t.gid)?'checked':''}><span class="fl" title="${esc(t.title)}">${esc(t.title)} <span style="color:var(--dim)">(${t.rows})</span></span></div>`).join('');
+ host.querySelectorAll('.ftog').forEach(row=>{const g=+row.dataset.g;
+ const set=v=>{v?SELECTED.add(g):SELECTED.delete(g); if(!SELECTED.size)SELECTED.add(g); loadData();};
+ row.querySelector('input').onchange=e=>set(e.target.checked);
+ row.querySelector('.fl').onclick=()=>{ SELECTED=new Set([g]); loadData(); }; // click name = ONLY this sheet
+ });
+}
function renderFields(){
const host=$('#ftogs'); if(!host)return;
host.innerHTML=orderedFields().map(o=>`<div class="ftog" data-c="${o.i}"><span class="grip" title="drag to reorder">⠿</span><input type="checkbox" ${HIDDEN.has(String(o.i))?'':'checked'}><span class="fl" title="${esc(o.h)}">${esc(o.h)}</span></div>`).join('');
host.querySelectorAll('.ftog').forEach(row=>{const ci=+row.dataset.c;
- row.querySelector('input').onchange=e=>{e.target.checked?HIDDEN.delete(String(ci)):HIDDEN.add(String(ci));LS.set('hidden.'+GID,[...HIDDEN]);render()};
+ row.querySelector('input').onchange=e=>{e.target.checked?HIDDEN.delete(String(ci)):HIDDEN.add(String(ci));LS.set('hidden.'+curKey(),[...HIDDEN]);render()};
row.querySelector('.grip').addEventListener('mousedown',e=>startFieldDrag(e,ci,row));
});
}
@@ -310,17 +332,17 @@ function reorder(from,to){ // shared: move column 'from' to 'to's slot; used b
DATA.headers.forEach((h,i)=>{if(h&&!order.includes(i))order.push(i)});
order=order.filter(x=>x!==from);
const ti=order.indexOf(to); order.splice(ti<0?order.length:ti,0,from);
- ORDER=order; LS.set('order.'+GID,ORDER); render(); renderFields(); // sync BOTH views
+ ORDER=order; LS.set('order.'+curKey(),ORDER); render(); renderFields(); // sync BOTH views
}
const moveCol=reorder, moveFieldTo=reorder;
function fitAllCols(){ // expand every visible column to fit its content
visCols().forEach(c=>{ let max=String(DATA.headers[c.i]||'').length;
document.querySelectorAll(`#grid td[data-c="${c.i}"]`).forEach(td=>{const t=td.textContent||'';if(t.length>max)max=t.length});
COLW[c.i]=Math.min(640,Math.max(70,Math.round(max*7.1)+26)); });
- LS.set('colw.'+GID,COLW); render();
+ LS.set('colw.'+curKey(),COLW); render();
}
-function resetLayout(){ COLW={}; ORDER=[]; LS.set('colw.'+GID,{}); LS.set('order.'+GID,[]);
- ORDER=LS.get('order.'+GID,[]); buildRail(); render(); }
+function resetLayout(){ COLW={}; ORDER=[]; LS.set('colw.'+curKey(),{}); LS.set('order.'+curKey(),[]);
+ ORDER=LS.get('order.'+curKey(),[]); buildRail(); render(); }
function render(){
const wrap=$('#wrap'); $$('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
const rows=filtered();
@@ -398,7 +420,7 @@ function wireGrid(){
const mv=ev=>{ newW=Math.max(60,startW+(ev.clientX-startX)); guide.style.left=(startX+(newW-startW))+'px'; };
const up=()=>{ document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);
guide.remove(); document.body.classList.remove('resizing');
- COLW[ci]=Math.round(newW); LS.set('colw.'+GID,COLW); render(); }; // full re-render = exact width, no re-proportioning
+ COLW[ci]=Math.round(newW); LS.set('colw.'+curKey(),COLW); render(); }; // full re-render = exact width, no re-proportioning
document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);
}));
// double-click a resize handle to auto-fit the column to its content
@@ -408,7 +430,7 @@ function wireGrid(){
const col=document.querySelectorAll('#grid colgroup col')[idx+1];
let max=String(DATA.headers[ci]||'').length;
document.querySelectorAll('#grid tbody tr').forEach(tr=>{const t=tr.children[idx+1]?.textContent||'';if(t.length>max)max=t.length;});
- const w=Math.min(600,Math.max(70,Math.round(max*7.1)+26));col.style.width=w+'px';COLW[ci]=w;LS.set('colw.'+GID,COLW);
+ const w=Math.min(600,Math.max(70,Math.round(max*7.1)+26));col.style.width=w+'px';COLW[ci]=w;LS.set('colw.'+curKey(),COLW);
}));
$$('td[contenteditable]').forEach(td=>{td.addEventListener('focus',()=>{editing=true;td._o=td.textContent});td.addEventListener('blur',()=>saveCell(td));td.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();td.blur()}})});
}
@@ -461,10 +483,10 @@ async function doEnhance(){const b=$('#enhance'),o=b.textContent;b.textContent='
function doGo(){const c=contact(DATA.rows[curRow]);if(curChan==='email'){if(!c.email)return alert('No email');location.href='mailto:'+c.email+'?subject='+encodeURIComponent($('#d-subject').value)+'&body='+encodeURIComponent($('#d-body').value)}else if(curChan==='linkedin'){const u=c.liC||c.mvpL||c.liCo;if(!u)return alert('No LinkedIn');navigator.clipboard?.writeText($('#d-body').value);window.open(u,'_blank','noopener')}else{if(!c.phone)return alert('No phone');location.href='tel:'+c.phone.replace(/[^0-9+]/g,'')}}
// ---- events + boot -----------------------------------------------------------
-$('#tab').onchange=e=>{GID=+e.target.value;LS.set('gid',GID);loadTab()};
+$('#tab').onchange=e=>{SELECTED=new Set([+e.target.value]);loadData()};
$$('#viewseg button').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;LS.set('view',VIEW);render()});
$('#railtog').onclick=()=>{$('#rail').classList.toggle('hide');if(!$('#rail').classList.contains('hide'))buildRail()};
-$('#refresh').onclick=async()=>{setSync('load');$('#refresh').textContent='↻ refreshing…';try{await fetch('/api/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});await loadMeta();$('#tab').value=GID;await loadTab()}catch(e){setSync('err')}$('#refresh').textContent='↻ Refresh'};
+$('#refresh').onclick=async()=>{setSync('load');$('#refresh').textContent='↻ refreshing…';try{await fetch('/api/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});await loadMeta();$('#tab').value=GID;await loadData()}catch(e){setSync('err')}$('#refresh').textContent='↻ Refresh'};
$('#dclose').onclick=closeDraft;$('#scrim').onclick=closeDraft;
(function(){const dr=$('#drawer'),h=$('#dresize');const sw=LS.get('drawerW',0);if(sw)dr.style.width=sw+'px';
h.addEventListener('mousedown',e=>{e.preventDefault();dr.classList.add('resizing');
@@ -480,7 +502,7 @@ function applyTheme(t){document.body.classList.toggle('light',t==='light');$('#t
$('#theme').onclick=()=>{const t=document.body.classList.contains('light')?'dark':'light';LS.set('theme',t);applyTheme(t)};
applyTheme(LS.get('theme','dark'));
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeDraft()});
-(async()=>{await loadMeta();await loadTab()})();
+(async()=>{await loadMeta();await loadData()})();
</script>
</body>
</html>
← 58db3f6 Composer: wider default (620px) + drag left edge to resize/e
·
back to Rentv Sheet Enrich Refine
·
Server: send no-store/no-cache headers so the browser always 58c6056 →