from __future__ import annotations import json import re from datetime import datetime from pathlib import Path from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot from PySide6.QtWidgets import QFileDialog, QMessageBox from pygui.backend.csv_file_metadata import CSVFileMetadata from pygui.backend.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 "" 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 self._files.append( { "selected": selected_state.get(str(csv_path), False), "baseName": stem, "wafer": stem, "waferType": stem[0].upper() if stem else "", "serialNumber": stem[1:] if len(stem) > 1 else "", "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 parser_data, _ = self._parser.parse(str(csv_path)) if parser_data is not None: wafer = parser_data.serial or "" date_text = "" if parser_data.date != datetime.min: date_text = CSVFileMetadata.format_date(parser_data.date) return CSVFileMetadata( wafer=wafer, date=date_text, chamber="", notes="", filename=str(csv_path), ) return self._metadata_from_filename(csv_path) def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata: match = re.match( r"^(?P[^-]+)-(?P\d{8})(?:_(?P