Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion MODULARIZATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,33 @@ domain/
5. Move command handlers after services are UI-neutral.
6. Split large UI pages only after their services are stable.

## Current Progress

- `unityscraper.core.paths` owns application storage and resource resolution;
`app_paths.py` is a compatibility wrapper.
- `unityscraper.core.version` owns version constants; `app_version.py` is a
compatibility wrapper.
- `unityscraper.core.metadata` exposes app name, slug, and version metadata for
UI, CLI, API, diagnostics, and packaging.
- `unityscraper.core.jobs` exposes shared job results, progress events,
cancellation tokens, contexts, and a synchronous runner for future desktop,
CLI, and API workflow reuse.
- `unityscraper.app.cli` has a command registry and lazy legacy CLI adapter so
package command discovery does not import the full scraper runtime.
- `unityscraper.app.api` and `unityscraper.app.desktop` keep package imports
light by importing their legacy runtime only when their surface starts.
- `unityscraper.domains.packages` exposes read-only package models and
inspectors.
- `unityscraper.domains.packages.commands` exposes the first UI-neutral
package use cases for STFS inspection and file-table inventory.
- `unityscraper.domains.backups` exposes backup models and operations.
- `unityscraper.domains.backups.migrations` owns the backup schema function;
`backup_service.ensure_backup_schema` remains import-compatible.
- `unityscraper.domains.profiles` exposes profile/save models and helpers.
- `unityscraper.domains.knowledge`, `unityscraper.domains.library`, and
`unityscraper.domains.tools` expose first-class model, repository, catalog,
and runner modules around their existing implementations.

## Feature Ownership

| Domain | Owns |
Expand All @@ -93,4 +120,3 @@ domain/
New Xbox 360 capabilities should enter as domain use cases first. Desktop
buttons, REST routes, and CLI flags should call those use cases rather than
owning file, database, FTP, or package logic themselves.

4 changes: 3 additions & 1 deletion UnityScraper.spec
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import sys
from pathlib import Path

from PyInstaller.utils.hooks import collect_submodules


