Initial commit: FCCS extraction tool
This commit is contained in:
32
config.ini
Normal file
32
config.ini
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[paths]
|
||||||
|
# Point these at the current engagement's directories
|
||||||
|
backup_dir = C:\Migration\FCCSBackup
|
||||||
|
export_dir = C:\Migration\Export
|
||||||
|
output_dir = C:\Migration\Output
|
||||||
|
drawer_id_file = C:\Migration\drawer_ids.txt
|
||||||
|
completed_file = C:\Migration\completed.txt
|
||||||
|
log_file = C:\Migration\run_log.txt
|
||||||
|
screenshot_dir = C:\Migration\screenshots
|
||||||
|
folder_list = fccs_folders.txt
|
||||||
|
|
||||||
|
[timeouts]
|
||||||
|
nav_timeout = 15
|
||||||
|
dialog_timeout = 15
|
||||||
|
progress_appear = 30
|
||||||
|
progress_finish = 1800
|
||||||
|
settle = 1.0
|
||||||
|
confirm_timeout = 30
|
||||||
|
|
||||||
|
[controls]
|
||||||
|
main_class = CSIFCAB
|
||||||
|
drawer_box_title = << Enter dra&wer ID or name >>
|
||||||
|
go_button_title = Go
|
||||||
|
dialog_title = Send To File Location
|
||||||
|
dialog_class = #32770
|
||||||
|
select_btn_title = &Select ->
|
||||||
|
ok_btn_title = OK
|
||||||
|
progress_title = Exporting Documents
|
||||||
|
progress_class = #32770
|
||||||
|
saved_title = "Send to" file saved
|
||||||
|
saved_class = #32770
|
||||||
|
saved_ok_title = OK
|
||||||
70
fccs_config.py
Normal file
70
fccs_config.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Shared configuration, logging, and utilities for the FCCS extraction tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import configparser
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = "config.ini"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""Parse the optional --config argument common to all scripts."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=None,
|
||||||
|
help=f"Path to config file (default: {DEFAULT_CONFIG} in script directory)",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path=None):
|
||||||
|
"""Read config.ini and return a ConfigParser object."""
|
||||||
|
if path is None:
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||||
|
path = os.path.join(script_dir, DEFAULT_CONFIG)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"ERROR: config file not found: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
cfg = configparser.ConfigParser()
|
||||||
|
cfg.read(path, encoding="utf-8")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def make_logger(log_file):
|
||||||
|
"""Return a log() function that writes timestamped lines to console and file."""
|
||||||
|
def log(msg):
|
||||||
|
line = f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}"
|
||||||
|
print(line)
|
||||||
|
try:
|
||||||
|
with open(log_file, "a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
def load_folder_list(path):
|
||||||
|
"""Load known FCCS folder names from file, one per line."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"ERROR: folder list file not found: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
folders = []
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
s = line.strip()
|
||||||
|
if s and not s.startswith("#"):
|
||||||
|
folders.append(s)
|
||||||
|
return folders
|
||||||
|
|
||||||
|
|
||||||
|
def load_lines(path):
|
||||||
|
"""Read non-blank, non-comment lines from a file."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return []
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
||||||
320
fccs_export.py
Executable file
320
fccs_export.py
Executable file
@@ -0,0 +1,320 @@
|
|||||||
|
"""
|
||||||
|
FileCabinet CS - Batch Export Automation (Step 2)
|
||||||
|
=================================================
|
||||||
|
Reads a list of drawer IDs from a file, then for each drawer:
|
||||||
|
1. Navigates to it in the main window (type ID -> Go)
|
||||||
|
2. Opens File > Send To > File
|
||||||
|
3. Clicks "Select ->" (grabs all documents via the tree root)
|
||||||
|
4. Clicks OK
|
||||||
|
5. Waits for the "Exporting Documents" progress dialog to appear and finish
|
||||||
|
6. Logs success/failure and moves to the next drawer
|
||||||
|
|
||||||
|
Key safety features:
|
||||||
|
- RESUME: completed drawer IDs are appended to a progress log. On restart,
|
||||||
|
already-completed drawers are skipped. A multi-hour run that dies partway
|
||||||
|
can simply be re-run and it picks up where it left off.
|
||||||
|
- LOGGING: every action and error is timestamped to a log file.
|
||||||
|
- DEFENSIVE: unexpected states are caught, logged, screenshotted, and the
|
||||||
|
run continues with the next drawer rather than crashing.
|
||||||
|
|
||||||
|
PREREQUISITES:
|
||||||
|
- Run from 32-bit Python (matches FileCabinet CS's architecture)
|
||||||
|
- FileCabinet CS already open, pointed at the correct data location
|
||||||
|
- Export options/checkboxes and destination already set MANUALLY once
|
||||||
|
(they persist between exports within a session)
|
||||||
|
- Neither FileCabinet nor this script running elevated (they must match)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pywinauto import Desktop, Application
|
||||||
|
from pywinauto import timings
|
||||||
|
|
||||||
|
from fccs_config import parse_args, load_config, make_logger, load_lines
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HELPERS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_completed(path):
|
||||||
|
"""Read the set of already-completed drawer IDs for resume support."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return set()
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return {line.strip() for line in f if line.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_completed(path, drawer_id):
|
||||||
|
"""Append a drawer ID to the completed log (resume tracking)."""
|
||||||
|
with open(path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(drawer_id + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def take_screenshot(window, drawer_id, tag, screenshot_dir, log):
|
||||||
|
"""Save a screenshot of a window for later diagnosis of a failure."""
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
os.makedirs(screenshot_dir, exist_ok=True)
|
||||||
|
fname = os.path.join(
|
||||||
|
screenshot_dir,
|
||||||
|
f"{drawer_id}_{tag}_{datetime.now():%Y%m%d_%H%M%S}.png"
|
||||||
|
)
|
||||||
|
window.capture_as_image().save(fname)
|
||||||
|
log(f" Screenshot saved: {fname}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f" Could not capture screenshot: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CONNECTION
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def connect_main(ctrl, log):
|
||||||
|
"""Find and connect to the running FileCabinet CS main window."""
|
||||||
|
target = None
|
||||||
|
for w in Desktop(backend="win32").windows():
|
||||||
|
if w.class_name() == ctrl["main_class"]:
|
||||||
|
target = w
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
log("ERROR: FileCabinet CS window not found. Is it open?")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
app = Application(backend="win32").connect(handle=target.handle)
|
||||||
|
main = app.window(handle=target.handle)
|
||||||
|
log(f"Connected to: {main.window_text()}")
|
||||||
|
return app, main
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PER-DRAWER EXPORT
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def navigate_to_drawer(main, drawer_id, ctrl, timeouts, log):
|
||||||
|
"""Type the drawer ID into the search box and click Go."""
|
||||||
|
box = main.child_window(title=ctrl["drawer_box_title"], class_name="Edit")
|
||||||
|
box.wait("visible ready", timeout=timeouts["nav"])
|
||||||
|
|
||||||
|
box.set_edit_text(drawer_id)
|
||||||
|
time.sleep(timeouts["settle"])
|
||||||
|
|
||||||
|
main.child_window(title=ctrl["go_button_title"], class_name="Button").click()
|
||||||
|
|
||||||
|
deadline = time.time() + timeouts["nav"]
|
||||||
|
while time.time() < deadline:
|
||||||
|
title = main.window_text()
|
||||||
|
if f"[{drawer_id}" in title:
|
||||||
|
log(f" Navigated to drawer {drawer_id}: {title}")
|
||||||
|
return True
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
log(f" WARNING: window title did not confirm drawer {drawer_id} "
|
||||||
|
f"(title is: {main.window_text()!r})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def open_send_to_file(main, app, ctrl, timeouts):
|
||||||
|
"""Open File > Send To > File and return the dialog window object."""
|
||||||
|
main.menu_select("File->Send To->File")
|
||||||
|
|
||||||
|
dlg = app.window(title=ctrl["dialog_title"], class_name=ctrl["dialog_class"])
|
||||||
|
dlg.wait("visible ready", timeout=timeouts["dialog"])
|
||||||
|
return dlg
|
||||||
|
|
||||||
|
|
||||||
|
def perform_export(dlg, ctrl, timeouts):
|
||||||
|
"""Click Select -> then OK to start the export."""
|
||||||
|
dlg.child_window(title=ctrl["select_btn_title"], class_name="Button").click()
|
||||||
|
time.sleep(timeouts["settle"])
|
||||||
|
|
||||||
|
dlg.child_window(title=ctrl["ok_btn_title"], class_name="Button").click()
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_export(app, ctrl, timeouts, log):
|
||||||
|
"""
|
||||||
|
Wait for the full export lifecycle to complete:
|
||||||
|
1. 'Exporting Documents' progress dialog appears (export running)
|
||||||
|
2. Progress dialog disappears (export finished writing files)
|
||||||
|
3. '"Send to" file saved' confirmation dialog appears (MODAL)
|
||||||
|
4. Dismiss it by clicking OK
|
||||||
|
"""
|
||||||
|
progress = app.window(title=ctrl["progress_title"], class_name=ctrl["progress_class"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
progress.wait("visible", timeout=timeouts["progress_appear"])
|
||||||
|
log(" Export in progress...")
|
||||||
|
except timings.TimeoutError:
|
||||||
|
log(" NOTE: progress dialog not seen; checking for confirmation dialog "
|
||||||
|
"(drawer may have exported very quickly).")
|
||||||
|
|
||||||
|
try:
|
||||||
|
progress.wait_not("visible", timeout=timeouts["progress_finish"])
|
||||||
|
except timings.TimeoutError:
|
||||||
|
log(f" ERROR: export did not finish within {timeouts['progress_finish']}s.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
saved = app.window(title=ctrl["saved_title"], class_name=ctrl["saved_class"])
|
||||||
|
try:
|
||||||
|
saved.wait("visible ready", timeout=timeouts["confirm"])
|
||||||
|
log(" Confirmation dialog appeared - export succeeded.")
|
||||||
|
except timings.TimeoutError:
|
||||||
|
log(" ERROR: confirmation dialog never appeared. Export may have "
|
||||||
|
"failed or produced no output.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
saved.child_window(title=ctrl["saved_ok_title"], class_name="Button").click()
|
||||||
|
saved.wait_not("visible", timeout=timeouts["confirm"])
|
||||||
|
log(" Confirmation dismissed.")
|
||||||
|
except Exception as e:
|
||||||
|
log(f" ERROR dismissing confirmation dialog: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
log(" Export complete.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def export_drawer(app, main, drawer_id, ctrl, timeouts, screenshot_dir, log):
|
||||||
|
"""Full per-drawer sequence. Returns True on success."""
|
||||||
|
log(f"Drawer {drawer_id}: starting")
|
||||||
|
|
||||||
|
# 1. Navigate
|
||||||
|
if not navigate_to_drawer(main, drawer_id, ctrl, timeouts, log):
|
||||||
|
take_screenshot(main, drawer_id, "nav_fail", screenshot_dir, log)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Open Send To File dialog
|
||||||
|
try:
|
||||||
|
dlg = open_send_to_file(main, app, ctrl, timeouts)
|
||||||
|
except Exception as e:
|
||||||
|
log(f" ERROR opening Send To File dialog: {e}")
|
||||||
|
take_screenshot(main, drawer_id, "dialog_fail", screenshot_dir, log)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3 & 4. Select -> and OK
|
||||||
|
try:
|
||||||
|
perform_export(dlg, ctrl, timeouts)
|
||||||
|
except Exception as e:
|
||||||
|
log(f" ERROR during export selection: {e}")
|
||||||
|
take_screenshot(dlg, drawer_id, "export_fail", screenshot_dir, log)
|
||||||
|
try:
|
||||||
|
dlg.child_window(title="Cancel", class_name="Button").click()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 5. Monitor progress
|
||||||
|
ok = wait_for_export(app, ctrl, timeouts, log)
|
||||||
|
if not ok:
|
||||||
|
take_screenshot(main, drawer_id, "progress_fail", screenshot_dir, log)
|
||||||
|
return False
|
||||||
|
|
||||||
|
time.sleep(timeouts["settle"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MAIN LOOP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
cfg = load_config(args.config)
|
||||||
|
log = make_logger(cfg.get("paths", "log_file"))
|
||||||
|
|
||||||
|
# Build lookup dicts from config
|
||||||
|
paths = {
|
||||||
|
"drawer_id_file": cfg.get("paths", "drawer_id_file"),
|
||||||
|
"completed_file": cfg.get("paths", "completed_file"),
|
||||||
|
"screenshot_dir": cfg.get("paths", "screenshot_dir"),
|
||||||
|
}
|
||||||
|
timeouts = {
|
||||||
|
"nav": cfg.getfloat("timeouts", "nav_timeout"),
|
||||||
|
"dialog": cfg.getfloat("timeouts", "dialog_timeout"),
|
||||||
|
"progress_appear": cfg.getfloat("timeouts", "progress_appear"),
|
||||||
|
"progress_finish": cfg.getfloat("timeouts", "progress_finish"),
|
||||||
|
"settle": cfg.getfloat("timeouts", "settle"),
|
||||||
|
"confirm": cfg.getfloat("timeouts", "confirm_timeout"),
|
||||||
|
}
|
||||||
|
ctrl = {
|
||||||
|
"main_class": cfg.get("controls", "main_class"),
|
||||||
|
"drawer_box_title": cfg.get("controls", "drawer_box_title"),
|
||||||
|
"go_button_title": cfg.get("controls", "go_button_title"),
|
||||||
|
"dialog_title": cfg.get("controls", "dialog_title"),
|
||||||
|
"dialog_class": cfg.get("controls", "dialog_class"),
|
||||||
|
"select_btn_title": cfg.get("controls", "select_btn_title"),
|
||||||
|
"ok_btn_title": cfg.get("controls", "ok_btn_title"),
|
||||||
|
"progress_title": cfg.get("controls", "progress_title"),
|
||||||
|
"progress_class": cfg.get("controls", "progress_class"),
|
||||||
|
"saved_title": cfg.get("controls", "saved_title"),
|
||||||
|
"saved_class": cfg.get("controls", "saved_class"),
|
||||||
|
"saved_ok_title": cfg.get("controls", "saved_ok_title"),
|
||||||
|
}
|
||||||
|
|
||||||
|
log("=" * 60)
|
||||||
|
log("FileCabinet CS batch export - run starting")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
all_ids = load_lines(paths["drawer_id_file"])
|
||||||
|
if not all_ids:
|
||||||
|
log(f"ERROR: no drawer IDs found in {paths['drawer_id_file']}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
completed = load_completed(paths["completed_file"])
|
||||||
|
|
||||||
|
pending = [d for d in all_ids if d not in completed]
|
||||||
|
log(f"Total drawers in list : {len(all_ids)}")
|
||||||
|
log(f"Already completed : {len(completed)}")
|
||||||
|
log(f"Remaining to process : {len(pending)}")
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
log("Nothing to do - all drawers already completed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
app, main_win = connect_main(ctrl, log)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
failed_ids = []
|
||||||
|
|
||||||
|
for i, drawer_id in enumerate(pending, start=1):
|
||||||
|
log(f"--- [{i}/{len(pending)}] Drawer {drawer_id} ---")
|
||||||
|
try:
|
||||||
|
ok = export_drawer(app, main_win, drawer_id, ctrl, timeouts,
|
||||||
|
paths["screenshot_dir"], log)
|
||||||
|
except Exception as e:
|
||||||
|
log(f" UNEXPECTED ERROR on drawer {drawer_id}: {e}")
|
||||||
|
try:
|
||||||
|
take_screenshot(main_win, drawer_id, "unexpected",
|
||||||
|
paths["screenshot_dir"], log)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
mark_completed(paths["completed_file"], drawer_id)
|
||||||
|
success_count += 1
|
||||||
|
log(f" Drawer {drawer_id} DONE ({success_count} ok / "
|
||||||
|
f"{fail_count} failed so far)")
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
failed_ids.append(drawer_id)
|
||||||
|
log(f" Drawer {drawer_id} FAILED - will NOT be marked complete. "
|
||||||
|
f"Re-run later to retry.")
|
||||||
|
|
||||||
|
log("=" * 60)
|
||||||
|
log("Run finished.")
|
||||||
|
log(f" Succeeded : {success_count}")
|
||||||
|
log(f" Failed : {fail_count}")
|
||||||
|
if failed_ids:
|
||||||
|
log(f" Failed drawer IDs: {', '.join(failed_ids)}")
|
||||||
|
log(" These were NOT marked complete; re-running the script "
|
||||||
|
"will retry them.")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
11
fccs_folders.txt
Normal file
11
fccs_folders.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# FCCS Folder Names
|
||||||
|
# -----------------
|
||||||
|
# List one folder name per line, exactly as it appears in FileCabinet CS.
|
||||||
|
# Open FCCS > Tools > Options > Folder Structure to see all folder names.
|
||||||
|
# Blank lines and lines starting with # are ignored.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# Tax Documents
|
||||||
|
# Correspondence
|
||||||
|
# Financial Statements
|
||||||
|
# Payroll
|
||||||
142
fccs_reorganize.py
Normal file
142
fccs_reorganize.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Step 3: Reorganize flat exported files into a proper folder structure.
|
||||||
|
|
||||||
|
Parses FCCS export filenames using known folder names and date anchors
|
||||||
|
to reconstruct the original drawer/folder hierarchy.
|
||||||
|
|
||||||
|
Filename format:
|
||||||
|
{drawer_id}_{client_name}_{folder_name}_{MM-DD-YYYY}_{document_name}.ext
|
||||||
|
Example: 01069_SMITH, BOB_2007 Tax Documents_02-10-2009_2007 Form 1040A Page 1.pdf
|
||||||
|
|
||||||
|
Output structure:
|
||||||
|
output/{drawer_id}_{client_name}/{folder_name}/{original_filename}
|
||||||
|
|
||||||
|
Files that cannot be parsed go to 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
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filename(filename, known_folders):
|
||||||
|
"""
|
||||||
|
Parse an FCCS export filename into (drawer_id, client_name, folder_name,
|
||||||
|
date, doc_name_with_ext) or return None if it cannot be parsed.
|
||||||
|
|
||||||
|
Strategy: anchor on drawer_id (left), date MM-DD-YYYY (middle), and match
|
||||||
|
a known folder name between client_name and date.
|
||||||
|
"""
|
||||||
|
stem, ext = os.path.splitext(filename)
|
||||||
|
|
||||||
|
# Drawer ID is always the first underscore-delimited token
|
||||||
|
sep = stem.find("_")
|
||||||
|
if sep == -1:
|
||||||
|
return None
|
||||||
|
drawer_id = stem[:sep]
|
||||||
|
rest = stem[sep + 1:]
|
||||||
|
|
||||||
|
# Find all date-pattern occurrences (MM-DD-YYYY) in rest
|
||||||
|
# We search with a leading underscore context to ensure proper boundaries
|
||||||
|
date_pattern = re.compile(r"_(\d{2}-\d{2}-\d{4})_")
|
||||||
|
# Prepend underscore so the first potential date at position 0 of rest is caught
|
||||||
|
search_str = "_" + rest
|
||||||
|
matches = list(date_pattern.finditer(search_str))
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try each date match; known_folders is already sorted longest-first
|
||||||
|
for m in matches:
|
||||||
|
date_str = m.group(1)
|
||||||
|
# Position in `rest` where date starts (adjust for prepended _)
|
||||||
|
date_start_in_rest = m.start() - 1 # -1 for the prepended _
|
||||||
|
# But the match includes the leading _, so the actual content before date:
|
||||||
|
before_date = rest[:date_start_in_rest]
|
||||||
|
after_date = rest[date_start_in_rest + len(date_str) + 1:] # +1 for trailing _
|
||||||
|
|
||||||
|
# Try to match a known folder at the end of before_date
|
||||||
|
for folder in known_folders:
|
||||||
|
if before_date.endswith(folder):
|
||||||
|
# Check there's an underscore separator before the folder name
|
||||||
|
prefix_end = len(before_date) - len(folder)
|
||||||
|
if prefix_end > 0 and before_date[prefix_end - 1] == "_":
|
||||||
|
client_name = before_date[: prefix_end - 1]
|
||||||
|
doc_name = after_date + ext
|
||||||
|
return (drawer_id, client_name, folder, date_str, doc_name)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
known_folders = load_folder_list(folder_list_path)
|
||||||
|
if not known_folders:
|
||||||
|
log("WARNING: folder list is empty. All files will go to _unparsed/.")
|
||||||
|
# Sort longest-first to prevent partial matches
|
||||||
|
known_folders.sort(key=len, reverse=True)
|
||||||
|
|
||||||
|
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, known_folders)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
log(f" UNPARSED: {filename}")
|
||||||
|
dest_dir = os.path.join(output_dir, "_unparsed")
|
||||||
|
failed += 1
|
||||||
|
else:
|
||||||
|
drawer_id, client_name, folder_name, date, doc_name = result
|
||||||
|
dest_dir = os.path.join(
|
||||||
|
output_dir,
|
||||||
|
f"{drawer_id}_{client_name}",
|
||||||
|
folder_name,
|
||||||
|
)
|
||||||
|
success += 1
|
||||||
|
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
src = os.path.join(export_dir, filename)
|
||||||
|
dst = os.path.join(dest_dir, filename)
|
||||||
|
|
||||||
|
# Handle duplicate filenames
|
||||||
|
if os.path.exists(dst):
|
||||||
|
base, fext = os.path.splitext(filename)
|
||||||
|
counter = 1
|
||||||
|
while os.path.exists(dst):
|
||||||
|
dst = os.path.join(dest_dir, f"{base}_{counter}{fext}")
|
||||||
|
counter += 1
|
||||||
|
log(f" DUPLICATE renamed: {filename} -> {os.path.basename(dst)}")
|
||||||
|
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
log("=" * 60)
|
||||||
|
log(f"Reorganization complete: {success} organized, {failed} unparsed")
|
||||||
|
if failed:
|
||||||
|
log(f"Review unparsed files in: {os.path.join(output_dir, '_unparsed')}")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
39
fccs_scan.py
Normal file
39
fccs_scan.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Step 1: Scan a restored FCCS backup directory for drawer IDs.
|
||||||
|
|
||||||
|
Every subdirectory in the backup root corresponds to a drawer.
|
||||||
|
Writes the sorted list of drawer IDs to the configured drawer_id_file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fccs_config import parse_args, load_config, make_logger
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
cfg = load_config(args.config)
|
||||||
|
log = make_logger(cfg.get("paths", "log_file"))
|
||||||
|
|
||||||
|
backup_dir = cfg.get("paths", "backup_dir")
|
||||||
|
output_file = cfg.get("paths", "drawer_id_file")
|
||||||
|
|
||||||
|
if not os.path.isdir(backup_dir):
|
||||||
|
log(f"ERROR: backup directory not found: {backup_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
drawer_ids = sorted(
|
||||||
|
name for name in os.listdir(backup_dir)
|
||||||
|
if os.path.isdir(os.path.join(backup_dir, name))
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
|
for did in drawer_ids:
|
||||||
|
f.write(did + "\n")
|
||||||
|
|
||||||
|
log(f"Found {len(drawer_ids)} drawers, written to {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user