Files
obsidian-wiki/scripts/manage_business_sync.py
Codex b85c26e0ba feat(cli): add unified user operations entrypoint
Add scripts/obsidian-wiki-ops.py as the repo-local command surface for common wiki, Kingdee, timer, daily update, and MCP operations.

Route the existing business and MCP shell wrappers through the unified entrypoint for compatibility, and update docs to recommend the single command path.

Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check.
2026-07-06 21:21:14 +00:00

293 lines
10 KiB
Python

#!/usr/bin/env python3
"""Manage the user-level Kingdee business fact sync timer."""
from __future__ import annotations
import argparse
import os
import shlex
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from obsidian_wiki.config import resolve_vault_path
SERVICE_NAME = "obsidian-wiki-business-sync.service"
TIMER_NAME = "obsidian-wiki-business-sync.timer"
DEFAULT_CONFIG_RELATIVE_PATH = Path(".runtime") / "kingdee.env"
DEFAULT_ON_CALENDAR = "*-*-* 07:10:00"
DEFAULT_RANDOMIZED_DELAY_SEC = "15m"
DEFAULT_LOOKBACK_HOURS = 48
DEFAULT_INITIAL_DAYS = 7
PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
@dataclass(frozen=True)
class BusinessSyncServicePaths:
project_root: Path
service_unit_path: Path
timer_unit_path: Path
config_path: Path
def systemd_quote(value: str | Path) -> str:
return shlex.quote(str(value))
def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> BusinessSyncServicePaths:
root = project_root.expanduser().resolve()
base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
unit_dir = base_config.expanduser() / "systemd" / "user"
return BusinessSyncServicePaths(
project_root=root,
service_unit_path=unit_dir / SERVICE_NAME,
timer_unit_path=unit_dir / TIMER_NAME,
config_path=root / DEFAULT_CONFIG_RELATIVE_PATH,
)
def resolve_vault_for_service(project_root: Path, vault_path: Path | str | None = None) -> Path:
if vault_path is not None:
return Path(vault_path).expanduser().resolve()
configured = resolve_vault_path(None, start=project_root)
if configured:
return Path(configured).expanduser().resolve()
return project_root.expanduser().resolve()
def render_service_unit(
*,
project_root: Path,
vault_path: Path,
config_path: Path,
python_path: Path | None = None,
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
initial_days: int = DEFAULT_INITIAL_DAYS,
) -> str:
resolved_python = python_path or Path(sys.executable)
command = " ".join(
[
systemd_quote(resolved_python),
"-m",
"obsidian_wiki",
"business",
"sync",
"kingdee",
"--vault",
systemd_quote(vault_path),
"--config",
systemd_quote(config_path),
"--incremental",
"--lookback-hours",
str(lookback_hours),
"--initial-days",
str(initial_days),
]
)
return (
"[Unit]\n"
"Description=obsidian-wiki Kingdee business fact sync\n"
"After=network-online.target\n"
"Wants=network-online.target\n"
"\n"
"[Service]\n"
"Type=oneshot\n"
f"WorkingDirectory={systemd_quote(project_root)}\n"
f"EnvironmentFile={systemd_quote(config_path)}\n"
f"PassEnvironment={' '.join(PROXY_ENV_NAMES)}\n"
f"ExecStart={command}\n"
)
def render_timer_unit(
*,
on_calendar: str = DEFAULT_ON_CALENDAR,
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
) -> str:
return (
"[Unit]\n"
"Description=Run obsidian-wiki Kingdee business sync on a schedule\n"
"\n"
"[Timer]\n"
f"OnCalendar={on_calendar}\n"
f"RandomizedDelaySec={randomized_delay_sec}\n"
"Persistent=true\n"
f"Unit={SERVICE_NAME}\n"
"\n"
"[Install]\n"
"WantedBy=timers.target\n"
)
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(["systemctl", "--user", *args], text=True, check=check)
def write_units(
paths: BusinessSyncServicePaths,
*,
vault_path: Path,
python_path: Path | None = None,
on_calendar: str = DEFAULT_ON_CALENDAR,
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
initial_days: int = DEFAULT_INITIAL_DAYS,
) -> None:
paths.service_unit_path.parent.mkdir(parents=True, exist_ok=True)
paths.service_unit_path.write_text(
render_service_unit(
project_root=paths.project_root,
vault_path=vault_path,
config_path=paths.config_path,
python_path=python_path,
lookback_hours=lookback_hours,
initial_days=initial_days,
),
encoding="utf-8",
)
paths.timer_unit_path.write_text(
render_timer_unit(on_calendar=on_calendar, randomized_delay_sec=randomized_delay_sec),
encoding="utf-8",
)
def start_timer(
*,
project_root: Path,
vault_path: Path | str | None = None,
python_path: Path | None = None,
on_calendar: str = DEFAULT_ON_CALENDAR,
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
initial_days: int = DEFAULT_INITIAL_DAYS,
) -> str:
paths = resolve_service_paths(project_root)
if not paths.config_path.exists():
raise RuntimeError(
f"Missing Kingdee config file: {paths.config_path}. "
"Run `scripts/obsidian-wiki-ops.py kingdee config` first."
)
resolved_vault = resolve_vault_for_service(paths.project_root, vault_path=vault_path)
write_units(
paths,
vault_path=resolved_vault,
python_path=python_path,
on_calendar=on_calendar,
randomized_delay_sec=randomized_delay_sec,
lookback_hours=lookback_hours,
initial_days=initial_days,
)
run_systemctl(["daemon-reload"])
run_systemctl(["enable", "--now", TIMER_NAME])
return (
"Business sync timer is enabled.\n"
f"Service: {paths.service_unit_path}\n"
f"Timer: {paths.timer_unit_path}\n"
f"Vault: {resolved_vault}\n"
f"Config: {paths.config_path}\n"
f"Schedule: {on_calendar} randomized by {randomized_delay_sec}\n"
)
def run_now() -> None:
run_systemctl(["start", SERVICE_NAME])
def stop_timer() -> None:
run_systemctl(["disable", "--now", TIMER_NAME], check=False)
def uninstall_timer(project_root: Path) -> None:
paths = resolve_service_paths(project_root)
stop_timer()
if paths.service_unit_path.exists():
paths.service_unit_path.unlink()
if paths.timer_unit_path.exists():
paths.timer_unit_path.unlink()
run_systemctl(["daemon-reload"], check=False)
run_systemctl(["reset-failed", SERVICE_NAME], check=False)
run_systemctl(["reset-failed", TIMER_NAME], check=False)
def timer_status() -> None:
run_systemctl(["status", TIMER_NAME], check=False)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Manage the Kingdee business sync user timer.")
subparsers = parser.add_subparsers(dest="command", required=True)
start_parser = subparsers.add_parser("start", help="Install or update the user timer.")
start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
start_parser.add_argument("--vault", dest="vault_path", type=Path, help="Vault path to sync into.")
start_parser.add_argument("--python", dest="python_path", type=Path, help="Python executable for the systemd service.")
start_parser.add_argument("--on-calendar", default=DEFAULT_ON_CALENDAR)
start_parser.add_argument("--randomized-delay-sec", default=DEFAULT_RANDOMIZED_DELAY_SEC)
start_parser.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS)
start_parser.add_argument("--initial-days", type=int, default=DEFAULT_INITIAL_DAYS)
run_now_parser = subparsers.add_parser("run-now", help="Run the sync service immediately.")
run_now_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
run_now_parser.add_argument("--vault", dest="vault_path", type=Path)
run_now_parser.add_argument("--python", dest="python_path", type=Path)
run_now_parser.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS)
run_now_parser.add_argument("--initial-days", type=int, default=DEFAULT_INITIAL_DAYS)
subparsers.add_parser("status", help="Show timer status.")
subparsers.add_parser("stop", help="Disable and stop the timer.")
uninstall_parser = subparsers.add_parser("uninstall", help="Remove managed timer/service unit files.")
uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
args = parser.parse_args(argv)
if args.command == "start":
print(
start_timer(
project_root=args.project_root,
vault_path=args.vault_path,
python_path=args.python_path,
on_calendar=args.on_calendar,
randomized_delay_sec=args.randomized_delay_sec,
lookback_hours=args.lookback_hours,
initial_days=args.initial_days,
),
end="",
)
return 0
if args.command == "run-now":
paths = resolve_service_paths(args.project_root)
if not paths.service_unit_path.exists():
start_timer(
project_root=args.project_root,
vault_path=args.vault_path,
python_path=args.python_path,
lookback_hours=args.lookback_hours,
initial_days=args.initial_days,
)
run_now()
print(f"Started {SERVICE_NAME}.")
return 0
if args.command == "status":
timer_status()
return 0
if args.command == "stop":
stop_timer()
print(f"Stopped {TIMER_NAME}.")
return 0
if args.command == "uninstall":
uninstall_timer(args.project_root)
print(f"Uninstalled {TIMER_NAME}.")
return 0
raise AssertionError(f"unhandled command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())