This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
LazyTidy is a command-line tool for the clutter you keep promising to sort "later": a Downloads folder full of IMG_001.JPG, scan (3).pdf and notes.txt. Point it at a folder and a local open-weight model suggests a category folder and a clean name for every file. You look at the plan, add --apply, and walk away. It is lazy-friendly by design: one command, no per-file decisions.
The twist is that the model is never trusted with the disk. A model that can rename and move your files can also lose them, so the rules are enforced by plain Python around it: dry run by default, a fixed list of allowed folders, sanitized names, never overwrite, never delete, and an undo command.
Honest note: I have not run this on my own messy folder with a real model, so I have no "I saved N hours" number and won't invent one. What I can say is what the tests prove about safety, below.
ollama pull gemma3
python3 lazytidy.py ~/Downloads # dry run: prints the plan only
python3 lazytidy.py ~/Downloads --apply # moves the files
python3 lazytidy.py ~/Downloads --undo # puts everything back
The dry run prints one line per file, in the shape scan (3).pdf -> invoices/acme-invoice-42.pdf. In my tests a scripted stand-in plays the model. I have not run it against a real Gemma instance, so how good the suggested names are is untested.
Stdlib-only Python, one file plus tests.
lazytidy.py:
#!/usr/bin/env python3
"""LazyTidy: sort and rename a messy folder while you do nothing. Fully local.
A local open-weight model (Gemma via Ollama) suggests a category folder and a clean
filename for each file. Plain Python makes it safe: dry-run by default, whitelisted
folders, sanitized names, never overwrites, never deletes, and writes an undo log.
Stdlib only.
"""
import json, re, sys, urllib.request
from pathlib import Path
OLLAMA, MODEL = "http://localhost:11434/api/generate", "gemma3"
CATEGORIES = ["documents", "images", "invoices", "notes", "code", "other"]
UNDO_LOG = ".lazytidy-undo.json"
def ask_model(name, snippet):
prompt = (f"Organize this file. Name: {name}\nStart of content: {snippet!r}\n"
f'Reply ONLY as JSON: {{"folder": one of {CATEGORIES}, '
'"new_name": short descriptive filename WITHOUT extension, lowercase words separated by dashes}')
body = json.dumps({"model": MODEL, "prompt": prompt, "stream": False, "format": "json"}).encode()
req = urllib.request.Request(OLLAMA, body, {"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as r:
return json.loads(json.loads(r.read())["response"])
def clean_stem(s, fallback):
if not isinstance(s, str): return fallback
s = re.sub(r"[^a-z0-9]+", "-", str(s).lower()).strip("-")[:60]
return s or fallback
def snippet_of(p, n=300):
try: return p.read_text(errors="ignore")[:n]
except Exception: return ""
def plan(folder, ask=ask_model):
"""Return list of (src, dst). Never touches the disk."""
root, moves, taken = Path(folder), [], set()
for p in sorted(root.iterdir()):
if not p.is_file() or p.name.startswith("."): continue
try: s = ask(p.name, snippet_of(p))
except Exception: s = {}
cat = s.get("folder") if s.get("folder") in CATEGORIES else "other"
stem = clean_stem(s.get("new_name"), clean_stem(p.stem, "file"))
dst, i = root / cat / (stem + p.suffix.lower()), 1
while dst.exists() or dst in taken: # never overwrite
i += 1; dst = root / cat / f"{stem}-{i}{p.suffix.lower()}"
taken.add(dst); moves.append((p, dst))
return moves
def apply(moves, folder):
done = []
for src, dst in moves:
dst.parent.mkdir(exist_ok=True)
src.rename(dst); done.append([str(src), str(dst)]) # move only, never delete
(Path(folder) / UNDO_LOG).write_text(json.dumps(done))
return done
def undo(folder):
log = Path(folder) / UNDO_LOG
for src, dst in reversed(json.loads(log.read_text())):
Path(dst).rename(src)
log.unlink()
def main():
a = sys.argv[1:]
if not a: sys.exit("usage: lazytidy.py FOLDER [--apply | --undo]")
folder = a[0]
if "--undo" in a: undo(folder); print("undone"); return
moves = plan(folder)
for s, d in moves: print(f"{s.name} -> {d.relative_to(folder)}")
if "--apply" in a: apply(moves, folder); print(f"moved {len(moves)} files. undo: --undo")
else: print("dry run only. add --apply to move files.")
if __name__ == "__main__":
main()
test_lazytidy.py (passes, using a fake model):
import tempfile
from pathlib import Path
import lazytidy as t
def make(d):
(d/"IMG_001.JPG").write_text("jpgdata"); (d/"scan (3).pdf").write_text("Invoice 42 total 10")
(d/"notes.txt").write_text("buy milk"); (d/"dupe.txt").write_text("another note")
(d/".hidden").write_text("skip me"); (d/"sub").mkdir()
def fake(name, snip):
return {"IMG_001.JPG": {"folder":"images","new_name":"Beach Day!!"},
"scan (3).pdf": {"folder":"invoices","new_name":"acme-invoice-42"},
"notes.txt": {"folder":"notes","new_name":"shopping"},
"dupe.txt": {"folder":"notes","new_name":"shopping"}}[name]
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp); make(d)
moves = t.plan(d, ask=fake)
assert not (d/"images").exists() # plan is a dry run
names = {s.name: str(dst.relative_to(d)) for s, dst in moves}
assert names["IMG_001.JPG"] == "images/beach-day.jpg" # sanitized, ext lowercased
assert {names["notes.txt"], names["dupe.txt"]} == {"notes/shopping.txt", "notes/shopping-2.txt"}
assert ".hidden" not in names and "sub" not in names
t.apply(moves, d)
assert (d/"invoices"/"acme-invoice-42.pdf").read_text() == "Invoice 42 total 10"
assert not (d/"notes.txt").exists()
t.undo(d)
assert (d/"notes.txt").read_text() == "buy milk" and (d/"IMG_001.JPG").exists()
assert not (d/t.UNDO_LOG).exists()
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp); make(d)
evil = lambda n, s: {"folder": "../../etc", "new_name": "../../../passwd"}
for s, dst in t.plan(d, ask=evil):
assert dst.parent == d/"other", dst # bad folder -> 'other'
assert ".." not in dst.name and "/" not in dst.name
def boom(n, s): raise RuntimeError("model down")
assert all(dst.parent == d/"other" for s, dst in t.plan(d, ask=boom)) # model failure is safe
assert sorted(dst.name for s, dst in t.plan(d, ask=boom)) == ["dupe.txt", "img-001.jpg", "notes.txt", "scan-3.pdf"]
(d/"other").mkdir(); (d/"other"/"notes.txt").write_text("KEEP")
t.apply(t.plan(d, ask=boom), d)
assert (d/"other"/"notes.txt").read_text() == "KEEP"
print("all tests passed")
format: "json". It sees only the file name and the first 300 characters of text, and it only ../../etc, becomes other); names are reduced to lowercase letters, digits and dashes; collisions get a -2 suffix; hidden files and sub-folders are skipped; moves are renames only, with an undo log written.other/ with their original (sanitized) names. While testing the "model down" case I found my first version renamed everything to none.txt, because a missing name was turned into the text "none". I fixed it and added a test for it.
Known limits: it reads text files only (images and PDFs are classified by name, not content), the undo log is overwritten by a second --apply, and undo does not remove the category folders it created. Those are the first things I would fix.
A file tidier is exactly the kind of tool that sees everything: invoices, contracts, private notes. I would not hand the contents of my Downloads folder to a server I don't control just to rename files. With an open-weight model running locally, the first 300 characters of each file never leave the machine, the tool works offline, and it costs nothing per run, which matters for something you would run weekly. The model being swappable also means a small one is good enough here, because the safety comes from the code around it, not from trusting the model.
Not included.
I can't claim a measured time saving, since I have not used it on a real folder yet. What I can claim is the part I tested: it will not overwrite a file, will not delete one, and can be undone.
Best Use of Gemma: Gemma, served locally via Ollama, makes the naming and sorting suggestions at the core of the project.