""" 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 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 for filename in files: 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 can't be determined. client_name = client_name_from_filename(filename) if client_name: dest_dir = os.path.join(output_dir, 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}") failed += 1 else: drawer_id, client_name, folder_parts, date, doc_name = result dest_dir = os.path.join( output_dir, client_name, *folder_parts, ) success += 1 os.makedirs(dest_dir, exist_ok=True) src = os.path.join(export_dir, filename) # For parsed files, use just the document name; for unparsed, keep original dest_filename = doc_name if result else 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) log("=" * 60) log(f"Reorganization complete: {success} organized, {failed} unparsed") 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).") log("=" * 60) if __name__ == "__main__": main()