231 lines
9.0 KiB
Python
231 lines
9.0 KiB
Python
"""
|
|
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})_")
|
|
# Trailing "[Subfolder Name]" on the folder field — FCCS encodes nested
|
|
# subfolders (e.g. Income Documents > 2016 > "4201 N Beach Street, LLC") as
|
|
# "2016 Income Documents[4201 N Beach Street, LLC]" in the export filename.
|
|
_SUBFOLDER_RE = re.compile(r"\[([^\[\]]+)\]$")
|
|
|
|
|
|
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:]
|
|
|
|
# Peel any trailing [Subfolder] groups off the folder field so the
|
|
# remainder can match a template; each becomes one more nested path
|
|
# component (in order, so "a[b][c]" nests as a/b/c).
|
|
folder_text = before_date
|
|
sub_parts = []
|
|
while True:
|
|
sm = _SUBFOLDER_RE.search(folder_text)
|
|
if not sm:
|
|
break
|
|
sub_parts.insert(0, sm.group(1))
|
|
folder_text = folder_text[: sm.start()]
|
|
|
|
# Try each folder pattern against the end of the folder text
|
|
for pattern, template_name, folder_type in folder_patterns:
|
|
folder_match = pattern.search(folder_text)
|
|
if folder_match and folder_match.end() == len(folder_text):
|
|
# Folder matched at the end — check for underscore separator before it
|
|
folder_start = folder_match.start()
|
|
if folder_start > 0 and folder_text[folder_start - 1] == "_":
|
|
client_name = folder_text[: folder_start - 1]
|
|
folder_parts = decompose_folder_path(
|
|
folder_match, folder_type, template_name
|
|
) + tuple(sub_parts)
|
|
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()
|