DEV Community

Ibnu Syifa Takbir Adha
Ibnu Syifa Takbir Adha

Posted on

LazyTidy: a local Gemma folder tidier that can't delete or overwrite anything

Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady Submission 🦥

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady

What I Built

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.

Demo

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
Enter fullscreen mode Exit fullscreen mode

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.

Code

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()
Enter fullscreen mode Exit fullscreen mode

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
    # same target name for two files: both kept, one gets a -2 suffix (dupe.txt sorts first)
    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
    # model down: original names are kept (sanitized), not replaced by junk like "none"
    assert sorted(dst.name for s, dst in t.plan(d, ask=boom)) == ["dupe.txt", "img-001.jpg", "notes.txt", "scan-3.pdf"]
    # pre-existing destination is not overwritten
    (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")
Enter fullscreen mode Exit fullscreen mode

How I Built It

  • Open-weight model: Gemma through Ollama's local HTTP API with format: "json". It sees only the file name and the first 300 characters of text, and it only suggests a folder and a name.
  • Guardrails in plain Python: folder must be on a whitelist (anything else, including a path-traversal attempt like ../../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.
  • Fail safe: if the model is down or returns junk, files fall back to 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.
  • Zero dependencies, so it runs anywhere with Python.

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.

Why Does Open Innovation Matter?

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.

My Agent Session

Not included.

The Lazy Payoff

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.

Prize Categories

Best Use of Gemma: Gemma, served locally via Ollama, makes the naming and sorting suggestions at the core of the project.

Top comments (0)