v4: interactive plan — cluster headers w/ select-all-per-cluster, select all/none/cluster, live selected counter, click-to-edit destinations (override wins), toast everywhere

This commit is contained in:
drjones
2026-10-05 05:33:06 -07:00
parent 241b1dfef8
commit c57a4694f7

77
app.py
View File

@@ -478,7 +478,8 @@ def dest_for(row, tx):
return os.path.join(tx["organized_root"], "Projects", row["topic"], row["role"] or "Misc")
return propose_destination(row, tx)
def apply_moves(ids, dry_run=True):
def apply_moves(ids, dry_run=True, overrides=None):
overrides = overrides or {}
tx = load_taxonomy()
c = db()
session = uuid.uuid4().hex[:12]
@@ -487,7 +488,13 @@ def apply_moves(ids, dry_run=True):
row = c.execute("SELECT * FROM files WHERE id=?", (fid,)).fetchone()
if not row or row["status"] not in ("classified", "approved"): skipped += 1; continue
dst_dir = dest_for(row, tx)
name = row["rename"] or row["name"]
if fid in overrides:
ov = os.path.abspath(os.path.expanduser(overrides[fid]))
dst_dir = os.path.dirname(ov) if overrides[fid].endswith("/") or os.path.splitext(ov)[1] == "" else os.path.dirname(ov)
forced_name = os.path.basename(ov) if os.path.splitext(ov)[1] else None
else:
forced_name = None
name = forced_name or row["rename"] or row["name"]
base, ext = os.path.splitext(name)
if ext.lower() != row["ext"] and row["ext"]:
name = base + row["ext"]
@@ -747,43 +754,88 @@ def plan_page():
plans.sort(key=lambda p: (p.get("topic") or "zzz", -p["conf"], p["verdict"] != "APPROVE"))
cards = ""
approved_default = 0
cur_topic = "zzz"
for p in plans[:600]:
if (p.get("topic") or "zzz") != cur_topic:
cur_topic = p.get("topic") or "zzz"
if cur_topic != "zzz":
grp = [q for q in plans[:600] if q.get("topic") == cur_topic]
gids = ",".join(str(q["id"]) for q in grp)
cards += (f"<div class='panel' style='border-color:rgba(124,108,255,.4);padding:12px 16px;display:flex;gap:12px;align-items:center'>"
f"<b class='grad' style='font-size:16px'>◆ {cur_topic}</b>"
f"<span class='muted'>{len(grp)} files move together as one project</span>"
f"<span style='flex:1'></span>"
f"<button class='ghost grp-btn' data-grp='{gids}' style='padding:5px 14px;font-size:12px'>SELECT ALL {len(grp)}</button></div>")
checked = "checked" if p["verdict"] == "APPROVE" else ""
if p["verdict"] == "APPROVE": approved_default += 1
dupnote = "<span class='chip' style='background:rgba(255,180,84,.16);color:#ffcf8f'>DUPLICATE GROUP</span>" if p["dup"] else ""
topicnote = f"<span class='chip' style='background:rgba(124,108,255,.2);color:#c9bfff'>◆ {p['topic']} · {p['role']}</span>" if p.get("topic") else ""
cards += (f"<div class='plan'>"
f"<div class='src'>{p['path']}</div><div class='arrow'>↓</div><div class='dst'>{p['dst']}</div>"
f"<div class='src'>{p['path']}</div><div class='arrow'>↓</div>"
f"<div class='dst' contenteditable='true' data-id='{p['id']}' data-default='{p['dst']}' title='click to edit destination'>{p['dst']}</div>"
f"<div class='meta'>{topicnote}<span class='chip c-{p['cls']}'>{p['cls']}</span><span class='muted'>{p['subj']} · {p['imp']}</span>"
f"<span class='muted'>conf {int(p['conf']*100)}%</span>"
f"<span class='v-{p['verdict']}' style='font-weight:700'>{p['verdict']}</span>"
+ (f"<span class='muted'>({p['why']})</span>" if p["why"] else "") + dupnote +
f"<span style='flex:1'></span>"
f"<label style='display:inline;margin:0'><input type='checkbox' class='pick' data-id='{p['id']}' {checked} style='width:auto'> include</label></div></div>")
f"<label style='display:inline;margin:0'><input type='checkbox' class='pick' data-id='{p['id']}' data-grp='{cur_topic}' {checked} style='width:auto'> include</label></div></div>")
body = f"""
<h1 class='grad'>Organization Plan</h1>
<form method='get' action='/plan'><div class='row'>
<div><label>Min confidence (safety gate)</label><input type='number' step='0.05' name='min_conf' value='{min_conf}'></div>
<div><button type='submit' class='ghost'>RE-GATE</button></div>
</div></form>
<p class='muted'>{len(plans)} proposals · {approved_default} auto-approved at this threshold. Uncheck anything you're unsure of — nothing moves without your click.</p>
<div class='panel' style='display:flex;gap:10px;flex-wrap:wrap;align-items:center'>
<button class='ghost' onclick='selAll(true);return false'>SELECT ALL</button>
<button class='ghost' onclick='selAll(false);return false'>SELECT NONE</button>
<button class='ghost' onclick='selGrp(true);return false'>SELECT CLUSTER</button>
<button class='ghost' onclick='selGrp(false);return false'>DESELECT CLUSTER</button>
<span style='flex:1'></span>
<b id='selcount' style='font-size:16px;background:linear-gradient(90deg,#fff,#35d0ba);background-clip:text;-webkit-text-fill-color:transparent'>0 selected</b>
</div>
<p class='muted'>{len(plans)} proposals · {approved_default} auto-approved at this threshold · destinations are click-to-edit (type a path, it wins over the default). Nothing moves without your click.</p>
<div style='margin:12px 0'><button class='teal' onclick='applyMoves(true)'>DRY RUN (approve only)</button>
<button onclick='applyMoves(false)'>APPLY CHECKED MOVES</button></div>
<div id='plans'>{cards}</div>
<div class='panel'><h2>Progress</h2><div class='bar'><i id='pbar' style='width:0%'></i></div><span class='muted' id='pmsg'></span></div>
<script>
function updCount() {{
var n = document.querySelectorAll('.pick:checked').length;
document.getElementById('selcount').textContent = n + ' selected';
}}
document.querySelectorAll('.pick').forEach(function(cb) {{ cb.addEventListener('change', updCount); }});
function selAll(on) {{ document.querySelectorAll('.pick').forEach(function(cb) {{ cb.checked = on; }}); updCount(); }}
function grpBoxes() {{
var g = prompt('Cluster name (exact, e.g. Qwen):'); if (!g) return null;
return Array.prototype.slice.call(document.querySelectorAll('.pick')).filter(function(cb) {{ return cb.dataset.grp === g; }});
}}
function selGrp(on) {{ var b = grpBoxes(); if (b) {{ b.forEach(function(cb) {{ cb.checked = on; }}); updCount(); }} }}
document.querySelectorAll('.grp-btn').forEach(function(b) {{
b.addEventListener('click', function() {{
b.dataset.grp.split(',').forEach(function(id) {{
var cb = document.querySelector('.pick[data-id=\"' + id + '\"]'); if (cb) cb.checked = true;
}}); updCount();
b.textContent = '✓ selected'; b.style.color = '#6fe8d6';
}});
}});
function applyMoves(dry) {{
var ids = [];
var ids = [], over = {{}};
document.querySelectorAll('.pick').forEach(function(cb) {{ if (cb.checked) ids.push(cb.dataset.id); }});
if (!ids.length) {{ alert('nothing checked'); return; }}
if (!ids.length) {{ toast('nothing checked'); return; }}
document.querySelectorAll('.dst').forEach(function(el) {{
var t = el.textContent.trim();
if (t && el.dataset.default && t !== el.dataset.default) over[el.dataset.id] = t;
}});
fetch('/api/apply', {{ method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{ ids: ids, dry_run: dry }}) }})
body: JSON.stringify({{ ids: ids, dry_run: dry, overrides: over }}) }})
.then(function(r){{ return r.json(); }})
.then(function(d) {{ var t=document.getElementById('toast'); t.textContent='✓ ' + d.done + ' applied · ' + d.skipped + ' skipped · session ' + d.session + (dry ? ' (dry run)' : ''); t.style.display='block'; setTimeout(function(){{t.style.display='none'}},5000);
document.getElementById('pmsg').textContent = t.textContent;
document.querySelectorAll('.pick').forEach(function(cb){{ cb.checked=false; }});
.then(function(d) {{ toast('✓ ' + d.done + ' applied · ' + d.skipped + ' skipped · session ' + d.session + (dry ? ' (dry run)' : ''));
document.getElementById('pmsg').textContent = document.getElementById('toast').textContent;
document.querySelectorAll('.pick').forEach(function(cb){{ cb.checked=false; }}); updCount();
}});
}}
function toast(msg) {{ var t=document.getElementById('toast'); t.textContent=msg; t.style.display='block'; setTimeout(function(){{t.style.display='none'}},5000); }}
updCount();
</script>
"""
return Response(shell(body, "plan"), mimetype="text/html")
@@ -792,7 +844,8 @@ function applyMoves(dry) {{
def api_apply():
d = request.get_json(force=True)
ids = [int(x) for x in d.get("ids", [])]
done, skipped, session = apply_moves(ids, dry_run=bool(d.get("dry_run", True)))
over = {int(k): v for k, v in (d.get("overrides") or {}).items()}
done, skipped, session = apply_moves(ids, dry_run=bool(d.get("dry_run", True)), overrides=over)
return {"done": done, "skipped": skipped, "session": session}
@app.route("/cluster", methods=["POST"])