icon = 'assets/UnityScraper.ico' if sys.platform == 'win32' else None
tk_datas = []
Expand All @@ -24,7 +26,7 @@ a = Analysis(
('THIRD_PARTY_NOTICES.md', '.'),
('assets', 'assets'),
] + tk_datas,
hiddenimports=[
hiddenimports=collect_submodules('unityscraper') + [
'backup_gui',
'backup_manager',
'backup_service',
Expand Down
209 changes: 2 additions & 207 deletions app_paths.py
Original file line number Diff line number Diff line change
@@ -1,210 +1,5 @@
"""Cross-platform application storage and bundled-resource paths."""
"""Compatibility wrapper for package-owned application paths."""

from __future__ import annotations

import os
import posixpath
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping

APP_NAME = "UnityScraper"
APP_SLUG = "unityscraper"


@dataclass(frozen=True)
class StoragePaths:
"""Resolved writable directories for one UnityScraper installation."""

base: Path
downloads: Path
logs: Path
config: Path
data: Path
cache: Path
exports: Path
diagnostics: Path


def app_root() -> Path:
"""Return the directory containing bundled application resources."""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS)
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent


def executable_root() -> Path:
"""Return the directory beside the executable or source checkout."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent


def portable_mode_enabled() -> bool:
"""Return True when the application should store all data locally."""
env_enabled = os.environ.get("UNITYSCRAPER_PORTABLE", "").strip() == "1"
marker_enabled = (executable_root() / "portable.mode").exists()
return env_enabled or marker_enabled


def resolve_storage_paths(
*,
os_name: str | None = None,
platform_name: str | None = None,
environ: Mapping[str, str] | None = None,
home: Path | None = None,
portable_root: Path | None = None,
) -> StoragePaths:
"""Resolve platform-native paths without creating them.

Explicit parameters keep path behavior straightforward to test on any host.
"""
current_os = os_name or os.name
current_platform = platform_name or sys.platform
env = environ if environ is not None else os.environ
user_home = Path(home) if home is not None else Path.home()

def xdg_path(variable: str, fallback: Path) -> Path:
value = env.get(variable)
if value:
candidate = Path(value).expanduser()
if posixpath.isabs(value):
return candidate
return fallback

if portable_root is not None:
base = Path(portable_root) / "UnityScraperData"
return StoragePaths(
base=base,
downloads=base / "downloads",
logs=base / "logs",
config=base / "config",
data=base / "data",
cache=base / "cache",
exports=base / "exports",
diagnostics=base / "diagnostics",
)

if current_os == "nt":
root = env.get("LOCALAPPDATA") or env.get("APPDATA")
base = Path(root) / APP_NAME if root else user_home / APP_NAME
return StoragePaths(
base=base,
downloads=base / "downloads",
logs=base / "logs",
config=base / "config",
data=base / "data",
cache=base / "cache",
exports=base / "exports",
diagnostics=base / "diagnostics",
)

if current_platform == "darwin":
base = user_home / "Library" / "Application Support" / APP_NAME
return StoragePaths(
base=base,
downloads=base / "downloads",
logs=user_home / "Library" / "Logs" / APP_NAME,
config=base / "config",
data=base / "data",
cache=user_home / "Library" / "Caches" / APP_NAME,
exports=base / "exports",
diagnostics=base / "diagnostics",
)

data_home = xdg_path("XDG_DATA_HOME", user_home / ".local" / "share")
config_home = xdg_path("XDG_CONFIG_HOME", user_home / ".config")
cache_home = xdg_path("XDG_CACHE_HOME", user_home / ".cache")
state_home = xdg_path("XDG_STATE_HOME", user_home / ".local" / "state")
base = data_home / APP_SLUG
return StoragePaths(
base=base,
downloads=base / "downloads",
logs=state_home / APP_SLUG / "logs",
config=config_home / APP_SLUG,
data=base / "data",
cache=cache_home / APP_SLUG,
exports=base / "exports",
diagnostics=base / "diagnostics",
)


_PATHS = resolve_storage_paths(
portable_root=executable_root() if portable_mode_enabled() else None
)

BASE_DIR = _PATHS.base
DOWNLOADS_DIR = _PATHS.downloads
LOG_DIR = _PATHS.logs
CONFIG_DIR = _PATHS.config
DATA_DIR = _PATHS.data
CACHE_DIR = _PATHS.cache
EXPORTS_DIR = _PATHS.exports
DIAGNOSTICS_DIR = _PATHS.diagnostics
PROFILE_BACKUPS_DIR = DATA_DIR / "profile_backups"
PLUGINS_DIR = DATA_DIR / "plugins"
LANGUAGE_PACKS_DIR = DATA_DIR / "languages"
OFFLINE_KNOWLEDGE_DIR = DATA_DIR / "offline_knowledge"

DATABASE_PATH = DATA_DIR / "unityscraper.db"
CONFIG_PATH = CONFIG_DIR / "config.json"
TITLEIDS_PATH = CONFIG_DIR / "JSON.txt"
CLI_LOG_PATH = LOG_DIR / "unityscraper.log"
GUI_LOG_PATH = LOG_DIR / "unityscraper_gui.log"
FIRST_RUN_PATH = CONFIG_DIR / "first_run_complete"


def resource_path(*parts: str) -> Path:
"""Return a bundled resource path in source and PyInstaller builds."""
return app_root().joinpath(*parts)


def ensure_app_dirs() -> None:
"""Create every writable application directory."""
for path in (
BASE_DIR,
DOWNLOADS_DIR,
LOG_DIR,
CONFIG_DIR,
DATA_DIR,
CACHE_DIR,
EXPORTS_DIR,
DIAGNOSTICS_DIR,
PROFILE_BACKUPS_DIR,
PLUGINS_DIR,
LANGUAGE_PACKS_DIR,
OFFLINE_KNOWLEDGE_DIR,
):
path.mkdir(parents=True, exist_ok=True)


def ensure_user_titleids_file() -> Path:
"""Create the user's editable TitleID list when it does not yet exist."""
ensure_app_dirs()

if not TITLEIDS_PATH.exists():
bundled = resource_path("JSON.txt")
if bundled.exists():
shutil.copyfile(bundled, TITLEIDS_PATH)
else:
TITLEIDS_PATH.write_text("", encoding="utf-8")

return TITLEIDS_PATH


def describe_storage() -> str:
"""Return a human-readable storage summary for diagnostics and the UI."""
mode = "Portable" if portable_mode_enabled() else "Installed"
return (
f"Mode: {mode}\n"
f"Data: {DATA_DIR}\n"
f"Downloads: {DOWNLOADS_DIR}\n"
f"Exports: {EXPORTS_DIR}\n"
f"Config: {CONFIG_DIR}\n"
f"Cache: {CACHE_DIR}\n"
f"Logs: {LOG_DIR}"
)
from unityscraper.core.paths import * # noqa: F403
7 changes: 4 additions & 3 deletions app_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Single source of truth for UnityScraper version information."""
"""Compatibility wrapper for package-owned version information."""

APP_VERSION = "1.2.0b1"
DISPLAY_VERSION = "1.2.0-beta.1"
from __future__ import annotations

from unityscraper.core.version import * # noqa: F403
69 changes: 1 addition & 68 deletions backup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,74 +24,7 @@
scan_local_target,
verify_backup_item,
)


def ensure_backup_schema(connection: sqlite3.Connection) -> None:
"""Create additive backup-manager tables on an existing connection."""
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS backup_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('local', 'ftp')),
location TEXT NOT NULL,
settings_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(kind, location)
);

CREATE TABLE IF NOT EXISTS backup_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER,
location TEXT NOT NULL,
status TEXT NOT NULL,
item_count INTEGER NOT NULL DEFAULT 0,
total_size INTEGER NOT NULL DEFAULT 0,
warnings_json TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
error_message TEXT,
FOREIGN KEY (target_id) REFERENCES backup_targets(id)
);

CREATE TABLE IF NOT EXISTS backup_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL,
titleid TEXT,
name TEXT NOT NULL,
format TEXT NOT NULL,
content_type TEXT,
media_id TEXT,
path TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
notes_json TEXT,
FOREIGN KEY (scan_id) REFERENCES backup_scans(id)
);

CREATE TABLE IF NOT EXISTS backup_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation TEXT NOT NULL,
source TEXT NOT NULL,
destination TEXT,
status TEXT NOT NULL,
bytes_copied INTEGER NOT NULL DEFAULT 0,
sha256 TEXT,
details_json TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
error_message TEXT
);

CREATE INDEX IF NOT EXISTS idx_backup_inventory_titleid
ON backup_inventory(titleid);
CREATE INDEX IF NOT EXISTS idx_backup_inventory_scan
ON backup_inventory(scan_id);
CREATE INDEX IF NOT EXISTS idx_backup_operations_status
ON backup_operations(status, started_at);
"""
)
from unityscraper.domains.backups.migrations import ensure_backup_schema


class BackupRepository:
Expand Down
6 changes: 3 additions & 3 deletions scripts/check_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@


def read_versions() -> dict[str, str]:
app_text = (ROOT / "app_version.py").read_text(encoding="utf-8")
app_text = (ROOT / "unityscraper/core/version.py").read_text(encoding="utf-8")
app_match = re.search(r'^APP_VERSION\s*=\s*"([^"]+)"', app_text, re.MULTILINE)
if not app_match:
raise RuntimeError("APP_VERSION was not found in app_version.py")
raise RuntimeError("APP_VERSION was not found in unityscraper/core/version.py")

version_data = json.loads((ROOT / "VERSION").read_text(encoding="utf-8"))
pyproject_text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
Expand All @@ -37,7 +37,7 @@ def read_versions() -> dict[str, str]:
raise RuntimeError("A release version was not found in Linux AppStream metadata")

return {
"app_version.py": app_match.group(1),
"unityscraper/core/version.py": app_match.group(1),
"VERSION": str(version_data["version"]),
"pyproject.toml": project_match.group(1),
"Linux AppStream metadata": str(release.get("version")),
Expand Down
Loading