diff --git a/README.md b/README.md index 8200639..5f30b29 100644 --- a/README.md +++ b/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/ - 01069_ABRAHAM, REBEKAH L._2025 Tax Documents_03-03-2026_030126 E-mail re Tax Info.pdf + 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 diff --git a/fccs_config.py b/fccs_config.py index 22715d8..9f4fa4b 100644 --- a/fccs_config.py +++ b/fccs_config.py @@ -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): diff --git a/fccs_reorganize.py b/fccs_reorganize.py index 0b699d5..a881cf9 100644 --- a/fccs_reorganize.py +++ b/fccs_reorganize.py @@ -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