LIBRA v1: scanner/extractor/Nimble classifier/safety gate/approval UI/op log/undo/dupe hold

This commit is contained in:
drjones
2026-10-04 22:05:12 -07:00
commit 4e38f7257e
5 changed files with 776 additions and 0 deletions

712
app.py Normal file
View File

@@ -0,0 +1,712 @@
#!/usr/bin/env python3
"""LIBRA — the Scales. Nimble-classified file organizer with approval-gated, reversible moves.
Local only: Flask on Mac, Ollama Nimble decision lane on nightmare .29."""
import os, re, json, time, uuid, hashlib, sqlite3, threading, shutil, traceback, fnmatch
import subprocess
from pathlib import Path
from flask import Flask, request, redirect, Response
APP_DIR = os.path.expanduser("~/libra")
DB_PATH = os.path.join(APP_DIR, "libra.db")
CFG_PATH = os.path.join(APP_DIR, "taxonomy.yaml")
OLLAMA = "http://10.30.20.29:11434"
MODEL = "nimble:latest"
HOME = os.path.expanduser("~")
DEFAULT_TAXONOMY = """# Libra taxonomy — destination map (editable here)
# mapping: '<file_class>+<subject>' -> destination (relative to organized root)
organized_root: ~/libra-organized
map:
document+finance: Documents/Financial
document+work: Documents/Work
document+personal: Documents/Personal
document+unknown: Documents/Misc
ebook+reference: Knowledge/Books
ebook+programming: Knowledge/Books
ebook+AI: Knowledge/Books
ebook+linux: Knowledge/Books
ebook+cybersecurity: Knowledge/Books
ebook+unknown: Knowledge/Books
ebook+personal: Knowledge/Books
image+photography: Media/Photos
image+personal: Media/Photos
image+unknown: Media/Photos
video+unknown: Media/Videos
audio+unknown: Media/Audio
software+unknown: Software/Installers
software+programming: Software/Source
source_code+programming: Software/Source
source_code+AI: Software/Source
archive+unknown: Data/Archives
dataset+unknown: Data/Datasets
financial+finance: Documents/Financial
personal+personal: Documents/Personal
temporary+unknown: Unsorted
unknown+unknown: Unsorted
default: Unsorted
"""
if not os.path.exists(CFG_PATH):
Path(APP_DIR).mkdir(parents=True, exist_ok=True)
open(CFG_PATH, "w").write(DEFAULT_TAXONOMY)
app = Flask(__name__)
LOCK = threading.Lock()
STATE = {"scan_running": False, "scan_done": 0, "scan_total": 0, "classify_running": False,
"classify_done": 0, "classify_total": 0, "last_error": ""}
# ---------- DB ----------
def db():
c = sqlite3.connect(DB_PATH, timeout=30)
c.row_factory = sqlite3.Row
return c
def init_db():
c = db()
c.executescript("""
CREATE TABLE IF NOT EXISTS files(
id INTEGER PRIMARY KEY, path TEXT UNIQUE, name TEXT, ext TEXT, size INTEGER, mtime REAL,
sha256 TEXT, preview TEXT, file_class TEXT, subject TEXT, importance TEXT, action TEXT,
confidence REAL, rename TEXT, status TEXT DEFAULT 'scanned', reason TEXT);
CREATE TABLE IF NOT EXISTS operations(
id INTEGER PRIMARY KEY, ts REAL, session TEXT, file_id INTEGER, op TEXT,
src TEXT, dst TEXT, sha256 TEXT, undone INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS sessions(
id TEXT PRIMARY KEY, ts REAL, n INTEGER, undone INTEGER DEFAULT 0, note TEXT);
""")
c.commit(); c.close()
# ---------- SCANNER ----------
SKIP_DIRS = {".Trashes", ".Spotlight-V100", ".TemporaryItems", ".fseventsd", "System Volume Information",
"$RECYCLE.BIN", "node_modules", "__pycache__", ".git", "Library", ".Trash"}
JUNK = (".DS_Store",)
def scan(root, max_files=5000, min_size=0, max_size=0):
root = os.path.abspath(os.path.expanduser(root))
c = db()
n = 0
for r, dirs, fs in os.walk(root):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith("._")]
for f in fs:
if f in JUNK or f.startswith("._"):
continue
p = os.path.join(r, f)
try:
st = os.lstat(p)
except OSError:
continue
if st.st_size < min_size or (max_size and st.st_size > max_size):
continue
try:
c.execute("INSERT OR IGNORE INTO files(path,name,ext,size,mtime,status) VALUES(?,?,?,?,?, 'scanned')",
(p, f, os.path.splitext(f)[1].lower(), st.st_size, st.st_mtime))
except sqlite3.Error:
pass
n += 1
if n >= max_files:
break
if n >= max_files:
break
c.commit(); c.close()
return n
def scan_thread(root, max_files, min_size, max_size):
try:
with LOCK:
STATE["scan_running"] = True
scan(root, max_files, min_size, max_size)
except Exception:
STATE["last_error"] = traceback.format_exc()[-800:]
finally:
with LOCK:
STATE["scan_running"] = False
refresh_counts()
def refresh_counts():
c = db()
for k, q in [("files", "SELECT COUNT(*) FROM files"),
("scanned", "SELECT COUNT(*) FROM files WHERE file_class IS NULL"),
("classified", "SELECT COUNT(*) FROM files WHERE file_class IS NOT NULL"),
("dupes", "SELECT COUNT(*) FROM files WHERE sha256 IS NOT NULL AND sha256 IN (SELECT sha256 FROM files WHERE sha256 IS NOT NULL GROUP BY sha256 HAVING COUNT(*)>1)"),
("pending", "SELECT COUNT(*) FROM files WHERE status='approved'"),
("moved", "SELECT COUNT(*) FROM files WHERE status='moved'")]:
STATE[k] = c.execute(q).fetchone()[0]
c.close()
# ---------- EXTRACTOR ----------
def extract_preview(p, ext, size):
prev = ""
try:
if ext == ".pdf" and size < 60_000_000:
from pypdf import PdfReader
rd = PdfReader(p)
txt = ""
for pg in rd.pages[:2]:
txt += (pg.extract_text() or "") + " "
prev = re.sub(r"\s+", " ", txt)[:600]
elif ext in (".txt", ".md", ".csv", ".json", ".yaml", ".yml", ".log", ".py", ".sh", ".html", ".cfg", ".ini") and size < 5_000_000:
prev = open(p, "r", errors="replace").read(600)
elif ext in (".zip",) and size < 500_000_000:
import zipfile
with zipfile.ZipFile(p) as z:
names = z.namelist()[:20]
prev = "zip listing: " + ", ".join(names)
elif ext in (".jpg", ".jpeg", ".png", ".heic", ".gif", ".webp"):
try:
from PIL import Image
im = Image.open(p)
prev = f"image {im.size[0]}x{im.size[1]}"
except Exception:
prev = "image file"
except Exception:
prev = ""
return prev
def hash_files(limit=500, max_mb=64):
c = db()
rows = c.execute("SELECT id,path,size FROM files WHERE sha256 IS NULL LIMIT ?", (limit,)).fetchall()
for row in rows:
if row["size"] > max_mb * 1_000_000:
c.execute("UPDATE files SET sha256='TOOBIG' WHERE id=?", (row["id"],)); continue
h = hashlib.sha256()
try:
with open(row["path"], "rb") as fh:
for chunk in iter(lambda: fh.read(4 << 20), b""):
h.update(chunk)
c.execute("UPDATE files SET sha256=? WHERE id=?", (h.hexdigest(), row["id"]))
except OSError:
c.execute("UPDATE files SET sha256='UNREADABLE' WHERE id=?", (row["id"],))
c.commit(); n = len(rows); c.close(); return n
def hash_thread(limit, max_mb):
try:
with LOCK: STATE["classify_running"] = True
while hash_files(limit, max_mb):
time.sleep(0.1)
except Exception:
STATE["last_error"] = traceback.format_exc()[-800:]
finally:
with LOCK: STATE["classify_running"] = False
refresh_counts()
# ---------- CLASSIFIER (Nimble) ----------
ENUMS = {
"file_class": ["document","image","video","audio","software","source_code","archive","dataset","ebook","financial","personal","temporary","unknown"],
"subject": ["work","finance","programming","AI","linux","cybersecurity","photography","personal","entertainment","reference","unknown"],
"importance": ["disposable","low","normal","important","critical"],
"action": ["keep","organize","archive","quarantine","review"],
}
def classify_one(row):
prompt = ("Classify this file. Reply ONLY minified JSON with keys file_class, subject, importance, action, confidence, rename. No other text.\n"
f"File: {row['name']} ({row['ext'] or 'no ext'}, {row['size']} bytes)\n"
+ (f"Content preview: {row['preview'][:400]}\n" if row['preview'] else "")
+ "file_class in " + str(ENUMS["file_class"]) + "; subject in " + str(ENUMS["subject"])
+ "; importance in " + str(ENUMS["importance"]) + "; action in " + str(ENUMS["action"])
+ "; confidence 0.0-1.0; rename = clean proposed filename or same")
import urllib.request
body = json.dumps({"model": MODEL, "prompt": prompt, "stream": False, "think": False,
"options": {"temperature": 0.1, "num_predict": 400}}).encode()
req = urllib.request.Request(OLLAMA + "/api/generate", data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
d = json.loads(r.read())
txt = d.get("response", "")
m = re.search(r"\{.*\}", txt, re.S)
if not m:
return None
try:
j = json.loads(m.group(0))
except Exception:
return None
out = {}
for k, vals in ENUMS.items():
v = str(j.get(k, "unknown")).strip().lower()
if v not in vals: v = "unknown" if k != "importance" else "normal"
out[k] = v
try: conf = float(j.get("confidence", 0.5))
except Exception: conf = 0.5
out["confidence"] = max(0.0, min(1.0, conf if conf <= 1.0 else conf / 10.0))
rn = str(j.get("rename", "")).strip()
out["rename"] = re.sub(r"[^A-Za-z0-9 ._-]", "", rn)[:120] or row["name"]
return out
def classify_thread(limit):
try:
with LOCK: STATE["classify_running"] = True
c = db()
rows = c.execute("SELECT * FROM files WHERE file_class IS NULL LIMIT ?", (limit,)).fetchall()
STATE["classify_total"] = len(rows); STATE["classify_done"] = 0
for row in rows:
res = classify_one(row)
if res:
if row["preview"] is None:
prev = extract_preview(row["path"], row["ext"], row["size"])
c.execute("UPDATE files SET preview=? WHERE id=?", (prev, row["id"]))
c.execute("UPDATE files SET file_class=?,subject=?,importance=?,action=?,confidence=?,rename=?,status='classified' WHERE id=?",
(res["file_class"], res["subject"], res["importance"], res["action"], res["confidence"], res["rename"], row["id"]))
STATE["classify_done"] += 1
c.commit(); c.close()
except Exception:
STATE["last_error"] = traceback.format_exc()[-800:]
finally:
with LOCK: STATE["classify_running"] = False
refresh_counts()
# ---------- TAXONOMY + SAFETY ----------
def load_taxonomy():
tx = {"organized_root": os.path.expanduser("~/libra-organized"), "map": {}, "default": "Unsorted"}
cur = None
for line in open(CFG_PATH):
line = line.rstrip()
if not line or line.lstrip().startswith("#"): continue
if line.startswith("organized_root:"):
tx["organized_root"] = os.path.expanduser(line.split(":", 1)[1].strip())
elif line.startswith("default:"):
tx["default"] = line.split(":", 1)[1].strip()
elif line.startswith("map:"):
cur = "map"
elif cur == "map" and line.startswith(" ") and ":" in line:
k, v = line.strip().split(":", 1)
tx["map"][k.strip()] = v.strip()
return tx
def propose_destination(row, tx):
key = f"{row['file_class']}+{row['subject']}"
sub = tx["map"].get(key, tx["map"].get(f"{row['file_class']}+unknown", tx["default"]))
date = time.strftime("%Y/%m", time.localtime(row["mtime"] or time.time()))
return os.path.join(tx["organized_root"], sub, date)
def validate(src, dst, confidence, min_conf, tx):
if confidence < min_conf: return "REVIEW", "confidence below threshold"
if not src.startswith(tx.get("scan_root", "/")): return "REJECT", "outside scan root"
for sysd in ("/System", "/usr", "/etc", "/bin", "/sbin", "/var", "/private", "/opt"):
if src.startswith(sysd + "/"): return "REJECT", "system path"
if os.path.islink(src): return "REJECT", "symlink"
if os.path.exists(dst): return "REVIEW", "destination exists"
if os.path.dirname(src) == dst: return "REJECT", "already there"
return "APPROVE", ""
def build_plan(tx, min_conf):
c = db()
rows = c.execute("SELECT * FROM files WHERE status='classified'").fetchall()
c.close()
dupset = set()
c = db()
for (s,) in c.execute("SELECT sha256 FROM files WHERE sha256 IS NOT NULL AND sha256 NOT IN ('TOOBIG','UNREADABLE') GROUP BY sha256 HAVING COUNT(*)>1"):
dupset.add(s)
c.close()
plans = []
for row in rows:
if row["action"] not in ("organize", "archive"): continue
dst_dir = propose_destination(row, tx)
name = row["rename"] if row["rename"] else row["name"]
base, ext = os.path.splitext(name)
if ext.lower() != row["ext"] and row["ext"]:
name = base + row["ext"]
dst = os.path.join(dst_dir, name)
verdict, why = validate(row["path"], dst, row["confidence"], min_conf, tx)
is_dup = row["sha256"] in dupset
plans.append({"id": row["id"], "path": row["path"], "name": row["name"], "dst": dst,
"cls": row["file_class"], "subj": row["subject"], "imp": row["importance"],
"conf": row["confidence"], "verdict": verdict, "why": why, "dup": is_dup,
"size": row["size"]})
return plans
# ---------- ORGANIZER (apply / undo) ----------
def apply_moves(ids, dry_run=True):
tx = load_taxonomy()
c = db()
session = uuid.uuid4().hex[:12]
done, skipped = 0, 0
for fid in ids:
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 = propose_destination(row, tx)
name = row["rename"] or row["name"]
base, ext = os.path.splitext(name)
if ext.lower() != row["ext"] and row["ext"]:
name = base + row["ext"]
dst = os.path.join(dst_dir, name)
i = 1
while os.path.exists(dst):
dst = os.path.join(dst_dir, f"{base}({i}){ext}"); i += 1
verdict, why = validate(row["path"], dst, row["confidence"], 0.0, tx)
if verdict == "REJECT":
skipped += 1; continue
if dry_run:
c.execute("UPDATE files SET status='approved' WHERE id=?", (fid,))
done += 1; continue
try:
os.makedirs(dst_dir, exist_ok=True)
shutil.move(row["path"], dst)
c.execute("INSERT INTO operations(ts,session,file_id,op,src,dst,sha256) VALUES(?,?,?,?,?,?,?)",
(time.time(), session, fid, "move", row["path"], dst, row["sha256"] or ""))
c.execute("UPDATE files SET path=?,name=?,status='moved' WHERE id=?", (dst, os.path.basename(dst), fid))
done += 1
except OSError as e:
skipped += 1
STATE["last_error"] = f"{e}"
if not dry_run:
c.execute("INSERT INTO sessions(id,ts,n,note) VALUES(?,?,?,?)", (session, time.time(), done, "apply"))
c.commit(); c.close(); refresh_counts()
return done, skipped, session
def undo_session(session):
c = db()
ops = c.execute("SELECT * FROM operations WHERE session=? AND undone=0 ORDER BY id DESC", (session,)).fetchall()
n = 0
for op in ops:
try:
if os.path.exists(op["dst"]):
os.makedirs(os.path.dirname(op["src"]), exist_ok=True)
shutil.move(op["dst"], op["src"])
c.execute("UPDATE files SET path=?,name=?,status='classified' WHERE id=?", (op["src"], os.path.basename(op["src"]), op["file_id"]))
c.execute("UPDATE operations SET undone=1 WHERE id=?", (op["id"],))
n += 1
except OSError as e:
STATE["last_error"] = str(e)
c.execute("UPDATE sessions SET undone=1 WHERE id=?", (session,))
c.commit(); c.close(); refresh_counts()
return n
def delete_duplicates(keep_id):
c = db()
row = c.execute("SELECT sha256 FROM files WHERE id=?", (keep_id,)).fetchone()
if not row or not row["sha256"]: c.close(); return 0
session = uuid.uuid4().hex[:12]
n = 0
for d in c.execute("SELECT * FROM files WHERE sha256=? AND id!=? AND status!='moved'", (row["sha256"], keep_id)).fetchall():
try:
hold = os.path.join(APP_DIR, "dupe_hold", session)
os.makedirs(hold, exist_ok=True)
shutil.move(d["path"], os.path.join(hold, f"{n:04d}__" + d["name"]))
c.execute("INSERT INTO operations(ts,session,file_id,op,src,dst,sha256) VALUES(?,?,?,?,?,?,?)",
(time.time(), session, d["id"], "dupe_hold", d["path"], os.path.join(hold, d["name"]), d["sha256"]))
c.execute("UPDATE files SET status='held' WHERE id=?", (d["id"],))
n += 1
except OSError:
pass
c.execute("INSERT INTO sessions(id,ts,n,note) VALUES(?,?,?,?)", (session, time.time(), n, "duplicates to hold"))
c.commit(); c.close(); refresh_counts()
return n
# ---------- UI ----------
BASE_CSS = """
:root{--bg:#0a0e14;--panel:rgba(20,26,38,.78);--line:rgba(120,160,255,.14);--txt:#dbe4f5;--dim:#8194b8;--acc:#7c6cff;--acc2:#35d0ba;--warn:#ffb454;--bad:#ff6b6b}
*{box-sizing:border-box;margin:0}
body{color:var(--txt);font:15px/1.55 -apple-system,'SF Pro Text',Helvetica,sans-serif;min-height:100vh;
background:linear-gradient(180deg,#0a0e14 0%,#0d1320 60%,#0a0e14 100%) fixed;
position:relative}
body:before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
background:radial-gradient(900px 500px at 15% -10%,rgba(124,108,255,.08),transparent 60%),
radial-gradient(800px 500px at 90% 20%,rgba(53,208,186,.06),transparent 60%),
radial-gradient(700px 400px at 50% 110%,rgba(124,108,255,.05),transparent 60%)}
body:after{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
background-image:linear-gradient(rgba(120,160,255,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(120,160,255,.035) 1px,transparent 1px);
background-size:46px 46px;mask-image:radial-gradient(80% 60% at 50% 20%,#000 30%,transparent 100%)}
.wrap{position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:26px 20px 80px}
nav{position:sticky;top:0;z-index:5;background:rgba(10,14,20,.94);border-bottom:1px solid var(--line);border-image:linear-gradient(90deg,transparent,rgba(124,108,255,.5),transparent) 1}
nav .in{max-width:1180px;margin:0 auto;display:flex;gap:18px;align-items:center;padding:13px 20px}
nav a{color:var(--dim);text-decoration:none;font-weight:600;font-size:14px;padding:5px 12px;border-radius:9px}
nav a.on,nav a:hover{color:#fff;background:rgba(124,108,255,.14)}
.brand{font-weight:800;letter-spacing:.5px;background:linear-gradient(90deg,#fff,#7c6cff 60%,#35d0ba);background-clip:text;-webkit-text-fill-color:transparent;font-size:17px}
h1.grad{font-size:30px;font-weight:800;background:linear-gradient(90deg,#fff,#9d8fff);background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:6px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:20px 0}
.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px 18px;box-shadow:0 12px 30px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.05);transition:transform .15s,box-shadow .15s}
.card:hover{transform:translateY(-2px);box-shadow:0 16px 36px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.06)}
.card .n{font-size:26px;font-weight:800;background:linear-gradient(90deg,#fff,#35d0ba);background-clip:text;-webkit-text-fill-color:transparent}
.card .l{color:var(--dim);font-size:12px;text-transform:uppercase;letter-spacing:1px;margin-top:2px}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:20px;margin:16px 0;box-shadow:0 12px 30px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.05)}
.panel h2{font-size:16px;margin-bottom:12px;color:#fff}
label{display:block;color:var(--dim);font-size:12px;text-transform:uppercase;letter-spacing:1px;margin:10px 0 4px}
input[type=text],input[type=number],textarea,select{width:100%;background:rgba(8,12,20,.7);border:1px solid var(--line);border-radius:10px;color:var(--txt);padding:9px 12px;font-size:14px;outline:none;transition:border .15s,box-shadow .15s}
input:focus,textarea:focus,select:focus{border-color:rgba(124,108,255,.6);box-shadow:0 0 0 3px rgba(124,108,255,.16)}
textarea{min-height:180px;font:13px ui-monospace,Menlo,monospace;resize:vertical}
button,.btn{display:inline-block;background:linear-gradient(135deg,#7c6cff,#5b48e8);color:#fff;border:none;border-radius:11px;padding:10px 18px;font-weight:700;font-size:14px;cursor:pointer;text-decoration:none;box-shadow:0 6px 18px rgba(124,108,255,.3);transition:transform .12s,filter .12s}
button:hover,.btn:hover{transform:translateY(-1px);filter:brightness(1.12)}
button:active{transform:translateY(1px)}
button.ghost{background:rgba(124,108,255,.12);box-shadow:none;color:#b7aaff}
button.teal{background:linear-gradient(135deg,#35d0ba,#22a893);box-shadow:0 6px 18px rgba(53,208,186,.25)}
button.bad{background:linear-gradient(135deg,#ff6b6b,#e04848);box-shadow:0 6px 18px rgba(255,107,107,.25)}
.row{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end}
.row>div{flex:1;min-width:150px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{color:var(--dim);text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:1px;padding:8px 10px;border-bottom:1px solid var(--line)}
td{padding:8px 10px;border-bottom:1px solid rgba(120,160,255,.07)}
tr:hover td{background:rgba(124,108,255,.05)}
.chip{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11px;font-weight:700}
.chip.c-doc{background:rgba(124,108,255,.18);color:#b7aaff}.chip.c-ebook{background:rgba(53,208,186,.16);color:#6fe8d6}
.chip.c-financial{background:rgba(255,180,84,.16);color:#ffcf8f}.chip.c-image{background:rgba(53,130,255,.16);color:#8fc0ff}
.chip.c-archive{background:rgba(160,160,180,.14);color:#c3c9dd}.chip.c-unknown{background:rgba(160,160,180,.14);color:#aab}
.bar{height:7px;border-radius:5px;background:rgba(120,160,255,.12);overflow:hidden;margin:8px 0}
.bar i{display:block;height:100%;background:linear-gradient(90deg,#7c6cff,#35d0ba);transition:width .3s}
.muted{color:var(--dim);font-size:13px}
.plan{border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:10px 0;background:rgba(12,17,28,.6);transition:transform .12s}
.plan:hover{transform:translateY(-1px)}
.plan .src{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--dim);word-break:break-all}
.plan .arrow{color:var(--acc2);font-size:16px;margin:6px 0}
.plan .dst{font-family:ui-monospace,Menlo,monospace;font-size:13px;color:#cfe3ff;word-break:break-all}
.plan .meta{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:8px}
.v-APPROVE{color:#6fe8d6}.v-REVIEW{color:#ffcf8f}.v-REJECT{color:#ff9c9c}
.dupe{background:rgba(255,180,84,.1);border:1px solid rgba(255,180,84,.35);border-radius:14px;padding:14px 16px;margin:10px 0}
.toast{position:fixed;bottom:22px;right:22px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 18px;box-shadow:0 14px 40px rgba(0,0,0,.5);z-index:9;display:none}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
"""
def shell(body, active="home"):
nav = ""
for k, label in [("home","Dashboard"),("files","Files"),("plan","Plan"),("dupes","Duplicates"),("sessions","Sessions"),("settings","Settings")]:
nav += f"<a class='{'on' if k==active else ''}' href='/{k}'>{label}</a>"
return ("<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>LIBRA — File Librarian</title><style>" + BASE_CSS + "</style></head><body>"
"<nav><div class='in'><span class='brand'>♎ LIBRA</span>" + nav + "</div></nav>"
"<div class='wrap'>" + body + "</div>"
"<div class='toast' id='toast'></div>"
"<script>window.LIBRA=" + json.dumps(STATE) + ";</script></body></html>")
@app.route("/")
def home():
refresh_counts()
err = f"<div class='panel' style='border-color:rgba(255,107,107,.4)'><h2 style='color:var(--bad)'>Last error</h2><pre style='white-space:pre-wrap;font-size:11px'>{STATE.get('last_error','')}</pre></div>" if STATE.get("last_error") else ""
scan_status = "<div class='bar'><i style='width:100%'></i></div><span class='muted'>scan running… refresh page</span>" if STATE["scan_running"] else ""
cls_status = f"<div class='bar'><i style='width:{int(100*STATE['classify_done']/max(1,STATE['classify_total']))}%'></i></div><span class='muted'>classifying {STATE['classify_done']}/{STATE['classify_total']}…</span>" if STATE["classify_running"] else ""
body = f"""
<h1 class='grad'>File Librarian</h1><p class='muted'>Scanner → Extractor → Nimble → Safety → You approve → Reversible move. Nothing moves without your click.</p>
<div class='grid'>
<div class='card'><div class='n'>{STATE.get('files',0)}</div><div class='l'>Files indexed</div></div>
<div class='card'><div class='n'>{STATE.get('classified',0)}</div><div class='l'>Classified</div></div>
<div class='card'><div class='n'>{STATE.get('dupes',0)}</div><div class='l'>In dup groups</div></div>
<div class='card'><div class='n'>{STATE.get('pending',0)}</div><div class='l'>Approved (dry)</div></div>
<div class='card'><div class='n'>{STATE.get('moved',0)}</div><div class='l'>Moved</div></div>
</div>{err}
<div class='panel'><h2>1 · Scan a location</h2>{scan_status}
<form method='post' action='/scan'><div class='row'>
<div><label>Root path</label><input type='text' name='root' value='~/Downloads'></div>
<div><label>Max files</label><input type='number' name='max_files' value='2000'></div>
<div><label>Min size (bytes)</label><input type='number' name='min_size' value='0'></div>
<div><label>Max size (bytes, 0=no cap)</label><input type='number' name='max_size' value='0'></div>
<div><button type='submit'>SCAN →</button></div>
</div></form></div>
<div class='panel'><h2>2 · Hash + Classify (Nimble on nightmare)</h2>{cls_status}
<form method='post' action='/hash'><div class='row'>
<div><label>Hash batch limit</label><input type='number' name='limit' value='500'></div>
<div><label>Max MB to hash per file</label><input type='number' name='max_mb' value='64'></div>
<div><button type='submit' class='ghost'>HASH DUPES →</button></div>
</div></form>
<form method='post' action='/classify' style='margin-top:10px'><div class='row'>
<div><label>Classify batch size</label><input type='number' name='limit' value='100'></div>
<div><button type='submit' class='teal'>CLASSIFY →</button></div>
</div></form></div>
<div class='panel'><h2>Pipeline</h2><p class='muted'>scan → hash (deterministic, sha256) → preview extract (pdf/zip/text/images) → Nimble structured JSON (think:false) → safety validate → <a href='/plan' style='color:#9d8fff'>review plan</a> → apply (dry-run default) → immutable op log → one-click undo.</p></div>
"""
return Response(shell(body, "home"), mimetype="text/html")
@app.route("/scan", methods=["POST"])
def scan_post():
root = request.form.get("root", "~/Downloads")
if STATE["scan_running"]:
return redirect("/")
threading.Thread(target=scan_thread, args=(root, int(request.form.get("max_files", 2000)),
int(request.form.get("min_size", 0)), int(request.form.get("max_size", 0))), daemon=True).start()
return redirect("/")
@app.route("/hash", methods=["POST"])
def hash_post():
if not STATE["classify_running"]:
threading.Thread(target=hash_thread, args=(int(request.form.get("limit", 500)),
int(request.form.get("max_mb", 64))), daemon=True).start()
return redirect("/")
@app.route("/classify", methods=["POST"])
def classify_post():
if not STATE["classify_running"]:
threading.Thread(target=classify_thread, args=(int(request.form.get("limit", 100)),), daemon=True).start()
return redirect("/")
@app.route("/files")
def files_page():
q = request.args.get("q", ""); cls = request.args.get("cls", "")
c = db()
sql = "SELECT * FROM files WHERE 1"; args = []
if q: sql += " AND (path LIKE ? OR name LIKE ?)"; args += [f"%{q}%", f"%{q}%"]
if cls: sql += " AND file_class=?"; args.append(cls)
sql += " ORDER BY id DESC LIMIT 400"
rows = c.execute(sql, args).fetchall()
c.close()
trs = ""
for r in rows:
cls_chip = f"<span class='chip c-{r['file_class']}'>{r['file_class'] or '—'}</span>" if r["file_class"] else "<span class='muted'>unclassified</span>"
subj = r["subject"] or ""
conf = f"{int((r['confidence'] or 0)*100)}%" if r["confidence"] is not None else ""
dup = " ◆dup" if r["sha256"] and r["sha256"] not in ("TOOBIG","UNREADABLE") else ""
pv = (r["preview"] or "")[:110]
trs += (f"<tr><td>{cls_chip} {subj} {conf}{dup}</td>"
f"<td><div style='word-break:break-all'>{r['path']}</div><div class='muted' style='font-size:11px'>{pv}</div></td>"
f"<td class='muted'>{r['status']}</td></tr>")
body = f"""
<h1 class='grad'>Indexed Files</h1>
<form method='get' action='/files'><div class='row'>
<div><label>Search</label><input type='text' name='q' value='{q}'></div>
<div><label>Class</label><input type='text' name='cls' value='{cls}'></div>
<div><button type='submit' class='ghost'>FILTER</button></div>
</div></form>
<div class='panel'><table><tr><th>Class</th><th>Path + preview</th><th>Status</th></tr>{trs}</table></div>
"""
return Response(shell(body, "files"), mimetype="text/html")
@app.route("/plan")
def plan_page():
tx = load_taxonomy()
min_conf = float(request.args.get("min_conf", 0.6))
plans = build_plan(tx, min_conf)
plans.sort(key=lambda p: (-p["conf"], p["verdict"] != "APPROVE"))
cards = ""
approved_default = 0
for p in plans[:600]:
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 ""
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='meta'><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>")
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 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 applyMoves(dry) {{
var ids = [];
document.querySelectorAll('.pick').forEach(function(cb) {{ if (cb.checked) ids.push(cb.dataset.id); }});
if (!ids.length) {{ alert('nothing checked'); return; }}
fetch('/api/apply', {{ method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{ ids: ids, dry_run: dry }}) }})
.then(function(r){{ return r.json(); }})
.then(function(d) {{ document.getElementById('pmsg').textContent = 'done: ' + d.done + ' applied, ' + d.skipped + ' skipped, session ' + d.session; }});
}}
</script>
"""
return Response(shell(body, "plan"), mimetype="text/html")
@app.route("/api/apply", methods=["POST"])
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)))
return {"done": done, "skipped": skipped, "session": session}
@app.route("/dupes")
def dupes_page():
c = db()
groups = c.execute("""SELECT sha256, COUNT(*) n, SUM(size) b FROM files
WHERE sha256 IS NOT NULL AND sha256 NOT IN ('TOOBIG','UNREADABLE')
GROUP BY sha256 HAVING COUNT(*)>1 ORDER BY b DESC LIMIT 100""").fetchall()
c.close()
out = ""
reclaim = 0
for g in groups:
c = db()
members = c.execute("SELECT * FROM files WHERE sha256=?", (g["sha256"],)).fetchall()
c.close()
reclaim += g["b"] * (g["n"] - 1)
rows = ""
for m in members:
rows += (f"<div style='display:flex;gap:10px;align-items:center;padding:4px 0'>"
f"<span style='flex:1;font-family:ui-monospace,Menlo,monospace;font-size:12px;word-break:break-all'>{m['path']}</span>"
f"<button class='ghost keep-btn' data-keep='{m['id']}' style='padding:4px 10px;font-size:11px'>KEEP THIS → hold others</button></div>")
out += f"<div class='dupe'><b style='color:#ffcf8f'>{g['n']} copies · {g['b']/1e6:.1f} MB each</b>{rows}</div>"
body = f"""
<h1 class='grad'>Duplicates</h1>
<p class='muted'>sha256-verified only — never guessed. Keepers stay; the rest go to <code>~/libra/dupe_hold/</code> (reversible). {reclaim/1e9:.2f} GB reclaimable.</p>
<div>{out or "<p class='muted'>No duplicate groups yet — run Hash on the dashboard.</p>"}</div>
<script>
document.querySelectorAll('.keep-btn').forEach(function(b){{
b.addEventListener('click', function() {{
fetch('/api/keep', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{keep: parseInt(b.dataset.keep)}})}})
.then(function(r){{return r.json()}}).then(function(d){{ b.textContent='held ' + d.held; b.disabled=true; }});
}});
}});
</script>"""
return Response(shell(body, "dupes"), mimetype="text/html")
@app.route("/api/keep", methods=["POST"])
def api_keep():
d = request.get_json(force=True)
n = delete_duplicates(int(d["keep"]))
return {"held": n}
@app.route("/sessions")
def sessions_page():
c = db()
sess = c.execute("SELECT * FROM sessions ORDER BY ts DESC LIMIT 50").fetchall()
ops = c.execute("SELECT * FROM operations ORDER BY ts DESC LIMIT 200").fetchall()
c.close()
sr = ""
for s in sess:
undo_btn = "" if s["undone"] or s["note"] == "duplicates to hold" else f"<button class='bad' onclick='undo(\"{s['id']}\")' style='padding:5px 12px;font-size:12px'>UNDO ALL ({s['n']})</button>"
sr += (f"<div class='plan' style='display:flex;gap:14px;align-items:center'><b style='font-family:monospace'>{s['id']}</b>"
f"<span class='muted'>{time.strftime('%b %d %H:%M', time.localtime(s['ts']))} · {s['n']} ops · {s['note']}"
+ (" · <span style='color:#ff9c9c'>UNDONE</span>" if s["undone"] else "") + "</span>"
f"<span style='flex:1'></span>{undo_btn}</div>")
orows = ""
for o in ops:
orows += (f"<tr><td class='muted'>{time.strftime('%m-%d %H:%M', time.localtime(o['ts']))}</td><td>{o['op']}</td>"
f"<td style='font-family:monospace;font-size:11px;word-break:break-all'>{o['src']}</td>"
f"<td style='font-family:monospace;font-size:11px;word-break:break-all'>{o['dst']}</td>"
f"<td class='muted'>{o['session']}" + (" ↩" if o["undone"] else "") + "</td></tr>")
body = f"""
<h1 class='grad'>Sessions · Operation Log</h1>
<p class='muted'>Every move is logged with source, destination and sha256. Undo restores files exactly.</p>
<div class='panel'><h2>Sessions</h2>{sr or '<span class=muted>no sessions yet</span>'}</div>
<div class='panel'><h2>Operation log</h2><table><tr><th>When</th><th>Op</th><th>Source</th><th>Destination</th><th>Session</th></tr>{orows}</table></div>
<script>
function undo(sid) {{
fetch('/api/undo', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{session: sid}})}})
.then(function(r){{return r.json()}}).then(function(d){{ location.reload(); }});
}}
</script>"""
return Response(shell(body, "sessions"), mimetype="text/html")
@app.route("/api/undo", methods=["POST"])
def api_undo():
d = request.get_json(force=True)
n = undo_session(d["session"])
return {"undone": n}
@app.route("/settings")
def settings_page():
tx = open(CFG_PATH).read()
body = f"""
<h1 class='grad'>Settings</h1>
<div class='panel'><h2>Taxonomy (destination map)</h2>
<form method='post' action='/settings/save'>
<label>taxonomy.yaml — organized_root + class+subject → destination</label>
<textarea name='tax'>{tx}</textarea>
<div style='margin-top:10px'><button type='submit' class='teal'>SAVE TAXONOMY</button></div>
</form></div>
<div class='panel'><h2>Engine</h2>
<p class='muted'>Model: <code>{MODEL}</code> @ <code>{OLLAMA}</code> (Nimble decision lane, think:false) · DB: <code>{DB_PATH}</code></p>
<p class='muted'>Safety gates: no system paths · no symlinks · no overwrite (auto-suffix) · confidence gate on plan · every move logged + reversible · dupes go to hold, never deleted.</p></div>
"""
return Response(shell(body, "settings"), mimetype="text/html")
@app.route("/settings/save", methods=["POST"])
def settings_save():
open(CFG_PATH, "w").write(request.form.get("tax", ""))
return redirect("/settings")
if __name__ == "__main__":
init_db()
refresh_counts()
app.run(host="127.0.0.1", port=5731, debug=False)