from __future__ import annotations import json import re from datetime import datetime from pathlib import Path from PySide6.QtCore import Property, QObject, QStandardPaths, Signal, Slot from PySide6.QtWidgets import QFileDialog, QMessageBox from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME from pygui.backend.data.csv_file_metadata import CSVFileMetadata from pygui.backend.wafer.zwafer_parser import ZWaferParser # ===== File Browser Model ===== class FileBrowser(QObject): # ===== Change Signals ===== filesChanged = Signal() currentDirectoryChanged = Signal() # ===== Lifecycle ===== def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._parser = ZWaferParser() self._files: list[dict[str, object]] = [] self._current_directory = self._resolve_default_directory() self._refresh_files(show_empty_message=False) # ===== Exposed Properties ===== @Property("QVariantList", notify=filesChanged) def files(self) -> list[dict[str, object]]: return [dict(row) for row in self._files] @Property(str, notify=currentDirectoryChanged) def currentDirectory(self) -> str: return str(self._current_directory) # ===== Directory Selection ===== @Slot() def chooseDirectory(self) -> None: selected_dir = QFileDialog.getExistingDirectory( None, "Select CSV Folder", self.currentDirectory, ) if not selected_dir: return self._set_current_directory(Path(selected_dir)) self._refresh_files(show_empty_message=True) @Slot() def refreshFiles(self) -> None: self._refresh_files(show_empty_message=False) # ===== Metadata Persistence ===== @Slot(str, str, str, str, str, bool, str) def saveMetadata( self, file_path: str, wafer: str, date: str, chamber: str, notes: str, selected: bool, master_type: str = "", ) -> None: csv_path = Path(file_path) if not csv_path.exists(): self._show_error(f"CSV file was not found:\n{file_path}") return row = self._find_row(file_path) if row is None: self._show_error("The selected CSV row is no longer available.") return row["wafer"] = wafer row["date"] = date row["chamber"] = chamber row["notes"] = notes row["selected"] = selected row["masterType"] = master_type payload = { "wafer": wafer, "date": date, "chamber": chamber, "notes": notes, "masterType": master_type, } try: sidecar_path = self._sidecar_path(csv_path) sidecar_path.write_text(json.dumps(payload, indent=4), encoding="utf-8") except Exception as exc: # pragma: no cover - defensive UI path self._show_error(f"Failed to save metadata:\n{exc}") return self.filesChanged.emit() self._show_info(f"Saved metadata for:\n{csv_path.name}") # ===== Internal Data Loading ===== def _refresh_files(self, show_empty_message: bool) -> None: selected_state = { str(row.get("fileName", "")): bool(row.get("selected", False)) for row in self._files } master_state = { str(row.get("fileName", "")): row.get("masterType", "") or "" for row in self._files } self._files = [] if not self._current_directory.exists(): self.filesChanged.emit() return for csv_path in sorted(self._current_directory.glob("*.csv")): try: metadata = self._load_metadata(csv_path) wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "") date_str = metadata.string_date_format() serial = metadata.wafer[1:] if len(metadata.wafer) > 1 else "" if not serial: stem = csv_path.stem serial = stem[1:] if len(stem) > 1 else "" if "-" in serial: serial = serial.split("-")[0] if "_" in serial: serial = serial.split("_")[0] time_str = date_str[11:] if len(date_str) > 10 else "" self._files.append( { "selected": selected_state.get(str(csv_path), False), "baseName": csv_path.stem, "wafer": metadata.wafer, "waferType": wafer_type, "serialNumber": serial, "date": date_str, "timeStr": time_str, "chamber": metadata.chamber, "notes": metadata.notes, "masterType": master_state.get(str(csv_path), "") or metadata.master_type, "fileName": str(csv_path), "highlight": wafer_type in {"A", "B", "C"}, } ) except Exception: stem = csv_path.stem serial_fallback = stem[1:] if len(stem) > 1 else "" if "-" in serial_fallback: serial_fallback = serial_fallback.split("-")[0] if "_" in serial_fallback: serial_fallback = serial_fallback.split("_")[0] self._files.append( { "selected": selected_state.get(str(csv_path), False), "baseName": stem, "wafer": stem, "waferType": stem[0].upper() if stem else "", "serialNumber": serial_fallback, "date": "", "timeStr": "", "chamber": "", "notes": "Unable to parse metadata", "masterType": master_state.get(str(csv_path), ""), "fileName": str(csv_path), "highlight": False, } ) self.filesChanged.emit() if show_empty_message and not self._files: self._show_info("No CSV files were found in the selected folder.") def _load_metadata(self, csv_path: Path) -> CSVFileMetadata: sidecar_path = self._sidecar_path(csv_path) if sidecar_path.exists(): try: payload = json.loads(sidecar_path.read_text(encoding="utf-8")) return CSVFileMetadata( wafer=str(payload.get("wafer", "")), date=str(payload.get("date", "")), chamber=str(payload.get("chamber", "")), notes=str(payload.get("notes", "")), master_type=str(payload.get("masterType", "")), filename=str(csv_path), ) except Exception: # Fall back to CSV/header parsing if sidecar is malformed. pass meta = self._metadata_from_filename(csv_path) parser_data, _ = self._parser.parse(str(csv_path)) if parser_data is not None: if parser_data.serial: meta.wafer = parser_data.serial if parser_data.date != datetime.min: meta.date = CSVFileMetadata.format_date(parser_data.date) return meta def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata: match = re.match( r"^(?P[^-]+)-(?P\d{8})(?:_(?P