fix(paths): centralize file:// URL handling to fix broken save-dir paths

This commit is contained in:
2026-07-13 15:45:22 -07:00
parent 44216e5857
commit d03b889d88
9 changed files with 42 additions and 35 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ Popup {
id: saveDirDialog id: saveDirDialog
title: "Choose a folder to save data" title: "Choose a folder to save data"
onAccepted: { onAccepted: {
var dir = decodeURIComponent(String(selectedFolder).replace(/^file:\/\//, "")) var dir = String(selectedFolder)
deviceController.setSaveDataDir(dir) deviceController.setSaveDataDir(dir)
file_browser.setCurrentDirectory(dir) file_browser.setCurrentDirectory(dir)
root.finished() root.finished()
+1 -5
View File
@@ -171,10 +171,6 @@ Rectangle {
} }
} }
function cleanFolderUrl(url) {
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
}
function _doDetect() { function _doDetect() {
root.memoryRead = false root.memoryRead = false
root.waferDetected = false root.waferDetected = false
@@ -187,7 +183,7 @@ Rectangle {
id: saveDirDialog id: saveDirDialog
title: "Choose a folder to save Data" title: "Choose a folder to save Data"
onAccepted: { onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder) var dir = String(selectedFolder)
deviceController.setSaveDataDir(dir) deviceController.setSaveDataDir(dir)
file_browser.setCurrentDirectory(dir) file_browser.setCurrentDirectory(dir)
root._doDetect() root._doDetect()
+2 -5
View File
@@ -113,9 +113,6 @@ ColumnLayout {
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...") readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
readonly property string portName: deviceController.selectedPort || "—" readonly property string portName: deviceController.selectedPort || "—"
function cleanFolderUrl(url) {
return decodeURIComponent(String(url).replace(/^file:\/\//, ""));
}
function currentFamilyCode() { function currentFamilyCode() {
var info = deviceController.lastWaferInfo; var info = deviceController.lastWaferInfo;
return info && info.length > 0 ? (info[0] || "") : ""; return info && info.length > 0 ? (info[0] || "") : "";
@@ -128,7 +125,7 @@ ColumnLayout {
id: saveDirDialog id: saveDirDialog
title: "Choose a folder to save." title: "Choose a folder to save."
onAccepted: { onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder); var dir = String(selectedFolder);
deviceController.setSaveDataDir(dir); deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir); file_browser.setCurrentDirectory(dir);
root.parseAndSavePendingRead(); root.parseAndSavePendingRead();
@@ -141,7 +138,7 @@ ColumnLayout {
id: dirOnlyDialog id: dirOnlyDialog
title: "Choose a folder to save Data" title: "Choose a folder to save Data"
onAccepted: { onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder); var dir = String(selectedFolder);
deviceController.setSaveDataDir(dir); deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir); file_browser.setCurrentDirectory(dir);
} }
@@ -260,14 +260,14 @@ ColumnLayout {
id: importFileDialog id: importFileDialog
title: "Select Data File To Import" title: "Select Data File To Import"
nameFilters: ["CSV files (*.csv)"] nameFilters: ["CSV files (*.csv)"]
onAccepted: file_browser.importZWaferCsv(String(selectedFile).replace(/^file:\/\//, "")) onAccepted: file_browser.importZWaferCsv(String(selectedFile))
} }
FolderDialog { FolderDialog {
id: batchExportDirDialog id: batchExportDirDialog
title: "Export Wafer Maps To…" title: "Export Wafer Maps To…"
onAccepted: { onAccepted: {
var dir = String(selectedFolder).replace(/^file:\/\//, ""); var dir = String(selectedFolder);
var paths = file_browser.files.map(function(row) { return row.fileName; }); var paths = file_browser.files.map(function(row) { return row.fileName; });
batchExportController.start(paths, dir); batchExportController.start(paths, dir);
} }
@@ -5,7 +5,7 @@ import threading
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary, to_local_path
from pygui.backend.wafer.batch_export import BatchExportResult, export_batch from pygui.backend.wafer.batch_export import BatchExportResult, export_batch
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -33,7 +33,9 @@ class BatchExportController(QObject):
self._busy = True self._busy = True
self.busyChanged.emit() self.busyChanged.emit()
threading.Thread( threading.Thread(
target=self._worker, args=(list(file_paths), output_dir), daemon=True target=self._worker,
args=(list(file_paths), str(to_local_path(output_dir))),
daemon=True,
).start() ).start()
def _worker(self, file_paths: list[str], output_dir: str) -> None: def _worker(self, file_paths: list[str], output_dir: str) -> None:
@@ -15,7 +15,7 @@ from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.models.data_model import TemperatureTableModel from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary, to_local_path
from pygui.backend.visualization.graph_view import GraphView from pygui.backend.visualization.graph_view import GraphView
from pygui.serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
convert_to_debug_temperatures, convert_to_debug_temperatures,
@@ -82,7 +82,8 @@ class DeviceController(QObject):
self._operation_in_progress = False self._operation_in_progress = False
self._activity_log: list[str] = [] self._activity_log: list[str] = []
self._raw_bytes: Optional[bytes] = None self._raw_bytes: Optional[bytes] = None
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv") saved_dir = getattr(settings, 'save_data_dir', "")
self._save_data_dir: str = str(to_local_path(saved_dir)) if saved_dir else str(Path(self._data_dir) / "csv")
self._last_wafer_info: dict[str, Any] = {} self._last_wafer_info: dict[str, Any] = {}
self._wafer_detected: bool = False self._wafer_detected: bool = False
self._last_csv_path: str = "" self._last_csv_path: str = ""
@@ -141,8 +142,8 @@ class DeviceController(QObject):
@slot_error_boundary @slot_error_boundary
def setSaveDataDir(self, path: str) -> None: def setSaveDataDir(self, path: str) -> None:
"""Set the directory for saving parsed CSV data files.""" """Set the directory for saving parsed CSV data files."""
self._save_data_dir = path self._save_data_dir = str(to_local_path(path))
self._append_log(f"Save data dir set to: {path}") self._append_log(f"Save data dir set to: {self._save_data_dir}")
self._save_status() self._save_status()
@Slot(result=str) @Slot(result=str)
+4 -2
View File
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import QFileDialog, QMessageBox
from pygui.backend.data.constants import default_data_dir from pygui.backend.data.constants import default_data_dir
from pygui.backend.data.csv_file_metadata import CSVFileMetadata from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.utils import to_local_path
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -45,7 +46,7 @@ class FileBrowser(QObject):
Used to keep this directory in sync with DeviceController.saveDataDir Used to keep this directory in sync with DeviceController.saveDataDir
when the user picks a save folder elsewhere in the app. when the user picks a save folder elsewhere in the app.
""" """
self._set_current_directory(Path(path)) self._set_current_directory(to_local_path(path))
self._refresh_files(show_empty_message=False) self._refresh_files(show_empty_message=False)
@Slot() @Slot()
@@ -124,7 +125,8 @@ class FileBrowser(QObject):
date still gets a deterministic (if degenerate) filename, so the date still gets a deterministic (if degenerate) filename, so the
duplicate-import guard below still fires on a second attempt. duplicate-import guard below still fires on a second attempt.
""" """
source_path = Path(file_path) source_path = to_local_path(file_path)
file_path = str(source_path)
parser_data, _ = self._parser.parse(file_path) parser_data, _ = self._parser.parse(file_path)
if parser_data is None: if parser_data is None:
self.importFinished.emit("", f"Failed to parse CSV:\n{source_path.name}") self.importFinished.emit("", f"Failed to parse CSV:\n{source_path.name}")
+2 -14
View File
@@ -5,7 +5,7 @@ import shutil
import time import time
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot from PySide6.QtCore import Property, QObject, Signal, Slot
from pygui.backend.license.license_manager import ( from pygui.backend.license.license_manager import (
LICENSE_FILENAME_RE, LICENSE_FILENAME_RE,
@@ -13,25 +13,13 @@ from pygui.backend.license.license_manager import (
parse_license_blob, parse_license_blob,
read_license_files, read_license_files,
) )
from pygui.backend.utils import to_local_path as _to_local_path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
TRIAL_DAYS = 30 TRIAL_DAYS = 30
def _to_local_path(path_or_url: str) -> Path:
"""Accept both plain paths and file:// URLs (QML FileDialog gives URLs).
Uses QUrl.toLocalFile() rather than urlparse — a manual
urlparse().path strip leaves a leading slash before a Windows drive
letter (e.g. "/C:/Users/...", not a valid absolute path), which
QUrl's platform-aware conversion handles correctly.
"""
if path_or_url.startswith("file:"):
return Path(QUrl(path_or_url).toLocalFile())
return Path(path_or_url)
# ===== License Model ===== # ===== License Model =====
class LicenseModel(QObject): class LicenseModel(QObject):
"""Context property backing the About dialog license grid. """Context property backing the About dialog license grid.
+21
View File
@@ -1,5 +1,26 @@
import logging import logging
import re
from functools import wraps from functools import wraps
from pathlib import Path
from PySide6.QtCore import QUrl
_MANGLED_DRIVE_RE = re.compile(r"^/([A-Za-z]:)")
def to_local_path(path_or_url: str) -> Path:
"""Accept plain paths, file:// URLs, and already-mangled "/C:/..." strings
(persisted by old buggy code before it stripped file:// by hand).
Uses QUrl.toLocalFile() rather than a manual string strip — stripping
"file://" by hand leaves a leading slash before a Windows drive letter
(e.g. "/C:/Users/...", not a valid absolute path), which QUrl's
platform-aware conversion handles correctly.
"""
if path_or_url.startswith("file:"):
return Path(QUrl(path_or_url).toLocalFile())
# ponytail: repair values saved by the old regex-stripping QML code
return Path(_MANGLED_DRIVE_RE.sub(r"\1", path_or_url))
def slot_error_boundary(func): def slot_error_boundary(func):