updated script for reorganizing folders to handle the weird nesting that FCCS does
This commit is contained in:
13
README.md
13
README.md
@@ -57,17 +57,22 @@ Parses the flat exported filenames and copies them into an organized structure:
|
||||
```
|
||||
output/
|
||||
01069_ABRAHAM, REBEKAH L./
|
||||
2025 Tax Documents/
|
||||
Tax Documents/
|
||||
2025/
|
||||
01069_ABRAHAM, REBEKAH L._2025 Tax Documents_03-03-2026_030126 E-mail re Tax Info.pdf
|
||||
Billing & Invoices/
|
||||
2026/
|
||||
01069_ABRAHAM, REBEKAH L._2026 Billing & Invoices_030826 Invoice for 2025 Forms 1040...pdf
|
||||
UltraTax CS/
|
||||
12-31-2008/
|
||||
01069_ABRAHAM, REBEKAH L._UltraTax CS 12-31-2008_02-12-2009_2008 Form 1040 Filing Instructions.doc
|
||||
Permanent File/
|
||||
01069_ABRAHAM, REBEKAH L._Permanent File_02-24-2018_Driver's License.pdf
|
||||
UltraTax CS 12-31-2008/
|
||||
01069_ABRAHAM, REBEKAH L._UltraTax CS 12-31-2008_02-12-2009_2008 Form 1040 Filing Instructions.doc
|
||||
_unparsed/
|
||||
(files that couldn't be parsed go here for manual review)
|
||||
```
|
||||
|
||||
Exported filenames follow the format `{drawer_id}_{client_name}_{folder_name}_{creation_date}_{document_name}.ext`. The parser uses folder templates from `fccs_folders.txt` (with `YYYY` expanded via regex) and the creation date (`MM-DD-YYYY`) as anchors to reliably split the underscore-delimited fields. UltraTax CS folders are matched by a built-in pattern.
|
||||
Exported filenames follow the format `{drawer_id}_{client_name}_{folder_name}_{creation_date}_{document_name}.ext`. The parser uses folder templates from `fccs_folders.txt` (with `YYYY` expanded via regex) and the creation date (`MM-DD-YYYY`) as anchors to reliably split the underscore-delimited fields. Folder names are decomposed into nested paths that match the FCCS UI structure (e.g. `2025 Tax Documents` becomes `Tax Documents/2025/`). UltraTax CS folders are matched by a built-in pattern.
|
||||
|
||||
## Config Reference
|
||||
|
||||
|
||||
@@ -71,32 +71,66 @@ def build_folder_patterns(templates):
|
||||
Non-recurring folders (no YYYY) are matched literally.
|
||||
A built-in pattern for 'UltraTax CS MM-DD-YYYY' is always included.
|
||||
|
||||
Returns a list of (compiled_regex, template_name) tuples, sorted
|
||||
longest-first to prevent partial matches.
|
||||
Returns a list of (compiled_regex, template_name, folder_type) tuples,
|
||||
sorted longest-first to prevent partial matches.
|
||||
|
||||
folder_type is one of:
|
||||
"yyyy" — recurring folder with year prefix (captured in group 1)
|
||||
"ultratax" — UltraTax CS folder with date suffix (captured in group 1)
|
||||
"static" — non-recurring folder, no decomposition needed
|
||||
"""
|
||||
patterns = []
|
||||
|
||||
# Built-in: UltraTax CS folders (auto-generated by UltraTax integration)
|
||||
patterns.append((
|
||||
re.compile(r"UltraTax CS \d{2}-\d{2}-\d{4}$"),
|
||||
re.compile(r"UltraTax CS (\d{2}-\d{2}-\d{4})$"),
|
||||
"UltraTax CS",
|
||||
"ultratax",
|
||||
))
|
||||
|
||||
for tmpl in templates:
|
||||
if "YYYY" in tmpl:
|
||||
# Replace YYYY with 4-digit year pattern, escape the rest
|
||||
# Replace YYYY with captured 4-digit year pattern, escape the rest
|
||||
parts = tmpl.split("YYYY")
|
||||
regex_str = re.escape(parts[0]) + r"\d{4}" + re.escape(parts[1])
|
||||
regex_str = re.escape(parts[0]) + r"(\d{4})" + re.escape(parts[1])
|
||||
patterns.append((re.compile(regex_str + "$"), tmpl, "yyyy"))
|
||||
else:
|
||||
# Non-recurring folder — exact match
|
||||
regex_str = re.escape(tmpl)
|
||||
patterns.append((re.compile(regex_str + "$"), tmpl))
|
||||
patterns.append((re.compile(regex_str + "$"), tmpl, "static"))
|
||||
|
||||
# Sort by regex pattern length (longest first) to avoid partial matches
|
||||
patterns.sort(key=lambda p: len(p[0].pattern), reverse=True)
|
||||
return patterns
|
||||
|
||||
|
||||
def decompose_folder_path(match, folder_type, template_name):
|
||||
"""
|
||||
Decompose a matched folder name into nested path components that
|
||||
recreate the FCCS UI folder structure.
|
||||
|
||||
Returns a tuple of path parts to be joined with os.path.join().
|
||||
|
||||
Examples:
|
||||
yyyy: "YYYY Tax Documents" matched "2025 Tax Documents"
|
||||
→ ("Tax Documents", "2025")
|
||||
ultratax: "UltraTax CS 12-31-2008"
|
||||
→ ("UltraTax CS", "12-31-2008")
|
||||
static: "Permanent File"
|
||||
→ ("Permanent File",)
|
||||
"""
|
||||
if folder_type == "yyyy":
|
||||
year = match.group(1)
|
||||
# Strip "YYYY " from template to get the base folder name
|
||||
base_name = template_name.replace("YYYY", "").strip()
|
||||
return (base_name, year)
|
||||
elif folder_type == "ultratax":
|
||||
date = match.group(1)
|
||||
return ("UltraTax CS", date)
|
||||
else:
|
||||
return (template_name,)
|
||||
|
||||
|
||||
def load_lines(path):
|
||||
"""Read non-blank, non-comment lines from a file."""
|
||||
if not os.path.exists(path):
|
||||
|
||||
@@ -12,8 +12,10 @@ Examples:
|
||||
01069_ABRAHAM, REBEKAH L._Permanent File_02-24-2018_Driver's License.pdf
|
||||
01069_ABRAHAM, REBEKAH L._UltraTax CS 12-31-2008_02-12-2009_2008 Form 1040 Filing Instructions.doc
|
||||
|
||||
Output structure:
|
||||
output/{drawer_id}_{client_name}/{folder_name}/{original_filename}
|
||||
Output structure (recreates FCCS UI nesting):
|
||||
output/{drawer_id}_{client_name}/Tax Documents/2025/{original_filename}
|
||||
output/{drawer_id}_{client_name}/UltraTax CS/12-31-2008/{original_filename}
|
||||
output/{drawer_id}_{client_name}/Permanent File/{original_filename}
|
||||
|
||||
Files that cannot be parsed go to output/_unparsed/ for manual review.
|
||||
"""
|
||||
@@ -25,7 +27,7 @@ import sys
|
||||
|
||||
from fccs_config import (
|
||||
parse_args, load_config, make_logger,
|
||||
load_folder_list, build_folder_patterns,
|
||||
load_folder_list, build_folder_patterns, decompose_folder_path,
|
||||
)
|
||||
|
||||
# Regex for the creation date field (MM-DD-YYYY) bounded by underscores
|
||||
@@ -34,18 +36,22 @@ _DATE_RE = re.compile(r"_(\d{2}-\d{2}-\d{4})_")
|
||||
|
||||
def parse_filename(filename, folder_patterns):
|
||||
"""
|
||||
Parse an FCCS export filename into (drawer_id, client_name, folder_name,
|
||||
date, doc_name_with_ext) or return None if it cannot be parsed.
|
||||
Parse an FCCS export filename into its components or return None.
|
||||
|
||||
folder_patterns is a list of (compiled_regex, template_name) from
|
||||
build_folder_patterns(), sorted longest-first.
|
||||
Returns (drawer_id, client_name, folder_parts, date, doc_name_with_ext)
|
||||
where folder_parts is a tuple of nested path components, e.g.:
|
||||
("Tax Documents", "2025") or ("UltraTax CS", "12-31-2008") or ("Permanent File",)
|
||||
|
||||
folder_patterns is a list of (compiled_regex, template_name, folder_type)
|
||||
from build_folder_patterns(), sorted longest-first.
|
||||
|
||||
Strategy:
|
||||
1. Drawer ID: first token before first underscore
|
||||
2. Creation date: find MM-DD-YYYY pattern as anchor
|
||||
3. Folder name: regex-match a known folder template at the end of
|
||||
the text between client_name and creation_date
|
||||
4. Client name: whatever is left between drawer_id and folder_name
|
||||
4. Decompose the matched folder into nested path components
|
||||
5. Client name: whatever is left between drawer_id and folder_name
|
||||
"""
|
||||
stem, ext = os.path.splitext(filename)
|
||||
|
||||
@@ -67,22 +73,22 @@ def parse_filename(filename, folder_patterns):
|
||||
for m in date_matches:
|
||||
date_str = m.group(1)
|
||||
# Calculate positions relative to `rest` (adjust for prepended _)
|
||||
# m.start() is the position of the leading _ in search_str
|
||||
# In `rest`, the content before the date ends at (m.start() - 1)
|
||||
before_date = rest[: m.start() - 1]
|
||||
after_date_pos = m.start() - 1 + len("_") + len(date_str) + len("_")
|
||||
doc_name = rest[after_date_pos:]
|
||||
|
||||
# Try each folder pattern against the end of before_date
|
||||
for pattern, template_name in folder_patterns:
|
||||
match = pattern.search(before_date)
|
||||
if match and match.end() == len(before_date):
|
||||
for pattern, template_name, folder_type in folder_patterns:
|
||||
folder_match = pattern.search(before_date)
|
||||
if folder_match and folder_match.end() == len(before_date):
|
||||
# Folder matched at the end — check for underscore separator before it
|
||||
folder_start = match.start()
|
||||
folder_start = folder_match.start()
|
||||
if folder_start > 0 and before_date[folder_start - 1] == "_":
|
||||
client_name = before_date[: folder_start - 1]
|
||||
folder_name = match.group() # the actual expanded name
|
||||
return (drawer_id, client_name, folder_name, date_str, doc_name + ext)
|
||||
folder_parts = decompose_folder_path(
|
||||
folder_match, folder_type, template_name
|
||||
)
|
||||
return (drawer_id, client_name, folder_parts, date_str, doc_name + ext)
|
||||
|
||||
return None
|
||||
|
||||
@@ -124,11 +130,11 @@ def main():
|
||||
dest_dir = os.path.join(output_dir, "_unparsed")
|
||||
failed += 1
|
||||
else:
|
||||
drawer_id, client_name, folder_name, date, doc_name = result
|
||||
drawer_id, client_name, folder_parts, date, doc_name = result
|
||||
dest_dir = os.path.join(
|
||||
output_dir,
|
||||
f"{drawer_id}_{client_name}",
|
||||
folder_name,
|
||||
*folder_parts,
|
||||
)
|
||||
success += 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user