mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
- Rename directory: paperforge_lite/ → paperforge/ - Update pyproject.toml: package name, entry point, includes - Verified: python -m paperforge --help works
291 lines
9.6 KiB
Python
291 lines
9.6 KiB
Python
"""PaperForge Lite — shared configuration and path resolver.
|
|
|
|
Configuration precedence (D-Configuration Hierarchy):
|
|
1. Explicit overrides (function parameter)
|
|
2. Process environment variables (PAPERFORGE_*)
|
|
3. paperforge.json nested vault_config block
|
|
4. paperforge.json top-level keys (legacy backward-compat)
|
|
5. Built-in defaults
|
|
|
|
All path construction uses pathlib.Path. No OCR secrets are loaded here.
|
|
No global os.environ mutation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# .env loader
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_simple_env(env_path: Path) -> None:
|
|
"""Load key=value pairs from a .env file into os.environ (no overwrite)."""
|
|
if not env_path.exists():
|
|
return
|
|
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
if not key or key in os.environ:
|
|
continue
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
value = value[1:-1]
|
|
os.environ[key] = value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DEFAULT_CONFIG: dict[str, str] = {
|
|
"system_dir": "99_System",
|
|
"resources_dir": "03_Resources",
|
|
"literature_dir": "Literature",
|
|
"control_dir": "LiteratureControl",
|
|
"base_dir": "05_Bases",
|
|
"skill_dir": ".opencode/skills",
|
|
"command_dir": ".opencode/command",
|
|
}
|
|
|
|
# Environment variable name map — maps config key to PAPERFORGE_* env var
|
|
ENV_KEYS: dict[str, str] = {
|
|
"vault": "PAPERFORGE_VAULT",
|
|
"system_dir": "PAPERFORGE_SYSTEM_DIR",
|
|
"resources_dir": "PAPERFORGE_RESOURCES_DIR",
|
|
"literature_dir": "paperforgeRATURE_DIR",
|
|
"control_dir": "PAPERFORGE_CONTROL_DIR",
|
|
"base_dir": "PAPERFORGE_BASE_DIR",
|
|
"skill_dir": "PAPERFORGE_SKILL_DIR",
|
|
"command_dir": "PAPERFORGE_COMMAND_DIR",
|
|
}
|
|
|
|
# All config keys accepted by load_vault_config
|
|
CONFIG_KEYS: set[str] = set(DEFAULT_CONFIG.keys())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON reader
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def read_paperforge_json(vault: Path) -> dict[str, Any]:
|
|
"""Read and parse paperforge.json, returning raw key-value pairs.
|
|
|
|
Supports both legacy top-level keys and nested ``vault_config`` block.
|
|
|
|
Returns an empty dict if the file does not exist or is invalid JSON.
|
|
"""
|
|
path = vault / "paperforge.json"
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Vault resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def resolve_vault(
|
|
cli_vault: Path | None = None,
|
|
env: dict[str, str] | None = None,
|
|
cwd: Path | None = None,
|
|
) -> Path:
|
|
"""Resolve the vault path using the locked precedence order.
|
|
|
|
Returns:
|
|
Path to the vault root directory.
|
|
|
|
Precedence:
|
|
1. Explicit ``cli_vault`` argument
|
|
2. ``PAPERFORGE_VAULT`` environment variable
|
|
3. Current directory or nearest parent containing ``paperforge.json``
|
|
4. ``cwd`` argument as final fallback
|
|
"""
|
|
# 1. Explicit CLI vault
|
|
if cli_vault is not None:
|
|
return Path(cli_vault).expanduser().resolve()
|
|
|
|
env = env if env is not None else os.environ
|
|
|
|
# 2. Environment variable
|
|
if ENV_KEYS["vault"] in env:
|
|
return Path(env[ENV_KEYS["vault"]]).expanduser().resolve()
|
|
|
|
# 3. Scan cwd upward for paperforge.json
|
|
if cwd is not None:
|
|
search: Path | None = Path(cwd).expanduser().resolve()
|
|
else:
|
|
search = Path.cwd()
|
|
|
|
while search is not None and search != search.parent:
|
|
if (search / "paperforge.json").exists():
|
|
return search
|
|
search = search.parent
|
|
|
|
# 4. Fallback to cwd
|
|
return Path(cwd).expanduser().resolve() if cwd is not None else Path.cwd()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_vault_config(
|
|
vault: Path,
|
|
env: dict[str, str] | None = None,
|
|
overrides: dict[str, str] | None = None,
|
|
) -> dict[str, str]:
|
|
"""Load the full PaperForge configuration for a vault.
|
|
|
|
Merges configuration sources in locked precedence order:
|
|
1. Built-in defaults
|
|
2. paperforge.json nested ``vault_config`` block
|
|
3. paperforge.json top-level keys (legacy backward-compat)
|
|
4. Process environment variables
|
|
5. Explicit ``overrides`` dict
|
|
|
|
Args:
|
|
vault: Path to the vault root.
|
|
env: Optional dict of environment variables. If None, uses os.environ.
|
|
overrides: Optional dict of explicit overrides. Highest precedence.
|
|
|
|
Returns:
|
|
Dict with keys: system_dir, resources_dir, literature_dir, control_dir,
|
|
base_dir, skill_dir, command_dir.
|
|
"""
|
|
env = env if env is not None else os.environ
|
|
|
|
# Start with defaults
|
|
config: dict[str, str] = dict(DEFAULT_CONFIG)
|
|
|
|
# Read paperforge.json
|
|
pf_data = read_paperforge_json(vault)
|
|
|
|
# 2. Merge nested vault_config block
|
|
nested = pf_data.get("vault_config", {})
|
|
if isinstance(nested, dict):
|
|
for key in CONFIG_KEYS:
|
|
if key in nested and nested[key]:
|
|
config[key] = nested[key]
|
|
|
|
# 3. Merge top-level legacy keys (override nested for backward compat)
|
|
for key in CONFIG_KEYS:
|
|
if key in pf_data and pf_data[key]:
|
|
config[key] = pf_data[key]
|
|
|
|
# 4. Merge environment variables
|
|
for config_key, env_var in ENV_KEYS.items():
|
|
if config_key == "vault":
|
|
continue # vault is handled separately in resolve_vault
|
|
if env_var in env and env[env_var]:
|
|
config[config_key] = env[env_var]
|
|
|
|
# 5. Merge explicit overrides (highest precedence)
|
|
if overrides:
|
|
for key in CONFIG_KEYS:
|
|
if key in overrides and overrides[key]:
|
|
config[key] = overrides[key]
|
|
|
|
return config
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def paperforge_paths(
|
|
vault: Path,
|
|
cfg: dict[str, str] | None = None,
|
|
) -> dict[str, Path]:
|
|
"""Build the complete PaperForge path inventory for a vault.
|
|
|
|
Returns absolute Path objects for every user-facing and worker-facing
|
|
location used by PaperForge Lite.
|
|
|
|
Args:
|
|
vault: Path to the vault root.
|
|
cfg: Optional config dict from load_vault_config. If None, loads it.
|
|
|
|
Returns:
|
|
Dict with keys:
|
|
- vault: vault root
|
|
- system: <vault>/<system_dir>
|
|
- paperforge: <vault>/<system_dir>/PaperForge
|
|
- exports: <paperforge>/exports (Better BibTeX JSON exports)
|
|
- ocr: <paperforge>/ocr
|
|
- resources: <vault>/<resources_dir>
|
|
- literature: <resources>/<literature_dir>
|
|
- control: <resources>/<control_dir>
|
|
- library_records: <control>/library-records
|
|
- bases: <vault>/<base_dir>
|
|
- worker_script: pipeline/worker/scripts/literature_pipeline.py
|
|
- skill_dir: <vault>/<skill_dir>
|
|
- ld_deep_script: <skill_dir>/literature-qa/scripts/ld_deep.py
|
|
"""
|
|
if cfg is None:
|
|
cfg = load_vault_config(vault)
|
|
|
|
vault = Path(vault).expanduser().resolve()
|
|
system_dir = cfg["system_dir"]
|
|
resources_dir = cfg["resources_dir"]
|
|
literature_dir = cfg["literature_dir"]
|
|
control_dir = cfg["control_dir"]
|
|
base_dir = cfg["base_dir"]
|
|
skill_dir = cfg["skill_dir"]
|
|
|
|
system = vault / system_dir
|
|
paperforge = system / "PaperForge"
|
|
resources = vault / resources_dir
|
|
literature = resources / literature_dir
|
|
control = resources / control_dir
|
|
bases = vault / base_dir
|
|
skill_path = vault / skill_dir
|
|
|
|
# Resolve script paths relative to vault (for portability in copied installs)
|
|
# worker_script: look relative to vault root
|
|
worker_script = vault / "pipeline" / "worker" / "scripts" / "literature_pipeline.py"
|
|
# ld_deep_script: look relative to skill_dir
|
|
ld_deep_script = skill_path / "literature-qa" / "scripts" / "ld_deep.py"
|
|
|
|
return {
|
|
"vault": vault,
|
|
"system": system,
|
|
"paperforge": paperforge,
|
|
"exports": paperforge / "exports",
|
|
"ocr": paperforge / "ocr",
|
|
"resources": resources,
|
|
"literature": literature,
|
|
"control": control,
|
|
"library_records": control / "library-records",
|
|
"bases": bases,
|
|
"worker_script": worker_script,
|
|
"skill_dir": skill_path,
|
|
"ld_deep_script": ld_deep_script,
|
|
}
|
|
|
|
|
|
def paths_as_strings(paths: dict[str, Path]) -> dict[str, str]:
|
|
"""Convert a paperforge_paths dict to JSON-serializable dict[str, str].
|
|
|
|
All Path values are converted to their string representation.
|
|
Key names are preserved exactly.
|
|
|
|
Args:
|
|
paths: Output of paperforge_paths().
|
|
|
|
Returns:
|
|
dict mapping path names to string paths.
|
|
"""
|
|
return {name: str(path) for name, path in paths.items()}
|