diff --git a/README.md b/README.md index e8109ae..4f681ca 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ Exported filenames follow the format `{drawer_id}_{client_name}_{folder_name}_{c Files that can't be fully parsed still keep their client: the drawer ID and client name are the first two underscore-delimited tokens and stay recoverable even when the folder/date parse fails, so those files are filed under `{client_name}/_unparsed/` (retaining their original filename). Only files whose client can't be recovered at all fall back to the top-level `_unparsed/`. +Folder names are sanitized for Windows before use — trailing spaces and periods are stripped from each path component, since Windows can't create a directory ending in a space or dot (e.g. a client named `FARR GROUP, P.L.` becomes `FARR GROUP, P.L`). Reorganization is also resilient per file: if one file can't be placed for any reason, the error is logged and counted (reported as `errored` in the summary) and the run continues with the rest rather than aborting. + ### Step 4 (optional): Verify Export Completeness During export, each drawer's document list is captured from the FCCS dialog and saved as a manifest (in `manifest_dir`). These tools compare the manifests against the files actually in the export folder to confirm nothing was missed. diff --git a/__pycache__/fccs_reorganize.cpython-313.pyc b/__pycache__/fccs_reorganize.cpython-313.pyc index dc228e4..ec4b55a 100644 Binary files a/__pycache__/fccs_reorganize.cpython-313.pyc and b/__pycache__/fccs_reorganize.cpython-313.pyc differ diff --git a/fccs_reorganize.py b/fccs_reorganize.py index 0f01c9b..9f68d9d 100644 --- a/fccs_reorganize.py +++ b/fccs_reorganize.py @@ -97,6 +97,19 @@ def parse_filename(filename, folder_patterns): 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) @@ -125,55 +138,75 @@ def main(): success = 0 failed = 0 + errored = 0 for filename in files: - result = parse_filename(filename, folder_patterns) + # 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 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}") + 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: - 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, - ) + 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 - - 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) + else: + failed += 1 log("=" * 60) - log(f"Reorganization complete: {success} organized, {failed} unparsed") + 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)