← back to Rentv Sheet Enrich Refine
Field reorder = drag (removed up/down buttons): left FIELDS rows have a grip to drag-reorder; table header drag + panel drag share one reorder() that syncs BOTH views; column order persists per tab. Also: split Phone(s) into Phone 1-4 columns (tap-to-call)
f54646e95654f3110bc161ddb25aa8e05b809a88 · 2026-08-13 14:46:41 -0700 · Steve Abrams
Files touched
M public_live/index.htmlA split_phones.py
Diff
commit f54646e95654f3110bc161ddb25aa8e05b809a88
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 13 14:46:41 2026 -0700
Field reorder = drag (removed up/down buttons): left FIELDS rows have a grip to drag-reorder; table header drag + panel drag share one reorder() that syncs BOTH views; column order persists per tab. Also: split Phone(s) into Phone 1-4 columns (tap-to-call)
---
public_live/index.html | 38 ++++++++++++------------
split_phones.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 99 insertions(+), 19 deletions(-)
diff --git a/public_live/index.html b/public_live/index.html
index 5400835..4d23dbc 100644
--- a/public_live/index.html
+++ b/public_live/index.html
@@ -51,10 +51,11 @@
.val .n{color:var(--dim);font-size:10px;margin-left:auto}
.val .bx{width:12px;height:12px;border:1px solid var(--line);border-radius:3px;flex:0 0 12px}
.val.on .bx{background:var(--accent);border-color:var(--accent)}
- .ftog{display:flex;align-items:center;gap:6px;padding:2px 4px;font-size:12px}
+ .ftog{display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:12px;border-radius:5px}
.ftog input{margin:0} .ftog .fl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
- .ftog .mv{display:flex;gap:1px;opacity:.45} .ftog:hover .mv{opacity:1}
- .ftog .mv button{padding:0 4px;font-size:9px;line-height:16px;border-radius:4px}
+ .ftog .grip{cursor:grab;color:var(--dim);font-size:12px;user-select:none;padding:0 2px}
+ .ftog .grip:active{cursor:grabbing}
+ .ftog.dragging{opacity:.4} .ftog.drop{box-shadow:inset 0 2px 0 var(--accent)}
.mini{font-size:10px;color:var(--dim);cursor:pointer} .mini:hover{color:var(--accent)}
/* table */
table{border-collapse:separate;border-spacing:0;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:10px}
@@ -280,29 +281,28 @@ function orderedFields(){ // all fields, in the current column order (hidden i
}
function renderFields(){
const host=$('#ftogs'); if(!host)return;
- host.innerHTML=orderedFields().map(o=>`<div class="ftog" data-c="${o.i}"><input type="checkbox" ${HIDDEN.has(String(o.i))?'':'checked'}><span class="fl" title="${esc(o.h)}">${esc(o.h)}</span><span class="mv"><button class="up" title="move up">▲</button><button class="dn" title="move down">▼</button></span></div>`).join('');
+ 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('.up').onclick=()=>moveField(ci,-1);
- row.querySelector('.dn').onclick=()=>moveField(ci,1);
+ row.querySelector('.grip').addEventListener('mousedown',e=>startFieldDrag(e,ci,row));
});
}
-function moveField(ci,dir){
- let order=(ORDER&&ORDER.length)?ORDER.slice():orderedFields().map(o=>o.i);
- DATA.headers.forEach((h,i)=>{if(h&&!order.includes(i))order.push(i)});
- const idx=order.indexOf(ci), ni=idx+dir;
- if(idx<0||ni<0||ni>=order.length)return;
- order.splice(idx,1); order.splice(ni,0,ci);
- ORDER=order; LS.set('order.'+GID,ORDER); renderFields(); render();
+function startFieldDrag(e,ci,row){
+ e.preventDefault();
+ const host=$('#ftogs'); row.classList.add('dragging');
+ const at=y=>[...host.querySelectorAll('.ftog')].find(r=>{const b=r.getBoundingClientRect();return y>=b.top&&y<=b.bottom});
+ const mv=ev=>{host.querySelectorAll('.ftog').forEach(r=>r.classList.remove('drop'));const t=at(ev.clientY);if(t&&t!==row)t.classList.add('drop')};
+ const up=ev=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);row.classList.remove('dragging');host.querySelectorAll('.ftog').forEach(r=>r.classList.remove('drop'));const t=at(ev.clientY);if(t&&+t.dataset.c!==ci)moveFieldTo(ci,+t.dataset.c)};
+ document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);
}
-function moveCol(from,to){
- let order=(ORDER&&ORDER.length)?ORDER.slice():visCols().map(c=>c.i); // seed from current order
- DATA.headers.forEach((h,i)=>{ if(h && !order.includes(i)) order.push(i); }); // include any missing
+function reorder(from,to){ // shared: move column 'from' to 'to's slot; used by table AND panel drags
+ let order=(ORDER&&ORDER.length)?ORDER.slice():visCols().map(c=>c.i);
+ 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();
+ 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
}
+const moveCol=reorder, moveFieldTo=reorder;
function render(){
const wrap=$('#wrap'); $$('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
const rows=filtered();
diff --git a/split_phones.py b/split_phones.py
new file mode 100644
index 0000000..11eee68
--- /dev/null
+++ b/split_phones.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""
+split_phones.py — on every tab that has a combined "Phone(s)" column, parse the multiple
+numbers out of each cell and distribute them into Phone 1 / Phone 2 / Phone 3 (/4) columns
+(created at the right if missing). Each becomes a single number the console renders as a
+tap-to-call tel: link. Quota-safe: one values.batchUpdate per tab. ADD-only: won't clobber
+an existing Phone N value. Leaves the source "Phone(s)" column intact (hide it in the UI).
+"""
+import lib, json, re, urllib.request, urllib.parse, urllib.error, time
+PHONE = re.compile(r'(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?:\s*(?:x|ext\.?)\s*\d+)?', re.I)
+MAXK = 4
+
+def req(method, url, tok, body=None):
+ r = urllib.request.Request(url, data=(None if body is None else json.dumps(body).encode()),
+ method=method, headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"})
+ try:
+ return json.load(urllib.request.urlopen(r))
+ except urllib.error.HTTPError as e:
+ raise RuntimeError(f"{e.code}: {e.read().decode()[:200]}")
+
+def header_row(rows):
+ best, bi = -1, 0
+ for i in range(min(4, len(rows))):
+ n = sum(1 for c in rows[i] if str(c).strip())
+ if n > best: best, bi = n, i
+ return bi
+
+def nums(cell):
+ return [m.group(0).strip() for m in PHONE.finditer(str(cell or ""))]
+
+def main():
+ tok = lib.access_token()
+ for s in lib.get_meta(tok)["sheets"]:
+ gid = s["properties"]["sheetId"]; title = s["properties"]["title"]
+ rows = lib.read_tab(tok, title)
+ if not rows: continue
+ hr = header_row(rows)
+ hdr = [str(c).strip() for c in rows[hr]]
+ pi = next((i for i, h in enumerate(hdr) if h.lower() in ("phone(s)", "phones")), None)
+ if pi is None: continue
+ def g(r, i): return str(r[i]).strip() if i < len(r) else ""
+ parsed = [nums(g(r, pi)) for r in rows]
+ k = min(MAXK, max((len(p) for p in parsed[hr+1:]), default=0))
+ if k == 0:
+ print(f" {title[:34]:34s} -> no parseable numbers"); continue
+ # locate / append Phone 1..k columns
+ width = max((len(r) for r in rows), default=len(hdr))
+ right = width
+ colidx = {}
+ for j in range(1, k+1):
+ label = f"Phone {j}"
+ found = next((i for i, h in enumerate(hdr) if h.lower() == label.lower()), None)
+ if found is None:
+ colidx[j] = right; right += 1
+ else:
+ colidx[j] = found
+ # widen grid if needed
+ need = max(colidx.values()) + 1
+ if need > width:
+ req("POST", f"{lib.API}/{lib.SID}:batchUpdate", tok, {"requests": [{"appendDimension": {
+ "sheetId": gid, "dimension": "COLUMNS", "length": need - width}}]})
+ # build the data ranges (header + per-row) — ADD-only per existing value
+ data = []
+ for j in range(1, k+1):
+ ci = colidx[j]; col = [[""] for _ in range(len(rows))]
+ col[hr] = [f"Phone {j}"]
+ for ri in range(hr+1, len(rows)):
+ existing = g(rows[ri], ci)
+ val = parsed[ri][j-1] if len(parsed[ri]) >= j else ""
+ col[ri] = [existing if existing else val]
+ colL = lib.col_letter(ci)
+ data.append({"range": f"'{title}'!{colL}1:{colL}{len(col)}", "values": col})
+ req("POST", f"{lib.API}/{lib.SID}/values:batchUpdate", tok,
+ {"valueInputOption": "RAW", "data": data})
+ filled = sum(1 for p in parsed[hr+1:] if p)
+ print(f" {title[:34]:34s} -> split into Phone 1..{k} ({filled} rows had numbers)")
+ time.sleep(1.0)
+
+if __name__ == "__main__":
+ main()
← d4ebb5d Left FIELDS panel: per-field move up/down controls that reor
·
back to Rentv Sheet Enrich Refine
·
Left panel: column controls — '⤢ Fit all' (auto-expand every a43fc1d →