updated script for reorganizing folders to handle the weird nesting that FCCS does

This commit is contained in:
2026-07-06 16:12:55 -05:00
parent 1ae9ba27a6
commit a664a00f4d
3 changed files with 74 additions and 29 deletions

View File

@@ -57,17 +57,22 @@ Parses the flat exported filenames and copies them into an organized structure:
``` ```
output/ output/
01069_ABRAHAM, REBEKAH L./ 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 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/ Permanent File/
01069_ABRAHAM, REBEKAH L._Permanent File_02-24-2018_Driver's License.pdf 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/ _unparsed/
(files that couldn't be parsed go here for manual review) (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 ## Config Reference

View File

@@ -71,32 +71,66 @@ def build_folder_patterns(templates):
Non-recurring folders (no YYYY) are matched literally. Non-recurring folders (no YYYY) are matched literally.
A built-in pattern for 'UltraTax CS MM-DD-YYYY' is always included. A built-in pattern for 'UltraTax CS MM-DD-YYYY' is always included.
Returns a list of (compiled_regex, template_name) tuples, sorted Returns a list of (compiled_regex, template_name, folder_type) tuples,
longest-first to prevent partial matches. 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 = [] patterns = []
# Built-in: UltraTax CS folders (auto-generated by UltraTax integration) # Built-in: UltraTax CS folders (auto-generated by UltraTax integration)
patterns.append(( 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 CS",
"ultratax",
)) ))
for tmpl in templates: for tmpl in templates:
if "YYYY" in tmpl: 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") 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: else:
# Non-recurring folder — exact match # Non-recurring folder — exact match
regex_str = re.escape(tmpl) 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 # Sort by regex pattern length (longest first) to avoid partial matches
patterns.sort(key=lambda p: len(p[0].pattern), reverse=True) patterns.sort(key=lambda p: len(p[0].pattern), reverse=True)
return patterns 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): def load_lines(path):
"""Read non-blank, non-comment lines from a file.""" """Read non-blank, non-comment lines from a file."""
if not os.path.exists(path): if not os.path.exists(path):

View File

@@ -12,8 +12,10 @@ Examples:
01069_ABRAHAM, REBEKAH L._Permanent File_02-24-2018_Driver's License.pdf 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 01069_ABRAHAM, REBEKAH L._UltraTax CS 12-31-2008_02-12-2009_2008 Form 1040 Filing Instructions.doc
Output structure: Output structure (recreates FCCS UI nesting):
output/{drawer_id}_{client_name}/{folder_name}/{original_filename} 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. Files that cannot be parsed go to output/_unparsed/ for manual review.
""" """
@@ -25,7 +27,7 @@ import sys
from fccs_config import ( from fccs_config import (
parse_args, load_config, make_logger, 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 # 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): def parse_filename(filename, folder_patterns):
""" """
Parse an FCCS export filename into (drawer_id, client_name, folder_name, Parse an FCCS export filename into its components or return None.
date, doc_name_with_ext) or return None if it cannot be parsed.
folder_patterns is a list of (compiled_regex, template_name) from Returns (drawer_id, client_name, folder_parts, date, doc_name_with_ext)
build_folder_patterns(), sorted longest-first. 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: Strategy:
1. Drawer ID: first token before first underscore 1. Drawer ID: first token before first underscore
2. Creation date: find MM-DD-YYYY pattern as anchor 2. Creation date: find MM-DD-YYYY pattern as anchor
3. Folder name: regex-match a known folder template at the end of 3. Folder name: regex-match a known folder template at the end of
the text between client_name and creation_date 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) stem, ext = os.path.splitext(filename)
@@ -67,22 +73,22 @@ def parse_filename(filename, folder_patterns):
for m in date_matches: for m in date_matches:
date_str = m.group(1) date_str = m.group(1)
# Calculate positions relative to `rest` (adjust for prepended _) # 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] before_date = rest[: m.start() - 1]
after_date_pos = m.start() - 1 + len("_") + len(date_str) + len("_") after_date_pos = m.start() - 1 + len("_") + len(date_str) + len("_")
doc_name = rest[after_date_pos:] doc_name = rest[after_date_pos:]
# Try each folder pattern against the end of before_date # Try each folder pattern against the end of before_date
for pattern, template_name in folder_patterns: for pattern, template_name, folder_type in folder_patterns:
match = pattern.search(before_date) folder_match = pattern.search(before_date)
if match and match.end() == len(before_date): if folder_match and folder_match.end() == len(before_date):
# Folder matched at the end — check for underscore separator before it # 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] == "_": if folder_start > 0 and before_date[folder_start - 1] == "_":
client_name = before_date[: folder_start - 1] client_name = before_date[: folder_start - 1]
folder_name = match.group() # the actual expanded name folder_parts = decompose_folder_path(
return (drawer_id, client_name, folder_name, date_str, doc_name + ext) folder_match, folder_type, template_name
)
return (drawer_id, client_name, folder_parts, date_str, doc_name + ext)
return None return None
@@ -124,11 +130,11 @@ def main():
dest_dir = os.path.join(output_dir, "_unparsed") dest_dir = os.path.join(output_dir, "_unparsed")
failed += 1 failed += 1
else: 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( dest_dir = os.path.join(
output_dir, output_dir,
f"{drawer_id}_{client_name}", f"{drawer_id}_{client_name}",
folder_name, *folder_parts,
) )
success += 1 success += 1