Files
libra/app.py

833 lines
46 KiB
Python

#!/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()
# deterministic fast-path: obvious extensions never need the LLM (instant + accurate)
QUICK_RULES = {
".pdf": ("document", None, None, 0.75), ".doc": ("document", None, None, 0.75), ".docx": ("document", None, None, 0.75),
".xlsx": ("document", "finance", None, 0.8), ".xls": ("document", "finance", None, 0.8), ".pptx": ("document", "work", None, 0.8),
".txt": ("document", None, None, 0.6), ".md": ("document", None, None, 0.6), ".csv": ("dataset", None, None, 0.8),
".jpg": ("image", "photography", None, 0.9), ".jpeg": ("image", "photography", None, 0.9), ".png": ("image", None, None, 0.85),
".heic": ("image", "photography", None, 0.9), ".gif": ("image", None, None, 0.85), ".webp": ("image", None, None, 0.85),
".psd": ("image", None, None, 0.8),
".mp4": ("video", None, None, 0.9), ".mov": ("video", None, None, 0.9), ".mkv": ("video", None, None, 0.9), ".avi": ("video", None, None, 0.9),
".mp3": ("audio", None, None, 0.9), ".wav": ("audio", None, None, 0.9), ".flac": ("audio", None, None, 0.9),
".zip": ("archive", None, None, 0.85), ".rar": ("archive", None, None, 0.85), ".7z": ("archive", None, None, 0.85),
".tar": ("archive", None, None, 0.85), ".gz": ("archive", None, None, 0.85), ".iso": ("archive", None, None, 0.8),
".py": ("source_code", "programming", None, 0.92), ".js": ("source_code", "programming", None, 0.92), ".ts": ("source_code", "programming", None, 0.92),
".sh": ("source_code", "linux", None, 0.9), ".go": ("source_code", "programming", None, 0.92), ".rs": ("source_code", "programming", None, 0.92),
".c": ("source_code", "programming", None, 0.9), ".cpp": ("source_code", "programming", None, 0.9), ".java": ("source_code", "programming", None, 0.92),
".exe": ("software", None, None, 0.9), ".msi": ("software", None, None, 0.9), ".dmg": ("software", None, None, 0.9), ".app": ("software", None, None, 0.9),
".deb": ("software", "linux", None, 0.9), ".rpm": ("software", "linux", None, 0.9), ".apk": ("software", None, None, 0.9),
".ova": ("dataset", None, None, 0.7), ".vmdk": ("dataset", None, None, 0.7), ".sqlite": ("dataset", None, None, 0.8), ".db": ("dataset", None, None, 0.8),
".epub": ("ebook", None, None, 0.95), ".mobi": ("ebook", None, None, 0.95), ".azw3": ("ebook", None, None, 0.95), ".djvu": ("ebook", None, None, 0.9),
}
# exts that LOOK like ebooks/docs but need content sniffing (pdf can be either) — handled by classifier
# content signals — deterministic, beat quick rules when the content screams (receipt, fullz, hacking…)
SIGNALS = [
(r"receipt|invoice|order confirmation|statement|payment|charged \$|billing", "financial", "finance", "important", "organize", 0.97),
(r"cvv|fullz|ssn|card number|carding|dump[s]? ", "personal", "unknown", "critical", "quarantine", 0.95),
(r"hacking|exploit|keylogger|rat |botnet|malware", "document", "cybersecurity", "important", "quarantine", 0.93),
(r"chapter one|novel|fiction|once upon", "document", "personal", "important", "organize", 0.9),
]
def sniff_content(p, ext, size):
if ext not in (".txt", ".md", ".csv", ".json", ".log", ".pdf", ".html", ".cfg", ".ini") or size > 5_000_000:
return None
try:
if ext == ".pdf":
from pypdf import PdfReader
txt = " ".join((pg.extract_text() or "") for pg in PdfReader(p).pages[:2])
else:
txt = open(p, "r", errors="replace").read(4000)
except Exception:
return None
low = txt.lower()
for rx, fc, sub, imp, act, conf in SIGNALS:
if re.search(rx, low):
return {"file_class": fc, "subject": sub, "importance": imp, "action": act,
"confidence": conf, "rename": None, "preview": re.sub(r"\s+", " ", txt)[:600]}
return None
# ---------- 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):
init_db()
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:
qr = QUICK_RULES.get(os.path.splitext(f)[1].lower())
if qr:
fc, sub, imp, conf = qr
sig = sniff_content(p, os.path.splitext(f)[1].lower(), st.st_size)
if sig:
fc, sub, imp, conf = sig["file_class"], sig["subject"], sig["importance"], sig["confidence"]
c.execute("""INSERT OR IGNORE INTO files(path,name,ext,size,mtime,file_class,subject,importance,confidence,action,preview,status)
VALUES(?,?,?,?,?,?,?,?,?,?,?, 'classified')""",
(p, f, os.path.splitext(f)[1].lower(), st.st_size, st.st_mtime, fc, sub, imp, conf, sig["action"], sig["preview"]))
n += 1
continue
c.execute("""INSERT OR IGNORE INTO files(path,name,ext,size,mtime,file_class,subject,importance,confidence,action,status)
VALUES(?,?,?,?,?,?,?,?,?,'organize','classified')""",
(p, f, os.path.splitext(f)[1].lower(), st.st_size, st.st_mtime, fc, sub, imp or 'normal', conf))
n += 1
continue
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():
try:
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()
except Exception:
pass
# ---------- 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 = ("You are a file-organizer classifier. Reply ONLY minified JSON: "
"{\"file_class\":\"..\",\"subject\":\"..\",\"importance\":\"..\",\"action\":\"..\",\"confidence\":0.0,\"rename\":\"..\"}\n"
"Examples:\n"
"Input: invoice_stripe_march.pdf (PDF, preview: 'Stripe payment receipt $120.00 subscription') -> "
"{\"file_class\":\"financial\",\"subject\":\"finance\",\"importance\":\"important\",\"action\":\"organize\",\"confidence\":0.97,\"rename\":\"2026-03_stripe_invoice.pdf\"}\n"
"Input: novel_draft_v2.docx (DOCX, preview: 'Chapter One. The lighthouse keeper woke...') -> "
"{\"file_class\":\"document\",\"subject\":\"personal\",\"importance\":\"important\",\"action\":\"organize\",\"confidence\":0.9,\"rename\":\"novel_draft_v2.docx\"}\n"
"Input: dumps_fullz_usa.txt (TXT, preview: 'CC number exp cvv ssn address...') -> "
"{\"file_class\":\"personal\",\"subject\":\"unknown\",\"importance\":\"critical\",\"action\":\"quarantine\",\"confidence\":0.95,\"rename\":\"dumps_fullz_usa.txt\"}\n"
"Now classify:\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,
"keep_alive": -1,
"options": {"temperature": 0.05, "num_predict": 220}}).encode()
req = urllib.request.Request(OLLAMA + "/api/generate", data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) 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_one_file(fid):
c = db()
row = c.execute("SELECT * FROM files WHERE id=?", (fid,)).fetchone()
if not row or (row["file_class"] and row["subject"]):
c.close(); return None
if row["preview"] is None:
prev = extract_preview(row["path"], row["ext"], row["size"])
c.execute("UPDATE files SET preview=? WHERE id=?", (prev, fid))
row = dict(row); row["preview"] = prev
try:
res = classify_one(row)
except Exception:
res = None
if not res:
# deterministic fallback — NEVER leave NULLs or stall the pipeline
qr = QUICK_RULES.get(row["ext"]) or ("unknown", "unknown", "normal", 0.3)
res = {"file_class": qr[0], "subject": qr[1] or "unknown", "importance": qr[2] or "normal",
"action": "review", "confidence": qr[3], "rename": row["name"]}
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"], fid))
c.commit()
c.close()
return res
def classify_thread(limit):
from concurrent.futures import ThreadPoolExecutor, as_completed
try:
with LOCK: STATE["classify_running"] = True
c = db()
fids = [r["id"] for r in c.execute("SELECT id FROM files WHERE file_class IS NULL LIMIT ?", (limit,)).fetchall()]
c.close()
STATE["classify_total"] = len(fids); STATE["classify_done"] = 0
# warm the model once (cold load can take 40s; warm calls ~4s)
try:
import urllib.request
body = json.dumps({"model": MODEL, "prompt": "warmup", "stream": False, "think": False,
"keep_alive": -1, "options": {"num_predict": 1}}).encode()
urllib.request.urlopen(urllib.request.Request(OLLAMA + "/api/generate", data=body,
headers={"Content-Type": "application/json"}), timeout=90).read()
except Exception:
pass
with ThreadPoolExecutor(max_workers=4) as ex:
futs = {ex.submit(classify_one_file, fid): fid for fid in fids}
for f in as_completed(futs):
STATE["classify_done"] += 1
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) + ";\n"
"(function pulse(){fetch('/api/state').then(function(r){return r.json()}).then(function(s){"
"var el=function(id){return document.getElementById(id)};"
"if(el('st-files')){el('st-files').textContent=s.files;el('st-classified').textContent=s.classified;"
"el('st-dupes').textContent=s.dupes;el('st-pending').textContent=s.pending;el('st-moved').textContent=s.moved}"
"window.LIBRA=s;setTimeout(pulse,2000)}).catch(function(){setTimeout(pulse,4000)})})();</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' id='st-files'>{STATE.get('files',0)}</div><div class='l'>Files indexed</div></div>
<div class='card'><div class='n' id='st-classified'>{STATE.get('classified',0)}</div><div class='l'>Classified</div></div>
<div class='card'><div class='n' id='st-dupes'>{STATE.get('dupes',0)}</div><div class='l'>In dup groups</div></div>
<div class='card'><div class='n' id='st-pending'>{STATE.get('pending',0)}</div><div class='l'>Approved (dry)</div></div>
<div class='card'><div class='n' id='st-moved'>{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("/api/state")
def api_state():
refresh_counts()
return {k: v for k, v in STATE.items() if isinstance(v, (int, float, bool, str))}
@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) {{ 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; }});
}});
}}
</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)