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:
77
app.py
77
app.py
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user