{"slug": "lazytidy-a-local-gemma-folder-tidier-that-can-t-delete-or-overwrite-anything", "title": "LazyTidy: a local Gemma folder tidier that can't delete or overwrite anything", "summary": "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.", "body_md": "*This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady](https://dev.to/challenges/hacktoberfest-week3-2026-10-19)*\n\n**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.\n\nThe 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.\n\nHonest 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.\n\n```\nollama pull gemma3\npython3 lazytidy.py ~/Downloads            # dry run: prints the plan only\npython3 lazytidy.py ~/Downloads --apply    # moves the files\npython3 lazytidy.py ~/Downloads --undo     # puts everything back\n```\n\nThe 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.\n\nStdlib-only Python, one file plus tests.\n\n`lazytidy.py`:\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"LazyTidy: sort and rename a messy folder while you do nothing. Fully local.\n\nA local open-weight model (Gemma via Ollama) suggests a category folder and a clean\nfilename for each file. Plain Python makes it safe: dry-run by default, whitelisted\nfolders, sanitized names, never overwrites, never deletes, and writes an undo log.\nStdlib only.\n\"\"\"\nimport json, re, sys, urllib.request\nfrom pathlib import Path\n\nOLLAMA, MODEL = \"http://localhost:11434/api/generate\", \"gemma3\"\nCATEGORIES = [\"documents\", \"images\", \"invoices\", \"notes\", \"code\", \"other\"]\nUNDO_LOG = \".lazytidy-undo.json\"\n\ndef ask_model(name, snippet):\n    prompt = (f\"Organize this file. Name: {name}\\nStart of content: {snippet!r}\\n\"\n              f'Reply ONLY as JSON: {{\"folder\": one of {CATEGORIES}, '\n              '\"new_name\": short descriptive filename WITHOUT extension, lowercase words separated by dashes}')\n    body = json.dumps({\"model\": MODEL, \"prompt\": prompt, \"stream\": False, \"format\": \"json\"}).encode()\n    req = urllib.request.Request(OLLAMA, body, {\"Content-Type\": \"application/json\"})\n    with urllib.request.urlopen(req, timeout=300) as r:\n        return json.loads(json.loads(r.read())[\"response\"])\n\ndef clean_stem(s, fallback):\n    if not isinstance(s, str): return fallback\n    s = re.sub(r\"[^a-z0-9]+\", \"-\", str(s).lower()).strip(\"-\")[:60]\n    return s or fallback\n\ndef snippet_of(p, n=300):\n    try: return p.read_text(errors=\"ignore\")[:n]\n    except Exception: return \"\"\n\ndef plan(folder, ask=ask_model):\n    \"\"\"Return list of (src, dst). Never touches the disk.\"\"\"\n    root, moves, taken = Path(folder), [], set()\n    for p in sorted(root.iterdir()):\n        if not p.is_file() or p.name.startswith(\".\"): continue\n        try: s = ask(p.name, snippet_of(p))\n        except Exception: s = {}\n        cat = s.get(\"folder\") if s.get(\"folder\") in CATEGORIES else \"other\"\n        stem = clean_stem(s.get(\"new_name\"), clean_stem(p.stem, \"file\"))\n        dst, i = root / cat / (stem + p.suffix.lower()), 1\n        while dst.exists() or dst in taken:           # never overwrite\n            i += 1; dst = root / cat / f\"{stem}-{i}{p.suffix.lower()}\"\n        taken.add(dst); moves.append((p, dst))\n    return moves\n\ndef apply(moves, folder):\n    done = []\n    for src, dst in moves:\n        dst.parent.mkdir(exist_ok=True)\n        src.rename(dst); done.append([str(src), str(dst)])   # move only, never delete\n    (Path(folder) / UNDO_LOG).write_text(json.dumps(done))\n    return done\n\ndef undo(folder):\n    log = Path(folder) / UNDO_LOG\n    for src, dst in reversed(json.loads(log.read_text())):\n        Path(dst).rename(src)\n    log.unlink()\n\ndef main():\n    a = sys.argv[1:]\n    if not a: sys.exit(\"usage: lazytidy.py FOLDER [--apply | --undo]\")\n    folder = a[0]\n    if \"--undo\" in a: undo(folder); print(\"undone\"); return\n    moves = plan(folder)\n    for s, d in moves: print(f\"{s.name}  ->  {d.relative_to(folder)}\")\n    if \"--apply\" in a: apply(moves, folder); print(f\"moved {len(moves)} files. undo: --undo\")\n    else: print(\"dry run only. add --apply to move files.\")\n\nif __name__ == \"__main__\":\n    main()\n```\n\n`test_lazytidy.py` (passes, using a fake model):\n\n``` python\nimport tempfile\nfrom pathlib import Path\nimport lazytidy as t\n\ndef make(d):\n    (d/\"IMG_001.JPG\").write_text(\"jpgdata\"); (d/\"scan (3).pdf\").write_text(\"Invoice 42 total 10\")\n    (d/\"notes.txt\").write_text(\"buy milk\"); (d/\"dupe.txt\").write_text(\"another note\")\n    (d/\".hidden\").write_text(\"skip me\"); (d/\"sub\").mkdir()\n\ndef fake(name, snip):\n    return {\"IMG_001.JPG\": {\"folder\":\"images\",\"new_name\":\"Beach Day!!\"},\n            \"scan (3).pdf\": {\"folder\":\"invoices\",\"new_name\":\"acme-invoice-42\"},\n            \"notes.txt\": {\"folder\":\"notes\",\"new_name\":\"shopping\"},\n            \"dupe.txt\": {\"folder\":\"notes\",\"new_name\":\"shopping\"}}[name]\n\nwith tempfile.TemporaryDirectory() as tmp:\n    d = Path(tmp); make(d)\n    moves = t.plan(d, ask=fake)\n    assert not (d/\"images\").exists()                       # plan is a dry run\n    names = {s.name: str(dst.relative_to(d)) for s, dst in moves}\n    assert names[\"IMG_001.JPG\"] == \"images/beach-day.jpg\"  # sanitized, ext lowercased\n    # same target name for two files: both kept, one gets a -2 suffix (dupe.txt sorts first)\n    assert {names[\"notes.txt\"], names[\"dupe.txt\"]} == {\"notes/shopping.txt\", \"notes/shopping-2.txt\"}\n    assert \".hidden\" not in names and \"sub\" not in names\n    t.apply(moves, d)\n    assert (d/\"invoices\"/\"acme-invoice-42.pdf\").read_text() == \"Invoice 42 total 10\"\n    assert not (d/\"notes.txt\").exists()\n    t.undo(d)\n    assert (d/\"notes.txt\").read_text() == \"buy milk\" and (d/\"IMG_001.JPG\").exists()\n    assert not (d/t.UNDO_LOG).exists()\n\nwith tempfile.TemporaryDirectory() as tmp:\n    d = Path(tmp); make(d)\n    evil = lambda n, s: {\"folder\": \"../../etc\", \"new_name\": \"../../../passwd\"}\n    for s, dst in t.plan(d, ask=evil):\n        assert dst.parent == d/\"other\", dst                 # bad folder -> 'other'\n        assert \"..\" not in dst.name and \"/\" not in dst.name\n    def boom(n, s): raise RuntimeError(\"model down\")\n    assert all(dst.parent == d/\"other\" for s, dst in t.plan(d, ask=boom))  # model failure is safe\n    # model down: original names are kept (sanitized), not replaced by junk like \"none\"\n    assert sorted(dst.name for s, dst in t.plan(d, ask=boom)) == [\"dupe.txt\", \"img-001.jpg\", \"notes.txt\", \"scan-3.pdf\"]\n    # pre-existing destination is not overwritten\n    (d/\"other\").mkdir(); (d/\"other\"/\"notes.txt\").write_text(\"KEEP\")\n    t.apply(t.plan(d, ask=boom), d)\n    assert (d/\"other\"/\"notes.txt\").read_text() == \"KEEP\"\nprint(\"all tests passed\")\n```\n\n`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.\nKnown 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.\n\nA 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.\n\nNot included.\n\nI 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.\n\n**Best Use of Gemma**: Gemma, served locally via Ollama, makes the naming and sorting suggestions at the core of the project.", "url": "https://wpnews.pro/news/lazytidy-a-local-gemma-folder-tidier-that-can-t-delete-or-overwrite-anything", "canonical_source": "https://dev.to/ibnusta/lazytidy-a-local-gemma-folder-tidier-that-cant-delete-or-overwrite-anything-336f", "published_at": "2026-10-20 16:16:03+00:00", "updated_at": "2026-10-20 16:19:57.198073+00:00", "lang": "en", "topics": ["ai-tools", "large-language-models", "developer-tools", "ai-agents"], "entities": ["LazyTidy", "Gemma", "Ollama", "Hacktoberfest", "Python"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/lazytidy-a-local-gemma-folder-tidier-that-can-t-delete-or-overwrite-anything", "markdown": "https://wpnews.pro/news/lazytidy-a-local-gemma-folder-tidier-that-can-t-delete-or-overwrite-anything.md", "text": "https://wpnews.pro/news/lazytidy-a-local-gemma-folder-tidier-that-can-t-delete-or-overwrite-anything.txt", "jsonld": "https://wpnews.pro/news/lazytidy-a-local-gemma-folder-tidier-that-can-t-delete-or-overwrite-anything.jsonld"}}