{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"build-a-private-document-organizer-in-python-offline-ocr-and-a-rules-file-2zrrq","url":"https://zyvop.com/build-a-private-document-organizer-in-python-offline-ocr-and-a-rules-file-2zrrq","title":"Build a Private Document Organizer in Python: Offline OCR and a Rules File","subtitle":"Rename and file scanned bills, statements and ID cards with Tesseract, a TOML rules file, a dry run and undo. Nothing leaves your machine.","tldr":"Cloud OCR means uploading your ID cards and bills to someone else's server. This post builds a local alternative in Python: offline OCR with Tesseract, rules in a TOML file, duplicate detection, a dry run and undo.","keywords":["ocr","Python","automation","tesseract","privacy","Tutorial"],"entities":["Anshu Pathak","ocr","Python","automation","tesseract","privacy","Tutorial","ZyVOP"],"keyTakeaways":["Your Downloads folder holds scan0007.pdf, IMG_2041.jpg and Download (3).pdf.","One is an electricity bill, one is an ID card, and one is a trip itinerary.","Finding any of them later means opening each file."],"headings":["What we're building","How it works","Set up the project","Decision 1: extraction is a pipeline","Decision 2 and 3: rules are data, and the first match wins","Naming files: dates and fields","Decision 4: classify sensitive documents, don't extract from them","Decision 5: dry run, collisions, duplicates and undo","Using it","Limits worth knowing","The complete code","docsort.py","rules.toml","Where to take it next"],"outboundLinks":["https://github.com/tesseract-ocr/tesseract"],"contentText":"Your Downloads folder holds scan0007.pdf, IMG_2041.jpg and Download (3).pdf. One is an electricity bill, one is an ID card, and one is a trip itinerary. Finding any of them later means opening each file. Online OCR and document services can sort this for you, but they do it by uploading your bills, bank statements and ID cards to someone else's server. For documents like these, keeping everything on your own machine is the safer default. This post builds a small tool that does it locally: it reads each scanned document with offline OCR, matches it against a rules file you write, then renames and files it. It has a dry run, duplicate detection and undo, because a tool that moves your documents should be easy to trust. The complete code is at the end. What we're building You drop files into an Inbox folder. The tool reads the text of each one, finds the first rule that matches, and moves the file into a Library folder with a clean name: scan0007.pdf -&gt; Bills/Electricity/2026/2026-11-03_state-electricity-board-bill.pdfThe design rests on five decisions, and they are the interesting part: Text extraction is a pipeline with fallbacks, not one OCR call. Rules are data in a TOML file, not code. The first matching rule wins, so order is priority. Sensitive documents are classified, never copied into a filename. Nothing is moved until you ask, and everything can be undone. How it works flowchart TD A[\"File in Inbox\"] --&gt; B{\"File type?\"} B -- \"txt or md\" --&gt; T[\"Read the text\"] B -- \"image\" --&gt; O[\"Tesseract OCR\"] B -- \"PDF\" --&gt; P{\"Has a text layer?\"} P -- \"Yes\" --&gt; X[\"pdftotext\"] P -- \"No, it is a scan\" --&gt; R[\"pdftoppm, then Tesseract\"] T --&gt; M[\"Match rules, first match wins\"] O --&gt; M X --&gt; M R --&gt; M M --&gt; N{\"A rule matched?\"} N -- \"Yes\" --&gt; D[\"Build folder and name from the templates\"] N -- \"No\" --&gt; U[\"Library/_Unsorted\"] D --&gt; C{\"Same content already filed?\"} C -- \"Yes\" --&gt; Q[\"Library/_Duplicates\"] C -- \"No\" --&gt; F[\"Move the file, or just print it in a dry run\"]Set up the project Create a folder and a virtual environment: mkdir doc-organizer &amp;&amp; cd doc-organizer python3 -m venv .venv source .venv/bin/activate # Windows PowerShell: .venv\\Scripts\\Activate.ps1 python --version # 3.11 or newerThe script itself uses only the standard library. Python 3.11 added tomllib, which reads the rules file. On an older Python, run pip install tomli and the script picks it up automatically. The OCR is done by two command-line tools. Tesseract is an open-source OCR engine that runs fully offline, and poppler provides pdftotext and pdftoppm: # macOS brew install tesseract poppler # Debian / Ubuntu sudo apt install tesseract-ocr poppler-utilsOn Windows, the easiest route is WSL with the Ubuntu commands above. Native installs work too: install Tesseract and poppler, then add both to your PATH. For Hindi documents, install the language data (sudo apt install tesseract-ocr-hin, or brew install tesseract-lang on macOS), check it with tesseract --list-langs, and set lang = \"eng+hin\" in the rules file. Your folder will look like this: doc-organizer/ .venv/ docsort.py rules.tomlDecision 1: extraction is a pipeline Documents arrive in three shapes: plain text, images (phone photos), and PDFs. PDFs come in two kinds, and telling them apart is the useful trick. A PDF exported from a bank's website has a text layer you can read instantly with pdftotext. A scanned PDF is just pictures of pages, so it needs OCR. The function tries the cheap path first and falls back to OCR only when it has to: def extract_text(path, lang=\"eng\"): \"\"\"Return the document's text, or None for an unsupported file type.\"\"\" ext = path.suffix.lower() if ext in TEXT_EXT: return path.read_text(errors=\"ignore\") if ext in IMAGE_EXT: return ocr_image(path, lang) if ext == \".pdf\": text = \"\" if shutil.which(\"pdftotext\"): text = run([\"pdftotext\", \"-l\", str(MAX_PAGES), \"-layout\", str(path), \"-\"]) if len(text.strip()) &gt;= MIN_TEXT: return text # the PDF already has a text layer: no OCR needed with tempfile.TemporaryDirectory() as tmp: run([\"pdftoppm\", \"-r\", str(OCR_DPI), \"-l\", str(MAX_PAGES), \"-png\", str(path), f\"{tmp}/page\"]) return \"\\n\".join(ocr_image(p, lang) for p in sorted(Path(tmp).glob(\"page*.png\"))) return NoneTwo details keep it fast. MIN_TEXT is the threshold for \"this PDF has no real text layer\", and MAX_PAGES = 3 limits OCR to the first pages, because the header of a document is what identifies it. Reading 40 pages of a statement would only make you wait. Decision 2 and 3: rules are data, and the first match wins Here is the rules file: # docsort rules. The FIRST matching rule wins, so put specific rules above general ones. inbox = \"~/Documents/Inbox\" library = \"~/Documents/Library\" lang = \"eng\" # \"eng+hin\" reads English and Hindi (needs the Hindi language data) [[rule]] name = \"Electricity bill\" any = [\"electricity\", \"units consumed\"] # at least one of these none = [\"gas bill\"] # and none of these folder = \"Bills/Electricity/{year}\" name_template = \"{date}_{provider}-bill\" [rule.fields] provider = \"^(.{3,40}?) Consumer No\" # first capture group becomes {provider} [[rule]] name = \"Bank statement\" all = [\"statement of account\"] # every one of these... any = [\"savings account\", \"account number\"] # ...plus at least one of these folder = \"Finance/Statements/{year}\" name_template = \"{date}_bank-statement\" [[rule]] name = \"Aadhaar card\" any = [\"unique identification authority of india\"] sensitive = true # classify only: no text is copied into the filename folder = \"Identity\" name_template = \"aadhaar-card\" [[rule]] name = \"PAN card\" all = [\"income tax department\", \"permanent account number\"] sensitive = true folder = \"Identity\" name_template = \"pan-card\"A rule matches when: at least one word from any is present (if you gave an any list), every word from all is present, and no word from none is present. def check(rule, text_l): \"\"\"A rule matches if any 'any' word is present, all 'all' words are, and no 'none' word is.\"\"\" any_hits, all_hits = hits(text_l, rule.get(\"any\", [])), hits(text_l, rule.get(\"all\", [])) blocked = hits(text_l, rule.get(\"none\", [])) ok = (bool(any_hits) or not rule.get(\"any\")) and len(all_hits) == len(rule.get(\"all\", [])) and not blocked return ok, any_hits + all_hits, blocked def classify(text, rules): \"\"\"First matching rule wins, so the order of rules in the file is the priority.\"\"\" text_l = normalise(text).lower() results = [(r, *check(r, text_l)) for r in rules] first = next((r for r, ok, _, _ in results if ok), None) return first, resultsMatching is case-insensitive, and the text is first collapsed so that all runs of whitespace and line breaks become single spaces. OCR often breaks a phrase across lines or adds extra spaces, and this step means unique identification authority of india still matches when the scan reads \"Unique Identification\" on one line and \"Authority of India\" on the next. Why first match wins instead of a scoring system? Because you can predict it. When a document lands in the wrong folder you read the file top to bottom and see why. Put specific rules above general ones, and that's the whole model. Choose keywords that survive OCR errors: short, distinctive phrases rather than whole sentences. A longer phrase has more chances for one misread character to break the match. The explain command, shown below, tells you exactly which words hit. Naming files: dates and fields A good filename starts with a date so that a folder sorts chronologically. The tool takes the earliest plausible date in the text: def find_date(text): \"\"\"Earliest plausible date in the text. Numeric dates are read day-first (Indian style).\"\"\" found = [] for m in re.finditer(r\"\\b(\\d{4})-(\\d{2})-(\\d{2})\\b\", text): found.append((m.start(), m.group(1), m.group(2), m.group(3))) for m in re.finditer(r\"\\b(\\d{1,2})[/.-](\\d{1,2})[/.-](\\d{4})\\b\", text): found.append((m.start(), m.group(3), m.group(2), m.group(1))) for m in re.finditer(r\"\\b(\\d{1,2})[ -]([A-Za-z]{3})[a-z]*[ ,.-]+(\\d{4})\\b\", text): found.append((m.start(), m.group(3), MONTHS.get(m.group(2).lower()), m.group(1))) valid = [] for pos, y, mo, d in found: try: day = datetime(int(y), int(mo), int(d)).date() except (TypeError, ValueError): continue if 2000 &lt;= day.year &lt;= 2100: valid.append((pos, day)) return min(valid)[1] if valid else NoneNumeric dates are read day first (03/11/2026 is 3 November), which is the Indian convention. If you work with US-format documents, that assumption is the line to change. \"Earliest date in the text\" is a heuristic. For a bill it is usually the bill date, and for a statement it is the start of the period. If a document needs an exact field, capture it with a regex in the rule's [rule.fields] table. If no date is found, the file's modified time is used. Templates then build the folder and filename. {date}, {year}, {month} and {original} (the old filename) are always available, and every key in [rule.fields] becomes a placeholder too. The first capture group of its regex is turned into a safe lowercase slug. A missing field becomes unknown instead of crashing the run. Decision 4: classify sensitive documents, don't extract from them Look at the two identity rules in the file: they have sensitive = true and templates with no fields. This is deliberate. For an ID card, the tool needs to know only what kind of document it is. It has no reason to read the number off the card and put it in a filename. Filenames leak far more easily than file contents: they show up in cloud sync logs, backup listings, search indexes, shell history and screenshots. A name like pan-card.jpg is useful and harmless; a name with the number in it is a liability. def values_for(rule, text, src): day = None if rule.get(\"sensitive\") else find_date(text) # an ID card's earliest date is usually a birth date from_file = day is None if from_file: day = datetime.fromtimestamp(src.stat().st_mtime).date() vals = Safe(date=day.isoformat(), year=str(day.year), month=f\"{day.month:02d}\", original=slug(src.stem)) if not rule.get(\"sensitive\"): # sensitive documents are classified, never copied into a filename flat = normalise(text) for key, pattern in rule.get(\"fields\", {}).items(): m = re.search(pattern, flat) vals[key] = slug(m.group(1)) if m and m.groups() else \"unknown\" return vals, from_fileThe rest of the tool follows the same rule: explain prints your own rule keywords and a character count, never the document's text. It shows the date it found only for non-sensitive documents. Dates for sensitive rules come from the file's modified time rather than the text, because the earliest date on an ID card is usually a date of birth. The log stores file paths, not contents. Two honest limits. While a scanned PDF is being OCR'd, its page images sit briefly in a temporary folder that is deleted afterwards. And \"local\" does not mean \"encrypted\": turn on full-disk encryption (FileVault, BitLocker or LUKS) for the machine that holds these files. Decision 5: dry run, collisions, duplicates and undo By default, scan only prints a plan. Nothing moves until you add --apply. A tool that renames your documents should earn trust before it gets write access. Three situations need handling when you do apply: Name collision. Two different documents map to the same name, for example two bills on the same date. The second becomes ...-2.pdf. The tool tracks the names it has planned in the current run too, so two files in one batch can't claim the same destination. Duplicate. If the target already holds a file with the same SHA-256 hash, the new file isn't filed again. It goes to _Duplicates, where you can check and delete it. Undo. Every move is appended to .docsort-log.jsonl in the library, tagged with a run id. undo reverses the most recent run only. The run id includes microseconds: with one-second resolution, two quick runs could share an id and one undo would reverse both. Rules can also go wrong. A folder template that resolves outside the library (for example one containing ..) is rejected, and that file is skipped with a message. Using it Put some files in the inbox and look at the plan. The sample documents below were generated with placeholder values (no real IDs): a photo, a scan, a text PDF and a duplicate. python docsort.py scanDownload (3).pdf -&gt; _Unsorted/Download (3).pdf [no rule matched] IMG_2041.jpg -&gt; Identity/pan-card.jpg [PAN card] IMG_2057.png -&gt; Identity/aadhaar-card.png [Aadhaar card] Statement_Oct.pdf -&gt; Finance/Statements/2026/2026-10-01_bank-statement.pdf [Bank statement] scan0007 (1).pdf -&gt; Bills/Electricity/2026/2026-11-03_state-electricity-board-bill.pdf [Electricity bill] scan0007.pdf -&gt; _Duplicates/scan0007.pdf [Electricity bill] duplicate of scan0007 (1).pdf in this batch Dry run: 6 file(s) would move. Nothing was changed. Re-run with --apply.If the plan looks right, apply it: python docsort.py scan --applyThe library now looks like this: Bills/Electricity/2026/2026-11-03_state-electricity-board-bill.pdf Finance/Statements/2026/2026-10-01_bank-statement.pdf Identity/aadhaar-card.png Identity/pan-card.jpg _Duplicates/scan0007.pdf _Unsorted/Download (3).pdfNotice that both identity documents were filed without any number in the name, the duplicate scan went to _Duplicates, and the itinerary that matched no rule is waiting in _Unsorted. That folder is your to-do list for new rules. When a file lands in the wrong place, ask why: python docsort.py explain scan0007.pdf105 characters of text. First date found: 2026-11-03 MATCH Electricity bill hit: ['electricity', 'units consumed'] no Bank statement no Aadhaar card no PAN card Winner: Electricity billAnd if you don't like what happened, undo the last run: python docsort.py undorestored scan0007.pdf restored scan0007 (1).pdf restored Statement_Oct.pdf restored IMG_2057.png restored IMG_2041.jpg restored Download (3).pdfA password-protected PDF can't be read, so it's skipped with the reason instead of crashing the run: SKIP encrypted.pdf: pdftotext failed: Command Line Error: Incorrect passwordLimits worth knowing OCR is not perfect. Blurry photos, handwriting and unusual fonts will fail to match, and those files end up in _Unsorted. Rules match on keywords, so a document that mentions \"electricity\" in passing could match the wrong rule. Use none and all to tighten it, and keep specific rules first. Only the first three pages are read. Numeric dates are day first. The complete code Save these two files in your project folder. docsort.py is about 300 lines. docsort.py #!/usr/bin/env python3 \"\"\" docsort - file scanned documents using offline OCR and a TOML rules file. python docsort.py scan # dry run: show what would happen python docsort.py scan --apply # actually move the files python docsort.py undo # reverse the last --apply run python docsort.py explain some.pdf # show why a file matched (or didn't) Needs Python 3.11+ (or `pip install tomli`). Scanned images and PDFs also need the `tesseract` and `pdftoppm`/`pdftotext` (poppler) command-line tools. Nothing is uploaded. \"\"\" import argparse import hashlib import json import re import shutil import subprocess import sys import tempfile from datetime import datetime from pathlib import Path try: import tomllib except ImportError: # Python &lt; 3.11: pip install tomli import tomli as tomllib IMAGE_EXT = {\".png\", \".jpg\", \".jpeg\", \".tif\", \".tiff\", \".bmp\"} TEXT_EXT = {\".txt\", \".md\"} OCR_DPI = 300 # higher is more accurate and slower MAX_PAGES = 3 # the first pages identify a document; no need to read 40 of them MIN_TEXT = 40 # a PDF yielding fewer characters than this is a scan and needs OCR LOG_NAME = \".docsort-log.jsonl\" MONTHS = {m: i for i, m in enumerate(\"jan feb mar apr may jun jul aug sep oct nov dec\".split(), 1)} class ToolMissing(RuntimeError): pass # ------------------------------------------------------------ text extraction def run(cmd): if not shutil.which(cmd[0]): raise ToolMissing(f\"'{cmd[0]}' is not installed or not on PATH (see the setup notes).\") done = subprocess.run(cmd, capture_output=True, text=True) if done.returncode: raise OSError(f\"{cmd[0]} failed: {done.stderr.strip() or 'unknown error'}\") return done.stdout def ocr_image(path, lang): return run([\"tesseract\", str(path), \"stdout\", \"-l\", lang]) def extract_text(path, lang=\"eng\"): \"\"\"Return the document's text, or None for an unsupported file type.\"\"\" ext = path.suffix.lower() if ext in TEXT_EXT: return path.read_text(errors=\"ignore\") if ext in IMAGE_EXT: return ocr_image(path, lang) if ext == \".pdf\": text = \"\" if shutil.which(\"pdftotext\"): text = run([\"pdftotext\", \"-l\", str(MAX_PAGES), \"-layout\", str(path), \"-\"]) if len(text.strip()) &gt;= MIN_TEXT: return text # the PDF already has a text layer: no OCR needed with tempfile.TemporaryDirectory() as tmp: run([\"pdftoppm\", \"-r\", str(OCR_DPI), \"-l\", str(MAX_PAGES), \"-png\", str(path), f\"{tmp}/page\"]) return \"\\n\".join(ocr_image(p, lang) for p in sorted(Path(tmp).glob(\"page*.png\"))) return None # ------------------------------------------------------------ dates and rules def find_date(text): \"\"\"Earliest plausible date in the text. Numeric dates are read day-first (Indian style).\"\"\" found = [] for m in re.finditer(r\"\\b(\\d{4})-(\\d{2})-(\\d{2})\\b\", text): found.append((m.start(), m.group(1), m.group(2), m.group(3))) for m in re.finditer(r\"\\b(\\d{1,2})[/.-](\\d{1,2})[/.-](\\d{4})\\b\", text): found.append((m.start(), m.group(3), m.group(2), m.group(1))) for m in re.finditer(r\"\\b(\\d{1,2})[ -]([A-Za-z]{3})[a-z]*[ ,.-]+(\\d{4})\\b\", text): found.append((m.start(), m.group(3), MONTHS.get(m.group(2).lower()), m.group(1))) valid = [] for pos, y, mo, d in found: try: day = datetime(int(y), int(mo), int(d)).date() except (TypeError, ValueError): continue if 2000 &lt;= day.year &lt;= 2100: valid.append((pos, day)) return min(valid)[1] if valid else None def normalise(text): return re.sub(r\"\\s+\", \" \", text).strip() def hits(text_l, words): return [w for w in words if w.lower() in text_l] def check(rule, text_l): \"\"\"A rule matches if any 'any' word is present, all 'all' words are, and no 'none' word is.\"\"\" any_hits, all_hits = hits(text_l, rule.get(\"any\", [])), hits(text_l, rule.get(\"all\", [])) blocked = hits(text_l, rule.get(\"none\", [])) ok = (bool(any_hits) or not rule.get(\"any\")) and len(all_hits) == len(rule.get(\"all\", [])) and not blocked return ok, any_hits + all_hits, blocked def classify(text, rules): \"\"\"First matching rule wins, so the order of rules in the file is the priority.\"\"\" text_l = normalise(text).lower() results = [(r, *check(r, text_l)) for r in rules] first = next((r for r, ok, _, _ in results if ok), None) return first, results def load_config(path): path = Path(path).expanduser() if not path.exists(): sys.exit(f\"Config file not found: {path}\") cfg = tomllib.loads(path.read_text(encoding=\"utf-8\")) for key in (\"inbox\", \"library\"): if key not in cfg: sys.exit(f\"{path}: missing '{key}'\") rules = cfg.get(\"rule\", []) for r in rules: if not (r.get(\"name\") and r.get(\"folder\") and (r.get(\"any\") or r.get(\"all\"))): sys.exit(f\"Rule {r.get('name', '?')!r} needs name, folder and at least one of any / all.\") return {\"inbox\": Path(cfg[\"inbox\"]).expanduser(), \"library\": Path(cfg[\"library\"]).expanduser(), \"lang\": cfg.get(\"lang\", \"eng\"), \"rules\": rules} # ------------------------------------------------------------ naming and planning class Safe(dict): def __missing__(self, key): return \"unknown\" def slug(s, limit=40): return re.sub(r\"[^a-z0-9]+\", \"-\", s.lower()).strip(\"-\")[:limit] or \"unknown\" def values_for(rule, text, src): day = None if rule.get(\"sensitive\") else find_date(text) # an ID card's earliest date is usually a birth date from_file = day is None if from_file: day = datetime.fromtimestamp(src.stat().st_mtime).date() vals = Safe(date=day.isoformat(), year=str(day.year), month=f\"{day.month:02d}\", original=slug(src.stem)) if not rule.get(\"sensitive\"): # sensitive documents are classified, never copied into a filename flat = normalise(text) for key, pattern in rule.get(\"fields\", {}).items(): m = re.search(pattern, flat) vals[key] = slug(m.group(1)) if m and m.groups() else \"unknown\" return vals, from_file def sha256(path): h = hashlib.sha256() with open(path, \"rb\") as f: for chunk in iter(lambda: f.read(1 &lt;&lt; 20), b\"\"): h.update(chunk) return h.hexdigest() def unique(dest, taken): cand, n = dest, 2 while cand.exists() or cand in taken: cand = dest.with_name(f\"{dest.stem}-{n}{dest.suffix}\") n += 1 return cand def plan_one(src, cfg, taken): \"\"\"Return (destination, rule name, note), or None for an unsupported file type.\"\"\" text = extract_text(src, cfg[\"lang\"]) if text is None: return None lib = cfg[\"library\"] rule, _ = classify(text, cfg[\"rules\"]) if rule is None: dest, name, note = lib / \"_Unsorted\" / src.name, \"no rule matched\", \"\" else: vals, from_file = values_for(rule, text, src) template = rule.get(\"name_template\", \"{date}_{original}\") stem = template.format_map(vals) dest = lib / rule[\"folder\"].format_map(vals) / (stem + src.suffix.lower()) try: dest.resolve().relative_to(lib.resolve()) except ValueError: raise ValueError(f\"rule {rule['name']!r} would write outside the library; \" \"check its folder and name_template\") from None uses_date = any(k in template + rule[\"folder\"] for k in (\"{date}\", \"{year}\", \"{month}\")) name, note = rule[\"name\"], \"date taken from file time\" if from_file and uses_date else \"\" existing = dest if dest.exists() else taken.get(dest) if existing and sha256(existing) == sha256(src): where = f\"the library file {dest.name}\" if existing == dest else f\"{existing.name} in this batch\" dest, note = lib / \"_Duplicates\" / src.name, f\"duplicate of {where}\" dest = unique(dest, taken) taken[dest] = src return dest, name, note # ------------------------------------------------------------ commands def cmd_scan(args, cfg): files = sorted(p for p in cfg[\"inbox\"].rglob(\"*\") if p.is_file() and not p.name.startswith(\".\")) if not files: print(\"Inbox is empty.\") return 0 taken, moves = {}, [] for src in files: try: result = plan_one(src, cfg, taken) except (ValueError, OSError) as err: print(f\"SKIP {src.name}: {err}\") continue if result is None: print(f\"SKIP {src.name}: unsupported file type\") continue dest, rule, note = result moves.append((src, dest)) print(f\"{src.name}\\n -&gt; {dest.relative_to(cfg['library'])} [{rule}]\" + (f\" {note}\" if note else \"\")) if not args.apply: print(f\"\\nDry run: {len(moves)} file(s) would move. Nothing was changed. Re-run with --apply.\") return 0 run_id = datetime.now().strftime(\"%Y%m%dT%H%M%S%f\") # microseconds: two runs never share an id cfg[\"library\"].mkdir(parents=True, exist_ok=True) with open(cfg[\"library\"] / LOG_NAME, \"a\", encoding=\"utf-8\") as log: for src, dest in moves: dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(dest)) log.write(json.dumps({\"run\": run_id, \"from\": str(src), \"to\": str(dest)}) + \"\\n\") log.flush() print(f\"\\nMoved {len(moves)} file(s). Undo with: python docsort.py undo\") return 0 def cmd_undo(args, cfg): log = cfg[\"library\"] / LOG_NAME entries = [json.loads(line) for line in log.read_text(encoding=\"utf-8\").splitlines() if line.strip()] \\ if log.exists() else [] if not entries: print(\"Nothing to undo.\") return 0 last = entries[-1][\"run\"] for e in reversed([e for e in entries if e[\"run\"] == last]): src, dest = Path(e[\"from\"]), Path(e[\"to\"]) if dest.exists() and not src.exists(): src.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(dest), str(src)) print(f\"restored {src.name}\") else: print(f\"left alone (missing, or the original spot is taken): {dest}\") keep = [e for e in entries if e[\"run\"] != last] log.write_text(\"\".join(json.dumps(e) + \"\\n\" for e in keep), encoding=\"utf-8\") return 0 def cmd_explain(args, cfg): src = Path(args.file).expanduser() text = extract_text(src, cfg[\"lang\"]) if text is None: sys.exit(\"Unsupported file type.\") first, results = classify(text, cfg[\"rules\"]) shown = find_date(text) or \"none\" if first and not first.get(\"sensitive\") else \"not shown\" print(f\"{len(text.strip())} characters of text. First date found: {shown}\") for rule, ok, found, blocked in results: # prints rule keywords only, never the document's text line = f\" {'MATCH' if ok else 'no':&lt;6}{rule['name']}\" line += f\" hit: {found}\" if found else \"\" line += f\" blocked by: {blocked}\" if blocked else \"\" print(line) print(f\"\\nWinner: {first['name'] if first else 'none (the file would go to _Unsorted)'}\") return 0 def main(): common = argparse.ArgumentParser(add_help=False) common.add_argument(\"--config\", default=\"rules.toml\", help=\"rules file (default: ./rules.toml)\") p = argparse.ArgumentParser(prog=\"docsort\", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = p.add_subparsers(dest=\"cmd\", required=True) s = sub.add_parser(\"scan\", parents=[common], help=\"plan (or perform) moves for everything in the inbox\") s.add_argument(\"--apply\", action=\"store_true\", help=\"really move the files\") s.set_defaults(fn=cmd_scan) sub.add_parser(\"undo\", parents=[common], help=\"reverse the last --apply run\").set_defaults(fn=cmd_undo) e = sub.add_parser(\"explain\", parents=[common], help=\"show which rules match one file\") e.add_argument(\"file\") e.set_defaults(fn=cmd_explain) args = p.parse_args() try: return args.fn(args, load_config(args.config)) except ToolMissing as err: print(f\"Error: {err}\", file=sys.stderr) return 1 if __name__ == \"__main__\": sys.exit(main())rules.toml Change inbox and library to your own folders. Keep the rules order in mind: specific first. # docsort rules. The FIRST matching rule wins, so put specific rules above general ones. inbox = \"~/Documents/Inbox\" library = \"~/Documents/Library\" lang = \"eng\" # \"eng+hin\" reads English and Hindi (needs the Hindi language data) [[rule]] name = \"Electricity bill\" any = [\"electricity\", \"units consumed\"] # at least one of these none = [\"gas bill\"] # and none of these folder = \"Bills/Electricity/{year}\" name_template = \"{date}_{provider}-bill\" [rule.fields] provider = \"^(.{3,40}?) Consumer No\" # first capture group becomes {provider} [[rule]] name = \"Bank statement\" all = [\"statement of account\"] # every one of these... any = [\"savings account\", \"account number\"] # ...plus at least one of these folder = \"Finance/Statements/{year}\" name_template = \"{date}_bank-statement\" [[rule]] name = \"Aadhaar card\" any = [\"unique identification authority of india\"] sensitive = true # classify only: no text is copied into the filename folder = \"Identity\" name_template = \"aadhaar-card\" [[rule]] name = \"PAN card\" all = [\"income tax department\", \"permanent account number\"] sensitive = true folder = \"Identity\" name_template = \"pan-card\"Where to take it next Run scan --apply on a schedule with cron or a systemd timer, so new scans are filed on their own. Check the first runs by hand and use dry runs until you trust your rules. Write the fields you extract (amounts, provider) into a CSV or SQLite table. Add full-text search over the OCR text with SQLite's FTS5, so you can find \"the bill from March\" without remembering where it went. Add a rule-test mode: a folder of sample documents with the folder each one should end up in, run whenever you edit the rules. Everything here runs on your own machine, and the only thing the tool ever leaves behind is a log of file paths.","contentHash":"sha256:017299b4d5b10c618c201a484bd29f84943f4ea57ebd7a62d9dd79113502c442","authorName":"Anshu Pathak","authorUrl":"https://zyvop.com/author/anshu","authorSameAs":["https://www.linkedin.com/in/anshu-pathak-zyvop/"],"category":"Tutorial","tags":["ocr","Python","automation","tesseract","privacy"],"audience":"Software engineers and developers building applications with Tutorial","tone":"Instructional, practical, code-first","readingTimeMinutes":17,"wordCount":4243,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-11-04T03:03:58.198Z","updatedAt":"2026-11-04T03:03:58.198Z","canonicalUrl":"https://zyvop.com/build-a-private-document-organizer-in-python-offline-ocr-and-a-rules-file-2zrrq"}