""" Step 3: Reorganize flat exported files into a proper folder structure. Parses FCCS export filenames using known folder templates and date anchors to reconstruct the original drawer/folder hierarchy. Filename format: {drawer_id}_{client_name}_{folder_name}_{creation_date MM-DD-YYYY}_{document_name}.ext Examples: 01069_ABRAHAM, REBEKAH L._2025 Tax Documents_03-03-2026_030126 E-mail re Tax Info.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 Output structure (recreates FCCS UI nesting): output/{client_name}/Tax Documents/2025/{original_filename} output/{client_name}/UltraTax CS/12-31-2008/{original_filename} output/{client_name}/Permanent File/{original_filename} Files that cannot be parsed are still filed under their client: output/{client_name}/_unparsed/{original_filename} Only files whose client can't be recovered from the leading tokens fall back to the top-level output/_unparsed/ for manual review. """ import os import re import shutil import sys from fccs_config import ( parse_args, load_config, make_logger, load_folder_list, build_folder_patterns, decompose_folder_path, client_name_from_filename, ) # Regex for the creation date field (MM-DD-YYYY) bounded by underscores _DATE_RE = re.compile(r"_(\d{2}-\d{2}-\d{4})_") def parse_filename(filename, folder_patterns): """ Parse an FCCS export filename into its components or return None. 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. 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) # 1. Drawer ID — first underscore-delimited token sep = stem.find("_") if sep == -1: return None drawer_id = stem[:sep] rest = stem[sep + 1:] # 2. Find all creation date candidates (MM-DD-YYYY bounded by underscores) # Prepend underscore so a date right at the start of `rest` is also found search_str = "_" + rest date_matches = list(_DATE_RE.finditer(search_str)) if not date_matches: return None # 3. Try each date match; for each, try to match a known folder template for m in date_matches: date_str = m.group(1) # Calculate positions relative to `rest` (adjust for prepended _) 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, 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 = folder_match.start() if folder_start > 0 and before_date[folder_start - 1] == "_": client_name = before_date[: folder_start - 1] 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 def safe_component(name): """Make a single path component safe to create on Windows. Windows cannot create a directory whose name ends in a space or a period — os.makedirs raises FileNotFoundError (WinError 3). Client names such as 'FARR GROUP, P.L.' (trailing period) or ones with a stray trailing space trigger this, so strip trailing dots/spaces. Falls back to '_' if the name is entirely dots/spaces. """ cleaned = name.rstrip(" .") return cleaned if cleaned else "_" def main(): args = parse_args() cfg = load_config(args.config) log = make_logger(cfg.get("paths", "log_file")) export_dir = cfg.get("paths", "export_dir") output_dir = cfg.get("paths", "output_dir") folder_list_path = cfg.get("paths", "folder_list") if not os.path.isdir(export_dir): log(f"ERROR: export directory not found: {export_dir}") sys.exit(1) folder_templates = load_folder_list(folder_list_path) if not folder_templates: log("WARNING: folder list is empty. Only built-in patterns (UltraTax CS) will match.") folder_patterns = build_folder_patterns(folder_templates) log(f"Loaded {len(folder_patterns)} folder patterns") files = [ f for f in os.listdir(export_dir) if os.path.isfile(os.path.join(export_dir, f)) ] log(f"Found {len(files)} files to reorganize") success = 0 failed = 0 errored = 0 for filename in files: # Defensive per-file: a single bad path (e.g. an un-creatable name) must # not abort the whole run — log it and move on to the next file. try: result = parse_filename(filename, folder_patterns) if result is None: # Still recover the client from the leading tokens so the file # lands in that client's folder (in an _unparsed subfolder to # flag it), rather than a single top-level bucket. Fall back to # the top-level _unparsed only when even the client is unknown. client_name = client_name_from_filename(filename) if client_name: dest_dir = os.path.join(output_dir, safe_component(client_name), "_unparsed") log(f" UNPARSED (filed under {client_name}): {filename}") else: dest_dir = os.path.join(output_dir, "_unparsed") log(f" UNPARSED (no client): {filename}") dest_filename = filename # keep original name for review parsed = False else: drawer_id, client_name, folder_parts, date, doc_name = result dest_dir = os.path.join( output_dir, safe_component(client_name), *(safe_component(p) for p in folder_parts), ) dest_filename = doc_name parsed = True os.makedirs(dest_dir, exist_ok=True) src = os.path.join(export_dir, filename) dst = os.path.join(dest_dir, dest_filename) # Handle duplicate filenames if os.path.exists(dst): base, fext = os.path.splitext(dest_filename) counter = 1 while os.path.exists(dst): dst = os.path.join(dest_dir, f"{base}_{counter}{fext}") counter += 1 log(f" DUPLICATE renamed: {dest_filename} -> {os.path.basename(dst)}") shutil.copy2(src, dst) except Exception as e: log(f" ERROR processing {filename}: {e}") errored += 1 continue # Count only after a successful copy so the totals stay accurate. if parsed: success += 1 else: failed += 1 log("=" * 60) log(f"Reorganization complete: {success} organized, {failed} unparsed, " f"{errored} errored") if failed: log("Review unparsed files in each client's _unparsed subfolder " f"(and {os.path.join(output_dir, '_unparsed')} for any without a " "detectable client).") if errored: log(f"{errored} file(s) could not be placed due to errors — see the " "ERROR lines above; these were left in the export folder.") log("=" * 60) if __name__ == "__main__": main()