
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 -> 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"] --> B{"File type?"}
B -- "txt or md" --> T["Read the text"]
B -- "image" --> O["Tesseract OCR"]
B -- "PDF" --> P{"Has a text layer?"}
P -- "Yes" --> X["pdftotext"]
P -- "No, it is a scan" --> R["pdftoppm, then Tesseract"]
T --> M["Match rules, first match wins"]
O --> M
X --> M
R --> M
M --> N{"A rule matched?"}
N -- "Yes" --> D["Build folder and name from the templates"]
N -- "No" --> U["Library/_Unsorted"]
D --> C{"Same content already filed?"}
C -- "Yes" --> Q["Library/_Duplicates"]
C -- "No" --> 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 && 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()) >= 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
anyis present (if you gave ananylist),every word from
allis present, andno word from
noneis 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 <= day.year <= 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:
explainprints 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.jsonlin the library, tagged with a run id.undoreverses 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
-> _Unsorted/Download (3).pdf [no rule matched]
IMG_2041.jpg
-> Identity/pan-card.jpg [PAN card]
IMG_2057.png
-> Identity/aadhaar-card.png [Aadhaar card]
Statement_Oct.pdf
-> Finance/Statements/2026/2026-10-01_bank-statement.pdf [Bank statement]
scan0007 (1).pdf
-> Bills/Electricity/2026/2026-11-03_state-electricity-board-bill.pdf [Electricity bill]
scan0007.pdf
-> _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
noneandallto 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 < 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()) >= 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 <= day.year <= 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 << 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 -> {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':<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 --applyon 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.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!