← back to Zendesk Chat Analyzer
chore: lint + refactor + escape live-feed HTML (XSS guard) + v1.0.0 (session close)
307ab80a20d3dec90c7e8df585a4b686ac6794d4 · 2026-08-12 08:09:15 -0700 · Steve
Files touched
A VERSIONA __pycache__/pull.cpython-314.pycA __pycache__/recent.cpython-314.pycM public/app.jsM server.js
Diff
commit 307ab80a20d3dec90c7e8df585a4b686ac6794d4
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Aug 12 08:09:15 2026 -0700
chore: lint + refactor + escape live-feed HTML (XSS guard) + v1.0.0 (session close)
---
VERSION | 1 +
__pycache__/pull.cpython-314.pyc | Bin 0 -> 12592 bytes
__pycache__/recent.cpython-314.pyc | Bin 0 -> 7046 bytes
public/app.js | 27 +++++++++++++++------------
server.js | 4 ++--
5 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..3eefcb9
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.0.0
diff --git a/__pycache__/pull.cpython-314.pyc b/__pycache__/pull.cpython-314.pyc
new file mode 100644
index 0000000..447c185
Binary files /dev/null and b/__pycache__/pull.cpython-314.pyc differ
diff --git a/__pycache__/recent.cpython-314.pyc b/__pycache__/recent.cpython-314.pyc
new file mode 100644
index 0000000..168313c
Binary files /dev/null and b/__pycache__/recent.cpython-314.pyc differ
diff --git a/public/app.js b/public/app.js
index 5138d93..2ffed88 100644
--- a/public/app.js
+++ b/public/app.js
@@ -199,6 +199,7 @@ const COLDEFS=[
const CMAP=Object.fromEntries(COLDEFS.map(d=>[d.k,d]));
const DEF_ORDER=COLDEFS.map(d=>d.k);
const DEF_HIDE=['region','browser','duration','search_terms','ticket_id','msg']; // sensible tight default
+const TABLE_ROW_CAP = 500;
let colOrder, colHidden;
function loadCols(){
try{ const s=JSON.parse(localStorage.getItem('zca_cols')||'null'); colOrder=(s&&s.order||DEF_ORDER).filter(k=>CMAP[k]); DEF_ORDER.forEach(k=>{if(!colOrder.includes(k))colOrder.push(k);}); colHidden=new Set(s&&s.hidden||DEF_HIDE); }
@@ -209,17 +210,22 @@ function saveCols(){ localStorage.setItem('zca_cols',JSON.stringify({order:colOr
const visCols=()=>colOrder.filter(k=>!colHidden.has(k));
window.setF=(dim,v)=>{ FILTER[dim]=v; renderAll(); };
-function table(){
+/** Returns filtered, searched, sorted rows — shared by table() and exportCsv. */
+function filteredSortedRows(){
let r=rows().slice();
const q=document.getElementById('search').value.toLowerCase().split(/\s+/).filter(Boolean);
if(q.length) r=r.filter(c=>{ const blob=COLDEFS.map(d=>d.p(c)).join(' ').toLowerCase(); return q.every(w=>blob.includes(w)); });
const sd=CMAP[sortKey]||CMAP.ts;
r.sort((a,b)=>{ let x=sd.p(a),y=sd.p(b); if(typeof x==='string')x=x.toLowerCase(); if(typeof y==='string')y=y.toLowerCase(); return (x>y?1:x<y?-1:0)*sortDir; });
+ return r;
+}
+function table(){
+ const r=filteredSortedRows();
const cols=visCols();
document.querySelector('#tbl thead').innerHTML='<tr>'+cols.map(k=>{ const d=CMAP[k];
return `<th class="drag" draggable="true" data-k="${k}" title="drag to reorder / click to sort">`+
`<span class="grip">⋮⋮</span>${d.l}${sortKey===k?(sortDir>0?' ▲':' ▼'):''}</th>`; }).join('')+'</tr>';
- document.querySelector('#tbl tbody').innerHTML=r.slice(0,500).map(c=>'<tr>'+cols.map(k=>{
+ document.querySelector('#tbl tbody').innerHTML=r.slice(0,TABLE_ROW_CAP).map(c=>'<tr>'+cols.map(k=>{
const d=CMAP[k]; const v=d.g(c); const t=String(d.p(c)).replace(/"/g,'');
return `<td title="${t}">${(v===''||v==null)?'<span class=mut>—</span>':v}</td>`;}).join('')+'</tr>').join('');
document.getElementById('tableCount').textContent=`${r.length} rows · ${cols.length}/${COLDEFS.length} cols`;
@@ -248,11 +254,7 @@ document.addEventListener('click',()=>document.getElementById('colPop').classLis
document.getElementById('colPop').onclick=e=>e.stopPropagation();
document.getElementById('resetCols').onclick=()=>{ colOrder=DEF_ORDER.slice(); colHidden=new Set(DEF_HIDE); sortKey='ts';sortDir=-1; localStorage.removeItem('zca_cols');localStorage.removeItem('zca_sort'); table(); };
document.getElementById('exportCsv').onclick=()=>{
- let r=rows().slice();
- const q=document.getElementById('search').value.toLowerCase().split(/\s+/).filter(Boolean);
- if(q.length) r=r.filter(c=>{ const blob=COLDEFS.map(d=>d.p(c)).join(' ').toLowerCase(); return q.every(w=>blob.includes(w)); });
- const sd=CMAP[sortKey]||CMAP.ts;
- r.sort((a,b)=>{ let x=sd.p(a),y=sd.p(b); if(typeof x==='string')x=x.toLowerCase(); if(typeof y==='string')y=y.toLowerCase(); return (x>y?1:x<y?-1:0)*sortDir; });
+ const r=filteredSortedRows(); // export honors active search + sort, no row cap
const cols=visCols(), esc=v=>{ v=String(v==null?'':v); return /[",\n]/.test(v)?'"'+v.replace(/"/g,'""')+'"':v; };
const csv=[cols.map(k=>esc(CMAP[k].l)).join(',')].concat(r.map(c=>cols.map(k=>esc(CMAP[k].p(c))).join(','))).join('\r\n');
const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));
@@ -270,6 +272,7 @@ document.getElementById('refresh').onclick=function(){
/* ---------- Live recent-activity feed ---------- */
const flag = cc => (cc && cc.length===2 && /^[A-Z]{2}$/.test(cc)) ? String.fromCodePoint(...[...cc].map(c=>0x1F1E6+c.charCodeAt(0)-65)) : '🌐';
+const esc = s => (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
function ago(ts, now){ try{ const t=Math.floor(new Date(ts).getTime()/1000); const s=now-t; if(s<60)return 'just now'; if(s<3600)return Math.floor(s/60)+'m ago'; if(s<86400)return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; }catch(e){ return ''; } }
let livePrevIds = new Set(), liveFirst = true;
function pollLive(){
@@ -286,11 +289,11 @@ function pollLive(){
return `<div class="fitem${c.missed?' miss':''}${fresh?' fresh':''}">
<div class="ago">${ago(c.ts,now)}</div>
<div style="flex:1;min-width:0">
- <div><span class="who">${flag(c.country_code)} ${c.city||c.country}</span>
- <span class="loc"> · ${c.country} · ${c.platform||''} · ${c.type==='offline_msg'?'offline msg':'chat'}</span>
- ${c.missed?'<span class="badge m">MISSED</span>':(agent?`<span class="badge">${agent}</span>`:'')}</div>
- ${c.msg?`<div class="msg">“${c.msg}”</div>`:''}
- ${(c.browsed&&c.browsed[0])?`<div class="brows">🛍️ ${c.browsed[0]}</div>`:''}
+ <div><span class="who">${flag(c.country_code)} ${esc(c.city||c.country)}</span>
+ <span class="loc"> · ${esc(c.country)} · ${esc(c.platform||'')} · ${c.type==='offline_msg'?'offline msg':'chat'}</span>
+ ${c.missed?'<span class="badge m">MISSED</span>':(agent?`<span class="badge">${esc(agent)}</span>`:'')}</div>
+ ${c.msg?`<div class="msg">“${esc(c.msg)}”</div>`:''}
+ ${(c.browsed&&c.browsed[0])?`<div class="brows">🛍️ ${esc(c.browsed[0])}</div>`:''}
</div></div>`;
}).join('')+'</div>';
livePrevIds = new Set(ch.map(c=>c.id)); liveFirst = false;
diff --git a/server.js b/server.js
index e0dd418..e4dffa7 100644
--- a/server.js
+++ b/server.js
@@ -20,7 +20,7 @@ http.createServer((req, res) => {
}
if (req.method === 'POST' && req.url === '/api/refresh') {
execFile('/usr/bin/python3', [path.join(__dirname, 'pull.py')],
- { env: { ...process.env, START: '1770000000', MAX_PAGES: '40' } },
+ { env: { ...process.env, START: '1770000000', MAX_PAGES: '40' }, maxBuffer: 8 * 1024 * 1024 },
(err, so, se) => {
res.writeHead(err ? 500 : 200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: !err, out: (so||'').trim(), err: (se||'').trim() }));
@@ -47,7 +47,7 @@ function refreshData(reason) {
}
try {
const dj = path.join(ROOT, 'data.json');
- const stale = !fs.existsSync(dj) || (Date.now() - fs.statSync(dj).mtimeMs) > REFRESH_HOURS * 3600e3;
+ const stale = !fs.existsSync(dj) || (Date.now() - fs.statSync(dj).mtimeMs) > REFRESH_HOURS * 3600 * 1000;
if (stale) setTimeout(() => refreshData('boot'), 4000);
} catch (e) {}
setInterval(() => refreshData('interval'), REFRESH_HOURS * 3600 * 1000);
← 0cde498 Add '💬 Go Live in Zendesk' button — opens agent console for
·
back to Zendesk Chat Analyzer
·
href-drill:exempt marker — not a browse-grid (Steve's exclus c5ed5c0 →