cd /news/ai-tools/lazytidy-a-local-gemma-folder-tidier… · home › topics › ai-tools › article
[ARTICLE · art-153875] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=↑ positive

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

A developer released LazyTidy, a stdlib-only Python command-line tool that uses a local Gemma model via Ollama to suggest category folders and cleaned-up filenames for messy directories. Safety is enforced in plain Python rather than by the model: dry runs are the default, folders are whitelisted, names are sanitized, files are never overwritten or deleted, and an undo log allows reversal. The author notes the tool has not been run against a real Gemma instance, so the quality of suggested names remains untested.

by read6 min views1 publishedOct 20, 2026

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.

── more in #ai-tools 4 stories · sorted by recency
── more on @lazytidy 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/lazytidy-a-local-gem…] indexed:0 read:6min 2026-10-20 · —