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 backend.csv_file_metadata import CSVFileMetadata from 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) self._files.append( { "selected": selected_state.get(str(csv_path), False), "wafer": metadata.wafer, "date": metadata.string_date_format(), "chamber": metadata.chamber, "notes": metadata.notes, "masterType": master_state.get(str(csv_path), "") or metadata.master_type, "fileName": str(csv_path), "highlight": metadata.get_wafer_type() in {"A", "B", "C"}, } ) except Exception: self._files.append( { "selected": selected_state.get(str(csv_path), False), "wafer": csv_path.stem, "date": "", "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\d+))?$", csv_path.stem, ) if not match: return CSVFileMetadata(filename=str(csv_path)) date_text = "" try: parsed = datetime.strptime(match.group("date"), "%Y%m%d") date_text = CSVFileMetadata.format_date(parsed) except ValueError: date_text = "" return CSVFileMetadata( wafer=match.group("wafer"), date=date_text, filename=str(csv_path), ) # ===== Internal Helpers ===== def _resolve_default_directory(self) -> Path: documents_dir = QStandardPaths.writableLocation( QStandardPaths.DocumentsLocation ) base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents") return base_dir / "isc_data" def _set_current_directory(self, directory: Path) -> None: normalized = Path(directory) if normalized == self._current_directory: return self._current_directory = normalized self.currentDirectoryChanged.emit() def _find_row(self, file_path: str) -> dict[str, object] | None: for row in self._files: if str(row.get("fileName", "")) == file_path: return row return None def _sidecar_path(self, csv_path: Path) -> Path: return csv_path.with_suffix(f"{csv_path.suffix}.json") def _show_error(self, message: str) -> None: QMessageBox.critical(None, "iSenseCloud", message) @Slot(str) def showInfo(self, message: str) -> None: self._show_info(message) @Slot(str, result="QVariantMap") def parseCsvMetadata(self, file_path: str) -> dict: from pathlib import Path as _Path csv_path = _Path(file_path) if not csv_path.exists(): return {"success": False, "error": "File not found"} parser_data, _ = self._parser.parse(file_path) if parser_data is None: return {"success": False, "error": "Failed to parse CSV"} wafer = parser_data.serial or "" date_text = "" if parser_data.date != datetime.min: date_text = CSVFileMetadata.format_date(parser_data.date) columns = len(parser_data.csv_headers) if parser_data.csv_headers else 0 family_code = wafer[0].upper() if wafer else "" return { "success": True, "wafer": wafer, "date": date_text, "columns": columns, "familyCode": family_code, } def _show_info(self, message: str) -> None: QMessageBox.information(None, "iSenseCloud", message) # Backward-compatible alias while the QML dialog is still named SelectFileDialog. SelectFileDialogModel = FileBrowser