fbb04eb2f7
- Changed application entry point to use QApplication for better compatibility with widgets. - Set up QML UI style to support custom button backgrounds. - Introduced LocalSettingsModel and FileBrowser for managing application settings and file selection. - Updated QML components to reflect new data models and improve layout consistency. - Enhanced Theme.qml with detailed comments and improved color management for better readability.
231 lines
7.6 KiB
Python
231 lines
7.6 KiB
Python
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)
|
|
def saveMetadata(
|
|
self,
|
|
file_path: str,
|
|
wafer: str,
|
|
date: str,
|
|
chamber: str,
|
|
notes: str,
|
|
selected: bool,
|
|
) -> 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
|
|
|
|
payload = {
|
|
"wafer": wafer,
|
|
"date": date,
|
|
"chamber": chamber,
|
|
"notes": notes,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
"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",
|
|
"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", "")),
|
|
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<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<run>\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)
|
|
|
|
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
|