Initial commit
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user