pdfnamer is a CLI tool that renames and organizes PDF bills and statements by automatically extracting the company name, date, and document type from the PDF content. Using a config file you maintain, it matches each PDF to a known company via filename patterns or keyword scanning, extracts the statement date, and moves the file to a structured output path — keeping your downloads folder clean with zero manual effort.
npm install -g @frankledo/pdfnamer
Requires Node.js 22.13 or later. No other system dependencies are needed.
1. Install
npm install -g @frankledo/pdfnamer
2. Generate a starter config
pdfnamer --init
This writes ~/.config/pdfnamer/config.json with an annotated example you can edit.
3. Run on a folder of PDFs
pdfnamer ~/Downloads
Use --dry-run to preview renames without moving any files:
pdfnamer --dry-run ~/Downloads
The config file lives at ~/.config/pdfnamer/config.json by default. Pass --config <path> to use a different location.
{
"output_path": "~/Documents/Bills/{year}/{company}/{date} - {company}[ - {account}][ - {doctype}]",
"date_format": "YYYY-MM-DD",
"date_labels": ["Statement Date:", "Bill Date:", "Invoice Date:", "Closing Date:"],
"companies": [
{
"name": "Riverside Electric",
"filename_patterns": ["riverside_bill_*", "RiversideElectric_*"],
"keywords": ["Riverside Electric Company", "riverside-electric.example.com"],
"noAccountNumber": true,
"subjects": [
{ "match": "Electric Bill", "alias": "Bill" }
]
},
{
"name": "Oakwood Bank - Sapphire Visa - 1234",
"keywords": ["Sapphire Visa", "oakwoodbank.example.com"],
"keyword_search_chars": 8000,
"noAccountNumber": true,
"date_labels": ["Closing Date:", "Statement Date:"],
"subjects": [
{ "match": "Statement", "alias": "Statement" }
]
},
{
"name": "Oakwood Bank - Rewards Visa - 5678",
"keywords": ["Rewards Visa", "oakwoodbank.example.com"],
"keyword_search_chars": 8000,
"noAccountNumber": true,
"date_labels": ["Closing Date:", "Statement Date:"],
"subjects": [
{ "match": "Statement", "alias": "Statement" }
]
},
{
"name": "Oakwood Bank",
"keywords": ["oakwoodbank.example.com", "Oakwood Bank"],
"accounts": [
{ "number": "9012", "alias": "Checking" }
],
"subjects": [
{ "match": "Account Statement", "alias": "Statement" }
]
}
]
}| Field | Default | Description |
|---|---|---|
output_path |
required | Template for the output file path. See tokens below. |
date_format |
YYYY-MM-DD |
Format for {date} in the output path. |
date_labels |
["Statement Date:", ...] |
Labels searched in every PDF when extracting the statement date. Can be overridden per company. |
rename_in_place |
false |
When true, rename files in their current directory instead of moving them to output_path. The filename format is still controlled by the basename of output_path. Useful for receipts folders you want to keep in place but make searchable. |
unmatched_prefix |
false |
When true, files that don't match any company entry are still renamed: a date prefix ({date} - ) is prepended to the original filename. Files that can't yield any date, or whose name already starts with a date, are left unchanged. Pairs well with rename_in_place. |
| Token | Description |
|---|---|
{year} |
Four-digit year extracted from the statement date |
{month} |
Two-digit month (01–12) |
{date} |
Full date formatted per date_format |
{company} |
Company name from the matched config entry |
{account} |
Account alias from a matched accounts entry |
{doctype} |
Document type alias from a matched subjects entry |
Wrap a token and its surrounding delimiter in [...] to make the whole segment optional — it is omitted entirely when the token is empty:
{date} - {company}[ - {account}][ - {doctype}]
{date}_{company}[_{account}][_{doctype}]
{year}/{company}/{date} - {company}[/{doctype}]
Companies that have no accounts entry will silently skip the [ - {account}] block; companies that do will include it. This works with any delimiter style.
| Field | Description |
|---|---|
name |
Display name used in the output path. |
output_path |
Override the global output_path for this company only. |
filename_patterns |
Glob patterns checked against the PDF filename (case-insensitive, supports *). Matched before keywords — no PDF parsing needed. |
keywords |
Strings to scan for in the PDF text when no filename pattern matches. |
keyword_search_chars |
How many characters from the start of the PDF text to search for keywords. Default 2000. Increase for documents where card-specific branding appears later in the file. |
date_labels |
Override the global date_labels for this company. Useful when a company uses non-standard date field names. |
accounts |
Array of { number, alias } objects. When a number is found in the PDF text the corresponding alias is used as {account}. |
subjects |
Array of { match, alias } objects. When match is found in the PDF text the alias is used as {doctype}. First match wins. |
noAccountNumber |
Set true to suppress account matching entirely (omits {account} token). |
| Flag | Description |
|---|---|
-n, --dry-run |
Preview renames without moving any files |
-c, --config <path> |
Use a custom config file instead of ~/.config/pdfnamer/config.json |
--init |
Write a starter config to ~/.config/pdfnamer/config.json and exit |
--install-quickaction |
Install a Finder Quick Action (macOS) so you can right-click PDFs to rename them. See Quick Action (macOS) for full setup steps. |
--install-watcher [dir] |
Install a launchd agent (macOS) that auto-files a folder whenever it changes. Defaults to ~/Downloads. See Folder watcher (macOS). |
--uninstall-watcher |
Remove the folder watcher (macOS). |
-h, --help |
Show usage information |
pdfnamer processes each PDF in two phases. Company order matters: the first matching entry wins. Place specific entries before catch-alls.
Company identification
- Check each company's
filename_patternsagainst the PDF filename (fast, no PDF parsing needed). First match wins. - If no pattern matches, extract the PDF text and scan for each company's
keywordsin order. First match wins. - If still no match, report
UNMATCHEDwith a text snippet to help you add a new entry.
Ordering example: Two credit cards from the same bank share a generic bank keyword. Put the card-specific entries first; the generic bank entry acts as a catch-all:
{ "name": "Oakwood Bank - Sapphire Visa", "keywords": ["Sapphire Visa", "oakwoodbank.example.com"], "keyword_search_chars": 8000 },
{ "name": "Oakwood Bank - Rewards Visa", "keywords": ["Rewards Visa", "oakwoodbank.example.com"], "keyword_search_chars": 8000 },
{ "name": "Oakwood Bank", "keywords": ["oakwoodbank.example.com", "Oakwood Bank"] }The catch-all only matches files that didn't already match a card-specific entry. Similarly, if two companies share a keyword, the one listed first always wins.
Date extraction
Dates are extracted in this order — the first successful result is used:
- Search for each label in
date_labels(case-insensitive). When found, parse the date in the following 60 characters. - Detect a date range anywhere in the document (
MM/DD/YYYY - MM/DD/YYYY,MM/DD/YYYY to MM/DD/YYYY,MM/DD/YY-MM/DD/YY) and use the end date. - Fall back to the first parseable date in the document header (first 2000 characters).
- Fall back to a date embedded in the filename itself.
Recognized date formats
YYYY-MM-DD(ISO)Month DD, YYYYandMonth DD , YYYY(e.g.January 1 , 2026)MM/DD/YYYYandMM-DD-YYYYMM/DD/YYandMM-DD-YYYYYYMMDDHHmmssembedded in filenames (e.g. utility download filenames)- Bare
YYYY(tax year — mapped to December 31 of that year)
pdfnamer can install a Finder Quick Action so you can right-click any PDF and rename it without opening a terminal.
1. Install the Quick Action
pdfnamer --install-quickaction
This copies a Workflow file into ~/Library/Services/.
2. Enable it in System Settings
Go to System Settings → Privacy & Security → Extensions → Finder Extensions and check the box next to pdfnamer.
3. Use it
Right-click any PDF in Finder → Quick Actions → pdfnamer.
Note: The Quick Action uses your default config at
~/.config/pdfnamer/config.json. Runpdfnamer --initfirst if you haven't set one up yet.
pdfnamer can install a launchd agent that automatically files a folder every time its contents change — drop a statement in the folder and it's renamed and moved without running anything by hand.
Install
pdfnamer --install-watcher ~/Downloads
The folder argument is optional and defaults to ~/Downloads. This writes a
LaunchAgent to ~/Library/LaunchAgents/com.pdfnamer.watcher.plist, loads it, and
logs activity to ~/Library/Logs/pdfnamer.log. Matched PDFs are renamed and moved
automatically (no preview); unmatched files are left in place.
Uninstall
pdfnamer --uninstall-watcher
One folder at a time: installing again with a different folder repoints the single watcher rather than adding a second one.
Pin a stable Node: the agent records the absolute path of the Node that ran the install. If that's a version-managed Node (nvm, fnm, volta, n), the path can disappear when you switch or remove versions, silently stopping the watcher.
--install-watcherwarns when it detects this — install pdfnamer under a stable Node (e.g. Homebrew) for a watcher that keeps working.
Note: The watcher uses your default config at
~/.config/pdfnamer/config.json.
Bug reports and pull requests are welcome on GitHub.
MIT — see LICENSE.