Files
2026-07-06 00:24:30 -05:00

429 lines
14 KiB
Python

"""Roland cut file and LightBurn file discovery and opening.
Searches a configured SMB folder (recursively) for files whose name matches
a SKU, opens the chosen file with the system default application, and
persists a list of saved folder paths so the user can swap between locations.
The search is on a worker thread because SMB roots can be slow.
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Callable
log = logging.getLogger(__name__)
CUT_FILE_EXTENSIONS: tuple[str, ...] = (
".eps", ".ai", ".svg", ".pdf", ".plt", ".gsd", ".dxf", ".cdr", ".ccx",
)
LIGHTBURN_EXTENSIONS: tuple[str, ...] = (
".lbrn", ".lbrn2",
)
def _walk_files(base: Path) -> list[Path]:
"""Yield every file under `base` (recursive), tolerant of per-subfolder errors.
`Path.rglob("*")` raises on the first permission error; `os.walk` with an
error handler lets us skip individual subdirectories.
"""
results: list[Path] = []
def _on_error(err: OSError) -> None:
log.debug("Walk skipped (likely permission/IO): %s", err)
for root, _dirs, files in os.walk(base, onerror=_on_error):
for name in files:
results.append(Path(root) / name)
return results
def extract_sku_suffix(sku: str) -> str | None:
"""Pull the part-code suffix off a SKU.
Example: ``'8620843-0423L'`` -> ``'0423L'``. Returns ``None`` if the SKU
has no dash or has nothing after the first dash.
"""
if "-" not in sku:
return None
suffix = sku.split("-", 1)[1]
return suffix if suffix else None
def make_lightburn_sku(sku: str) -> str | None:
"""Derive the LightBurn filename SKU from a part SKU.
LightBurn files all share the internal part number ``'E000'`` as their
prefix followed by the portion of the part SKU after the first dash.
Returns ``None`` if no suffix can be extracted.
"""
suffix = extract_sku_suffix(sku)
if suffix is None:
return None
return "E000-" + suffix
def _whole_token_pattern(token: str) -> re.Pattern:
"""Return a compiled regex that matches `token` as a whole token in a filename."""
escaped = re.escape(token)
return re.compile(
r"(?:^|[^a-z0-9])" + escaped + r"(?:[^a-z0-9]|$)",
re.IGNORECASE,
)
def find_cut_files(base_dir: str, sku: str, *, limit: int = 40) -> list[Path]:
"""Find cut files anywhere under base_dir that match the SKU.
Match priority (highest first):
1. Exact stem match against the full SKU
2. Exact stem match against the suffix (portion after the first dash)
Returns a list of matching Paths, most specific match first.
"""
base = Path(base_dir)
if not base.exists():
log.warning("Cut file base dir does not exist: %s", base_dir)
return []
try:
all_files = _walk_files(base)
except Exception as exc:
log.error("Cut file base dir unreachable: %s (%s)", base_dir, exc)
return []
suffix = extract_sku_suffix(sku)
search_tokens = [sku]
if suffix and suffix.lower() != sku.lower():
search_tokens.append(suffix)
patterns = [_whole_token_pattern(t) for t in search_tokens]
matches: list[Path] = []
seen: set[str] = set()
for path in all_files:
stem = path.stem
for pat in patterns:
if pat.search(stem):
key = str(path)
if key not in seen:
seen.add(key)
matches.append(path)
break
if len(matches) >= limit:
break
return matches
def open_file(path: str) -> bool:
"""Open a file with the system default application. Cross-platform."""
try:
if sys.platform == "win32":
os.startfile(path)
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])
return True
except Exception as exc:
log.error("Failed to open %s: %s", path, exc)
return False
def reveal_in_explorer(path: str) -> bool:
"""Open the file's containing folder with the file selected (Windows)."""
try:
if sys.platform == "win32":
subprocess.Popen(["explorer", f"/select,{path}"])
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", path])
else:
subprocess.Popen(["xdg-open", str(Path(path).parent)])
return True
except Exception as exc:
log.error("Failed to reveal %s: %s", path, exc)
return False
class CutFileIndex:
"""In-memory snapshot of all cut files under one folder.
The first search of a session walks the SMB share once (potentially slow)
and caches the result. Subsequent lookups are O(1) dict access.
"""
def __init__(self, extensions: tuple[str, ...]) -> None:
self._extensions = extensions
self._indexed_path: str | None = None
self._built_at: float | None = None
self._index: dict[str, list[Path]] = {}
self._cut_file_count = 0
self._total_files_seen = 0
self._dirs_walked = 0
@property
def file_count(self) -> int:
return self._cut_file_count
@property
def indexed_path(self) -> str | None:
return self._indexed_path
@property
def built_at(self) -> float | None:
return self._built_at
@property
def stats(self) -> tuple[int, int, int]:
"""(cut_files_indexed, total_files_seen, dirs_walked)."""
return (self._cut_file_count, self._total_files_seen, self._dirs_walked)
def is_valid_for(self, path: str | None) -> bool:
"""True if the index reflects `path` (even if `path` is None)."""
return self._indexed_path == path and self._built_at is not None
def invalidate(self) -> None:
self._indexed_path = None
self._built_at = None
self._index.clear()
self._cut_file_count = 0
self._total_files_seen = 0
self._dirs_walked = 0
def build(self, path: str | None) -> int:
"""Walk `path` and replace the index. Returns the cut-file count.
Also records dirs_walked and files_seen so the UI can show a summary.
"""
if not path:
self.invalidate()
return 0
base = Path(path)
if not base.exists():
log.warning("Cut file base dir does not exist: %s", path)
self.invalidate()
return 0
new_index: dict[str, list[Path]] = {}
cut_count = 0
total_count = 0
dir_count = 0
def _on_error(err: OSError) -> None:
log.debug("Walk skipped (likely permission/IO): %s", err)
try:
for root, dirs, files in os.walk(base, onerror=_on_error):
dir_count += 1
for name in files:
total_count += 1
p = Path(root) / name
if p.suffix.lower() not in self._extensions:
continue
cut_count += 1
token = p.stem.lower()
new_index.setdefault(token, []).append(p)
except Exception as exc:
log.error("Cut file index build failed in %s: %s", path, exc)
self.invalidate()
return 0
self._index = new_index
self._indexed_path = path
self._built_at = time.monotonic()
self._cut_file_count = cut_count
self._total_files_seen = total_count
self._dirs_walked = dir_count
log.info(
"Cut file index built: %d cut files (of %d total) across %d folders under %s",
cut_count, total_count, dir_count, path,
)
return cut_count
def lookup(self, sku: str) -> list[Path]:
"""Match a SKU against the cached index using the same ranking as
`find_cut_files` (exact, suffix-as-token, full-SKU substring).
"""
suffix = extract_sku_suffix(sku)
search_tokens = [sku.lower()]
if suffix and suffix.lower() != sku.lower():
search_tokens.append(suffix.lower())
seen: set[str] = set()
results: list[Path] = []
for token_query in search_tokens:
pat = _whole_token_pattern(token_query)
for stem_token, paths in self._index.items():
if pat.search(stem_token):
for p in paths:
key = str(p)
if key not in seen:
seen.add(key)
results.append(p)
return results
class CutFileManager:
"""Holds saved cut-file folder paths, the active selection, and the
in-memory file index.
Persists path config to the shared ui_state.json dict via `save_fn` so
each workstation can have its own set of folders. Pass different
`state_key_paths` / `state_key_active` values to reuse this class for
both Roland cut files and LightBurn files.
"""
def __init__(
self,
state: dict,
save_fn: Callable[[dict], None],
extensions: tuple[str, ...],
state_key_paths: str | None = None,
state_key_active: str | None = None,
) -> None:
self._state = state
self._save_fn = save_fn
self._extensions = extensions
self._key_paths = state_key_paths or "cut_file_paths"
self._key_active = state_key_active or "active_cut_file_path"
raw = state.get(self._key_paths, [])
self._paths: list[dict[str, str]] = [
p for p in (raw if isinstance(raw, list) else [])
if isinstance(p, dict) and "name" in p and "path" in p
]
self._active_name: str = state.get(self._key_active, "") or ""
if self._active_name and not any(p["name"] == self._active_name for p in self._paths):
self._active_name = self._paths[0]["name"] if self._paths else ""
self._index = CutFileIndex(extensions)
self._lock = threading.Lock()
# ── Accessors ──────────────────────────────────────────────────────────────
def get_paths(self) -> list[dict[str, str]]:
return list(self._paths)
def get_active_name(self) -> str | None:
return self._active_name or None
def get_active_path(self) -> str | None:
for p in self._paths:
if p["name"] == self._active_name:
return p["path"]
return None
def index_file_count(self) -> int:
return self._index.file_count
def index_built_at(self) -> float | None:
return self._index.built_at
def index_stats(self) -> tuple[int, int, int]:
"""(cut_files_indexed, total_files_seen, dirs_walked)."""
return self._index.stats
def index_is_fresh_for_active(self) -> bool:
return self._index.is_valid_for(self.get_active_path())
# ── Mutations (each persists immediately) ──────────────────────────────────
def add_or_update(self, name: str, path: str) -> None:
for p in self._paths:
if p["name"] == name:
p["path"] = path
self._index.invalidate()
self._persist()
return
self._paths.append({"name": name, "path": path})
if not self._active_name:
self._active_name = name
self._index.invalidate()
self._persist()
def remove(self, name: str) -> None:
self._paths = [p for p in self._paths if p["name"] != name]
if self._active_name == name:
self._active_name = self._paths[0]["name"] if self._paths else ""
self._index.invalidate()
self._persist()
def set_active(self, name: str) -> None:
if any(p["name"] == name for p in self._paths):
if self._active_name != name:
self._active_name = name
self._index.invalidate()
self._persist()
def _persist(self) -> None:
self._state[self._key_paths] = list(self._paths)
self._state[self._key_active] = self._active_name
try:
self._save_fn(self._state)
except Exception as exc:
log.warning("Failed to persist cut file state: %s", exc)
# ── Search ─────────────────────────────────────────────────────────────────
def search_async(
self,
sku: str,
callback: Callable[[list[Path]], None],
) -> None:
"""Look up a SKU. Builds the index on first use or after a path switch."""
active_path = self.get_active_path()
def worker() -> None:
try:
with self._lock:
if not self._index.is_valid_for(active_path):
self._index.build(active_path)
results = self._index.lookup(sku)
callback(results)
except Exception:
log.exception("Cut file search blew up")
callback([])
threading.Thread(target=worker, daemon=True).start()
def refresh_index_async(
self,
callback: Callable[[int], None] | None = None,
) -> None:
"""Force-rebuild the index against the current active path."""
active_path = self.get_active_path()
def worker() -> None:
try:
with self._lock:
count = self._index.build(active_path)
if callback:
callback(count)
except Exception:
log.exception("Cut file index refresh failed")
if callback:
callback(0)
threading.Thread(target=worker, daemon=True).start()
# ── State serialisation (for destroy()) ────────────────────────────────────
def to_state(self) -> dict:
"""Return the manager's portion of ui_state.json."""
return {
self._key_paths: list(self._paths),
self._key_active: self._active_name,
}