Files
cmbot/src/services/file_service.py
T
adminandClaude Opus 4.8 76f2c6dd5b feat: separate writable data dir from program dir (LAN update groundwork)
- add get_data_dir() with CMBOT_DATA_DIR override, fallback to program dir
- point config/logs/output at the data root; resources stay on program root
- harden config/template writes with parents=True for a deep data root
- add docs/10-lan-update.md (LAN auto-update design); default install root
  %LOCALAPPDATA%\CMBot for elevation-free self-update

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:29:47 +08:00

158 lines
5.5 KiB
Python

import logging
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import List
logger = logging.getLogger(__name__)
SUPPORTED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
def is_supported_image(path):
"""Return True if path has a supported image extension (case-insensitive)."""
return Path(path).suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
def get_app_dir():
"""Return the program root directory (read-only program files) as a Path.
PyInstaller onedir: directory that contains the .exe. In the versioned
install layout (docs/10-lan-update.md) this is versions/<x.y.z>/, which is
replaced wholesale on update.
Development: project root (three levels above this file:
src/services/file_service.py -> src/services -> src -> project root).
Resources live here; writable user data does not — use get_data_dir().
"""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent.parent
def get_data_dir():
"""Return the writable data root (config, templates, logs, output) as a Path.
Kept separate from the program root so that replacing the program on update
never touches user data (docs/10-lan-update.md §5).
Resolution order:
1. CMBOT_DATA_DIR environment variable, when set. The launcher points this
at the install-wide data/ folder in the versioned layout.
2. Fallback to get_app_dir() — the flat layout used in development and in
the current onedir release, where data sits next to the program.
"""
env = os.environ.get("CMBOT_DATA_DIR", "").strip()
if env:
return Path(env)
return get_app_dir()
def get_resource_path(relative_path):
"""Return absolute Path for a resource file under the program root.
Development resources live under src/resources/. In a PyInstaller onedir
build, resources are copied next to the executable under resources/.
"""
if getattr(sys, "frozen", False):
return get_app_dir() / "resources" / relative_path
return get_app_dir() / "src" / "resources" / relative_path
def get_config_path(relative_path):
"""Return absolute Path for a file under <data_dir>/config/."""
return get_data_dir() / "config" / relative_path
def get_log_dir():
"""Return <data_dir>/logs/ as a Path, creating the directory if absent."""
d = get_data_dir() / "logs"
d.mkdir(parents=True, exist_ok=True)
return d
def get_output_dir():
"""Return <data_dir>/output/ as a Path, creating the directory if absent."""
d = get_data_dir() / "output"
d.mkdir(parents=True, exist_ok=True)
return d
# ---------------------------------------------------------------------------
# 文件夹扫描
# ---------------------------------------------------------------------------
def scan_image_folder(folder) -> List:
"""Recursively scan *folder* for supported images.
Returns a list of ImageAsset objects (selected=True by default).
Skips unsupported files and logs them at DEBUG level.
Raises OSError if *folder* does not exist or is not a directory.
"""
from core.models import ImageAsset
folder = Path(folder)
if not folder.exists():
raise OSError("Folder not found: {}".format(folder))
if not folder.is_dir():
raise OSError("Path is not a directory: {}".format(folder))
assets = []
skipped_count = 0
for entry in sorted(folder.rglob("*")):
if not entry.is_file():
continue
if is_supported_image(entry):
assets.append(ImageAsset(path=entry))
else:
skipped_count += 1
logger.debug("Skipped unsupported file: %s", entry)
if skipped_count:
logger.info(
"Skipped %d unsupported file(s) in %s", skipped_count, folder
)
logger.info("Found %d image(s) in %s", len(assets), folder)
return assets
# ---------------------------------------------------------------------------
# 安全输出文件名
# ---------------------------------------------------------------------------
def timestamped_run_dir(base_dir):
"""Return base_dir/<YYYYmmdd_HHMMSS> for grouping one export run.
Compute this once per export run (a batch, or a single export) and pass the
result as the output_dir to make_safe_output_path, so repeated runs land in
separate timestamped folders instead of mixing together.
"""
return Path(base_dir) / datetime.now().strftime("%Y%m%d_%H%M%S")
def make_safe_output_path(output_dir, garment_path, print_path, output_format="PNG"):
"""Return an output Path that will not overwrite an existing file.
Outputs are grouped by print into a subfolder named after the print file:
<output_dir>/<print_stem>/<garment_stem>_<print_stem>.<ext>
If that path exists, appends _1, _2, ... until a free slot is found.
(The composer creates the subfolder when saving.)
"""
ext = ".png" if output_format.upper() == "PNG" else ".jpg"
target_dir = Path(output_dir) / Path(print_path).stem
stem = "{}_{}".format(Path(garment_path).stem, Path(print_path).stem)
candidate = target_dir / (stem + ext)
counter = 1
while candidate.exists():
candidate = target_dir / ("{}_{}{}".format(stem, counter, ext))
counter += 1
return candidate