← back to 4square Admin
feat: bulk tag editor for wallco rows
b358ef8bad982cfc66cd921a7d8118cd3b601a0b · 2026-05-14 09:42:17 -0700 · Steve
- POST /api/bulk/tag {ids, add:[], remove:[]}
- 'add' is idempotent (appends only if not already in tags array; per-tag scan via CASE WHEN $2 = ANY(tags))
- 'remove' uses array_remove for each tag in turn
- returns counts of added/removed/rows-affected
- new '+ Tag' button next to '⤓ CSV', enabled when selection > 0 AND source is editable (wallco only)
- frontend uses two prompt() inputs for add/remove comma-lists (low-friction, no modal); blank skips
- smoke-tested with 4 rows, verified add + remove round-trip cleanly
Files touched
Diff
commit b358ef8bad982cfc66cd921a7d8118cd3b601a0b
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 14 09:42:17 2026 -0700
feat: bulk tag editor for wallco rows
- POST /api/bulk/tag {ids, add:[], remove:[]}
- 'add' is idempotent (appends only if not already in tags array; per-tag scan via CASE WHEN $2 = ANY(tags))
- 'remove' uses array_remove for each tag in turn
- returns counts of added/removed/rows-affected
- new '+ Tag' button next to '⤓ CSV', enabled when selection > 0 AND source is editable (wallco only)
- frontend uses two prompt() inputs for add/remove comma-lists (low-friction, no modal); blank skips
- smoke-tested with 4 rows, verified add + remove round-trip cleanly
---
index.html | 22 ++++++++++++++++++++++
server.js | 27 +++++++++++++++++++++++++++
2 files changed, 49 insertions(+)
diff --git a/index.html b/index.html
index 7d2703c..3f56ee4 100644
--- a/index.html
+++ b/index.html
@@ -207,6 +207,7 @@
<button class="recolor" id="b-rec" onclick="bulk('recolor')" disabled>⟳ Regen</button>
</div>
<div class="group">
+ <button class="small" id="b-tag" onclick="openTagEditor()" disabled title="Bulk add/remove tags">+ Tag</button>
<button class="small" id="b-csv" onclick="exportCSV()" disabled title="Export selected rows as CSV">⤓ CSV</button>
</div>
</div>
@@ -431,6 +432,7 @@ function renderControls() {
$('#bulk-actions').style.opacity = CURRENT.readonly ? .35 : 1;
$('#bulk-actions').title = CURRENT.readonly ? 'Read-only source' : '';
$('#b-csv').disabled = selected.size === 0;
+ $('#b-tag').disabled = selected.size === 0 || CURRENT.readonly;
// Source bar
const bar = $('#src-bar');
@@ -456,6 +458,26 @@ function toggleColor(c) {
renderControls();
}
+async function openTagEditor() {
+ if (!selected.size || CURRENT.readonly) return;
+ const add = prompt(`Add tags to ${selected.size} selected wallco row(s) — comma-separated (or leave blank to skip):`, '');
+ if (add === null) return;
+ const remove = prompt(`Remove tags from ${selected.size} selected row(s) — comma-separated (or leave blank to skip):`, '');
+ if (remove === null) return;
+ const addList = add.split(',').map(s=>s.trim()).filter(Boolean);
+ const removeList = remove.split(',').map(s=>s.trim()).filter(Boolean);
+ if (!addList.length && !removeList.length) return;
+ try {
+ const j = await fetch('/api/bulk/tag', {
+ method:'POST', headers:{'content-type':'application/json'},
+ body: JSON.stringify({ ids: Array.from(selected), add: addList, remove: removeList })
+ }).then(r => r.json());
+ if (!j.ok) { toast('Tag op failed: '+j.error, 'err'); return; }
+ toast(`Tagged ${j.ids} row(s): +[${addList.join(',')}] -[${removeList.join(',')}]`, 'ok');
+ await loadProducts();
+ } catch (e) { toast('Tag request failed: '+e.message, 'err'); }
+}
+
async function exportCSV() {
if (!selected.size) return;
const r = await fetch('/api/export', {
diff --git a/server.js b/server.js
index 7c988f1..969a923 100644
--- a/server.js
+++ b/server.js
@@ -403,6 +403,33 @@ app.post('/api/bulk/delete', async (req, res) => {
const d = await pool.query(`DELETE FROM spoon_all_designs WHERE id=ANY($1) RETURNING id`, [ids]);
res.json({ ok:true, action:'delete', deleted: d.rows.length, files_moved: moved.length, trash_dir: TRASH });
});
+app.post('/api/bulk/tag', async (req, res) => {
+ const ids = parseIds(req, res); if (!ids) return;
+ const add = (req.body?.add || []).map(s => String(s).trim()).filter(Boolean);
+ const remove = (req.body?.remove || []).map(s => String(s).trim()).filter(Boolean);
+ if (!add.length && !remove.length) return res.status(400).json({ ok:false, error:'add or remove required' });
+ let changed = 0;
+ try {
+ if (add.length) {
+ // Append-only-if-not-present per row
+ for (const t of add) {
+ const r = await pool.query(
+ `UPDATE spoon_all_designs SET tags = CASE WHEN $2 = ANY(tags) THEN tags ELSE COALESCE(tags,'{}') || $2 END
+ WHERE id=ANY($1) RETURNING id`, [ids, t]);
+ changed += r.rows.length;
+ }
+ }
+ if (remove.length) {
+ for (const t of remove) {
+ await pool.query(`UPDATE spoon_all_designs SET tags = array_remove(tags, $2) WHERE id=ANY($1)`, [ids, t]);
+ }
+ }
+ res.json({ ok:true, action:'tag', ids: ids.length, added: add, removed: remove });
+ } catch (e) {
+ res.status(500).json({ ok:false, error:e.message });
+ }
+});
+
app.post('/api/bulk/recolor', async (req, res) => {
const ids = parseIds(req, res); if (!ids) return;
const target = String(req.body?.target_color || '').trim();
← 198d58c feat: 12-chip color filter strip (right-aligned in source ba
·
back to 4square Admin
·
fix: revoke CSV blob object URL after download to prevent me d04b35d →