v0.1.0 - initial release #1

Merged
peisongxiao merged 12 commits from v0.1.0 into master 2026-02-08 23:38:12 +00:00
7 changed files with 337 additions and 74 deletions
Showing only changes of commit 122b7ea348 - Show all commits

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ __pycache__/
*.egg-info/ *.egg-info/
.env .env
.venv/ .venv/
testing/**/*

View File

@@ -104,7 +104,27 @@ wp-materialize local /path/to/output
Notes: Notes:
1. The local export assumes every post is new and generates create commands. 1. The local export assumes every post is new and generates create commands.
2. Categories must already exist in WordPress for exact commands. 2. The local export does not call WordPress or resolve category IDs.
Create placeholder config or manifest:
```bash
wp-materialize new --config
wp-materialize new --config /path/to/config.json
wp-materialize new --manifest /path/to/content
```
Add files or subdirectories to a manifest (no evaluation):
```bash
wp-materialize add-file /path/to/content/post.md
wp-materialize add-file /path/to/content/post.md /path/to/content
wp-materialize add-file /path/to/content/post.md --current
wp-materialize add-subdir /path/to/content/notes
wp-materialize add-subdir /path/to/content/notes /path/to/content
wp-materialize add-subdir /path/to/content/notes --current
```
## Manifests ## Manifests
@@ -116,6 +136,11 @@ Each managed directory must contain a `.wp-materialize.json` manifest. See `conf
2. Packages: 2. Packages:
- `Markdown>=3.6` - `Markdown>=3.6`
## System Prerequisites
1. `wp` CLI must be installed and available in PATH for `apply`.
2. `local` does not require `wp`.
Install dependencies: Install dependencies:
```bash ```bash

View File

@@ -111,3 +111,34 @@ Subdirectory manifest (`design/.wp-materialize.json`):
- `git_repositories` entries use git commit timestamps for `created_on`/`last_modified` inference. - `git_repositories` entries use git commit timestamps for `created_on`/`last_modified` inference.
- `directories` entries use filesystem timestamps even if the path is inside a git repo. - `directories` entries use filesystem timestamps even if the path is inside a git repo.
## Scaffold Command Examples
Create a placeholder config:
```bash
wp-materialize new --config
wp-materialize new --config /path/to/config.json
```
Create a dummy manifest:
```bash
wp-materialize new --manifest /path/to/content
```
Add a file to a manifest:
```bash
wp-materialize add-file /path/to/content/post.md
wp-materialize add-file /path/to/content/post.md /path/to/content
wp-materialize add-file /path/to/content/post.md --current
```
Add a directory to a manifest:
```bash
wp-materialize add-subdir /path/to/content/notes
wp-materialize add-subdir /path/to/content/notes /path/to/content
wp-materialize add-subdir /path/to/content/notes --current
```

View File

@@ -10,6 +10,7 @@ from .config import load_config
from .errors import ConfigurationError, MaterializeError, ValidationError from .errors import ConfigurationError, MaterializeError, ValidationError
from .evaluation import evaluate from .evaluation import evaluate
from .local_export import export_local from .local_export import export_local
from .scaffold import add_dir_to_manifest, add_file_to_manifest, create_config, create_manifest, resolve_manifest_dir
from .state import load_state from .state import load_state
from .wp_cli import WordPressCLI from .wp_cli import WordPressCLI
@@ -74,16 +75,110 @@ def main() -> int:
help="Output directory for local export (required).", help="Output directory for local export (required).",
) )
new_parser = subparsers.add_parser(
"new",
help="Create placeholder config or manifest files.",
description="Create a placeholder config file or a dummy manifest.",
)
new_group = new_parser.add_mutually_exclusive_group(required=True)
new_group.add_argument(
"--config",
nargs="?",
const=str(_default_config_path()),
metavar="file",
help="Create a placeholder config file at <file> or the default config path.",
)
new_group.add_argument(
"--manifest",
metavar="dir",
help="Create a dummy manifest in the specified directory.",
)
add_file_parser = subparsers.add_parser(
"add-file",
help="Add a file entry to a manifest.",
description="Add a file entry to the manifest in a given directory.",
)
add_file_parser.add_argument("file", help="File path to add to the manifest.")
add_file_parser.add_argument(
"manifest_dir",
nargs="?",
help="Directory containing the manifest (defaults to current directory).",
)
add_file_parser.add_argument(
"--current",
action="store_true",
help="Find the manifest in the same directory as the file (cannot be used with manifest_dir).",
)
add_dir_parser = subparsers.add_parser(
"add-subdir",
help="Add a subdirectory entry to a manifest.",
description="Add a subdirectory entry to the manifest in a given directory.",
)
add_dir_parser.add_argument("dir", help="Directory path to add to the manifest.")
add_dir_parser.add_argument(
"manifest_dir",
nargs="?",
help="Directory containing the manifest (defaults to current directory).",
)
add_dir_parser.add_argument(
"--current",
action="store_true",
help="Find the manifest in the same directory as the target (cannot be used with manifest_dir).",
)
args = parser.parse_args() args = parser.parse_args()
if args.command is None: if args.command is None:
parser.print_help() parser.print_help()
return 1 return 1
if args.command == "new":
try:
if args.config is not None:
create_config(Path(args.config))
print(f"Created config: {args.config}")
else:
path = create_manifest(Path(args.manifest))
print(f"Created manifest: {path}")
except MaterializeError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
if args.command == "add-file":
try:
file_path = Path(args.file)
manifest_dir = resolve_manifest_dir(file_path, Path(args.manifest_dir) if args.manifest_dir else None, args.current)
add_file_to_manifest(file_path, manifest_dir)
print(f"Added file to manifest: {file_path}")
except MaterializeError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
if args.command == "add-subdir":
try:
dir_path = Path(args.dir)
manifest_dir = resolve_manifest_dir(dir_path, Path(args.manifest_dir) if args.manifest_dir else None, args.current)
add_dir_to_manifest(dir_path, manifest_dir)
print(f"Added directory to manifest: {dir_path}")
except MaterializeError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
try: try:
config = load_config(args.config) config = load_config(args.config)
state = load_state(args.state) state = load_state(args.state)
result = evaluate(config, state, sync_repos=not args.no_sync, force_new=args.force_new) result = evaluate(
config,
state,
sync_repos=not args.no_sync,
force_new=args.force_new,
skip_wp_checks=args.command == "local",
)
except ValidationError as exc: except ValidationError as exc:
_print_validation_error(exc) _print_validation_error(exc)
return 1 return 1
@@ -101,9 +196,8 @@ def main() -> int:
if not output_dir: if not output_dir:
print("Error: local command requires an output directory", file=sys.stderr) print("Error: local command requires an output directory", file=sys.stderr)
return 1 return 1
wp = WordPressCLI(config.wordpress_root)
try: try:
export_local(result, Path(output_dir), wp) export_local(result, Path(output_dir))
except MaterializeError as exc: except MaterializeError as exc:
print(f"Error: {exc}", file=sys.stderr) print(f"Error: {exc}", file=sys.stderr)
return 1 return 1

View File

@@ -25,7 +25,13 @@ class _Context:
manifest_chain: List[Path] manifest_chain: List[Path]
def evaluate(config: Config, state: State, sync_repos: bool, force_new: bool = False) -> EvaluationResult: def evaluate(
config: Config,
state: State,
sync_repos: bool,
force_new: bool = False,
skip_wp_checks: bool = False,
) -> EvaluationResult:
issues: List[ValidationIssue] = [] issues: List[ValidationIssue] = []
sources = _load_sources(config, sync_repos, issues) sources = _load_sources(config, sync_repos, issues)
@@ -45,21 +51,25 @@ def evaluate(config: Config, state: State, sync_repos: bool, force_new: bool = F
state=state, state=state,
issues=issues, issues=issues,
posts=posts, posts=posts,
force_new=force_new,
) )
if shutil.which("wp") is None: missing_categories: List[List[str]] = []
issues.append(ValidationIssue("wp CLI not found in PATH", context=str(config.wordpress_root))) missing_tags: List[str] = []
categories = [] if not skip_wp_checks:
tag_names: Set[str] = set() if shutil.which("wp") is None:
try: issues.append(ValidationIssue("wp CLI not found in PATH", context=str(config.wordpress_root)))
wp = WordPressCLI(config.wordpress_root) categories = []
categories = wp.list_categories() tag_names: Set[str] = set()
tags = wp.list_tags() try:
tag_names = {tag.name for tag in tags} wp = WordPressCLI(config.wordpress_root)
except Exception as exc: categories = wp.list_categories()
issues.append(ValidationIssue(str(exc), context=str(config.wordpress_root))) tags = wp.list_tags()
tag_names = {tag.name for tag in tags}
except Exception as exc:
issues.append(ValidationIssue(str(exc), context=str(config.wordpress_root)))
missing_categories, missing_tags = _plan_taxonomy(posts, categories, tag_names) missing_categories, missing_tags = _plan_taxonomy(posts, categories, tag_names)
if issues: if issues:
raise ValidationError(issues) raise ValidationError(issues)
@@ -121,6 +131,7 @@ def _evaluate_directory(
state: State, state: State,
issues: List[ValidationIssue], issues: List[ValidationIssue],
posts: List[PostPlan], posts: List[PostPlan],
force_new: bool,
) -> None: ) -> None:
manifest_path = directory / ".wp-materialize.json" manifest_path = directory / ".wp-materialize.json"
manifest = load_manifest(manifest_path, issues) manifest = load_manifest(manifest_path, issues)
@@ -243,6 +254,7 @@ def _evaluate_directory(
state=state, state=state,
issues=issues, issues=issues,
posts=posts, posts=posts,
force_new=force_new,
) )

View File

@@ -5,33 +5,22 @@ import re
import shlex import shlex
import unicodedata import unicodedata
from pathlib import Path from pathlib import Path
from typing import Dict, List, Set from typing import List, Set
from .errors import MaterializeError, WordPressError from .errors import MaterializeError
from .models import EvaluationResult, PostPlan from .models import EvaluationResult, PostPlan
from .wp_cli import CategoryTerm, WordPressCLI
def export_local(result: EvaluationResult, output_dir: Path, wp: WordPressCLI) -> None: def export_local(result: EvaluationResult, output_dir: Path) -> None:
if not output_dir.exists(): if not output_dir.exists():
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
if not output_dir.is_dir(): if not output_dir.is_dir():
raise MaterializeError(f"Output path is not a directory: {output_dir}") raise MaterializeError(f"Output path is not a directory: {output_dir}")
categories = wp.list_categories()
category_map = _build_category_map(categories)
missing_categories = _find_missing_categories(result.posts, category_map)
if missing_categories:
raise MaterializeError(
"Cannot build exact wp commands with missing categories. "
"Run apply to create categories first."
)
used_names: Set[str] = set() used_names: Set[str] = set()
for post in result.posts: for post in result.posts:
category_ids = _resolve_category_ids(post, category_map) metadata = _build_metadata(post)
metadata = _build_metadata(post, category_ids) command = _build_wp_command(post)
command = _build_wp_command(post, category_ids)
base_name = _normalize_name(f"{post.source.name}/{post.relative_path}") base_name = _normalize_name(f"{post.source.name}/{post.relative_path}")
title_name = _normalize_name(post.title) title_name = _normalize_name(post.title)
@@ -53,50 +42,13 @@ def export_local(result: EvaluationResult, output_dir: Path, wp: WordPressCLI) -
(target_dir / "wp-command.txt").write_text(command + "\n", encoding="utf-8") (target_dir / "wp-command.txt").write_text(command + "\n", encoding="utf-8")
def _build_category_map(categories: List[CategoryTerm]) -> Dict[tuple[int, str], int]: def _build_metadata(post: PostPlan) -> dict:
return {(category.parent, category.name): category.term_id for category in categories}
def _resolve_category_ids(post: PostPlan, category_map: Dict[tuple[int, str], int]) -> List[int]:
category_ids: List[int] = []
for path in post.categories:
segments = [segment for segment in path.split("/") if segment]
if not segments:
continue
parent = 0
for segment in segments:
map_key = (parent, segment)
if map_key not in category_map:
raise WordPressError(f"Missing category during local export: {path}")
parent = category_map[map_key]
category_ids.append(parent)
return category_ids
def _find_missing_categories(posts: List[PostPlan], category_map: Dict[tuple[int, str], int]) -> List[str]:
missing: Set[str] = set()
for post in posts:
for path in post.categories:
segments = [segment for segment in path.split("/") if segment]
if not segments:
continue
parent = 0
for segment in segments:
map_key = (parent, segment)
if map_key not in category_map:
missing.add(path)
break
parent = category_map[map_key]
return sorted(missing)
def _build_metadata(post: PostPlan, category_ids: List[int]) -> dict:
metadata = { metadata = {
"post_type": "post", "post_type": "post",
"post_status": "publish", "post_status": "publish",
"post_title": post.title, "post_title": post.title,
"post_content": post.html, "post_content": post.html,
"post_category": category_ids, "post_category": post.categories,
"tags_input": post.tags, "tags_input": post.tags,
"meta_input": {"_wp_materialize_source": post.identity}, "meta_input": {"_wp_materialize_source": post.identity},
} }
@@ -109,7 +61,7 @@ def _build_metadata(post: PostPlan, category_ids: List[int]) -> dict:
return metadata return metadata
def _build_wp_command(post: PostPlan, category_ids: List[int]) -> str: def _build_wp_command(post: PostPlan) -> str:
payload = json.dumps({"_wp_materialize_source": post.identity}) payload = json.dumps({"_wp_materialize_source": post.identity})
args = [ args = [
"wp", "wp",
@@ -119,7 +71,7 @@ def _build_wp_command(post: PostPlan, category_ids: List[int]) -> str:
"--post_status=publish", "--post_status=publish",
f"--post_title={post.title}", f"--post_title={post.title}",
f"--post_content={post.html}", f"--post_content={post.html}",
f"--post_category={','.join(str(cat) for cat in category_ids)}", f"--post_category={','.join(post.categories)}",
f"--tags_input={','.join(post.tags)}", f"--tags_input={','.join(post.tags)}",
f"--meta_input={payload}", f"--meta_input={payload}",
"--porcelain", "--porcelain",

148
src/scaffold.py Normal file
View File

@@ -0,0 +1,148 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from .errors import MaterializeError
def create_config(path: Path) -> None:
_ensure_parent_exists(path)
if path.exists():
raise MaterializeError(f"Config already exists: {path}")
payload = {
"wordpress_root": "/path/to/wordpress",
"repo_storage_dir": "/path/to/repo-storage",
"git_repositories": [
{
"name": "example-repo",
"url": "https://example.com/repo.git",
"branch": "main",
"root_subdir": None,
}
],
"directories": [
{
"name": "example-dir",
"path": "/path/to/content",
"root_subdir": None,
}
],
}
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def create_manifest(directory: Path) -> Path:
if not directory.exists():
raise MaterializeError(f"Directory does not exist: {directory}")
if not directory.is_dir():
raise MaterializeError(f"Not a directory: {directory}")
manifest_path = directory / ".wp-materialize.json"
if manifest_path.exists():
raise MaterializeError(f"Manifest already exists: {manifest_path}")
payload = {
"categories": {"content": [], "inherit": True},
"tags": {"content": [], "inherit": True},
"author": {"content": [], "inherit": True},
"subdirectories": {"content": [], "inherit": True},
"files": {},
}
manifest_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return manifest_path
def add_file_to_manifest(file_path: Path, manifest_dir: Path) -> None:
if not file_path.exists():
raise MaterializeError(f"File does not exist: {file_path}")
if not file_path.is_file():
raise MaterializeError(f"Not a file: {file_path}")
manifest_path = _manifest_path(manifest_dir)
data = _load_manifest_json(manifest_path)
relative = _relative_to(file_path, manifest_dir)
files = data.setdefault("files", {})
if not isinstance(files, dict):
raise MaterializeError("Manifest files must be an object")
if relative in files:
raise MaterializeError(f"File already exists in manifest: {relative}")
files[relative] = {"title": "TODO: Title"}
_write_manifest_json(manifest_path, data)
def add_dir_to_manifest(dir_path: Path, manifest_dir: Path) -> None:
if not dir_path.exists():
raise MaterializeError(f"Directory does not exist: {dir_path}")
if not dir_path.is_dir():
raise MaterializeError(f"Not a directory: {dir_path}")
manifest_path = _manifest_path(manifest_dir)
data = _load_manifest_json(manifest_path)
relative = _relative_to(dir_path, manifest_dir)
subdirs = data.setdefault("subdirectories", {"content": [], "inherit": True})
if not isinstance(subdirs, dict):
raise MaterializeError("Manifest subdirectories must be an object")
content = subdirs.setdefault("content", [])
if not isinstance(content, list) or any(not isinstance(item, str) for item in content):
raise MaterializeError("Manifest subdirectories.content must be a list of strings")
if relative in content:
raise MaterializeError(f"Subdirectory already exists in manifest: {relative}")
content.append(relative)
_write_manifest_json(manifest_path, data)
def resolve_manifest_dir(target_path: Path, manifest_dir: Optional[Path], use_current: bool) -> Path:
if manifest_dir and use_current:
raise MaterializeError("--current cannot be used with an explicit manifest directory")
if manifest_dir:
return manifest_dir
if use_current:
return target_path.parent
return Path.cwd()
def _manifest_path(manifest_dir: Path) -> Path:
if not manifest_dir.exists():
raise MaterializeError(f"Manifest directory does not exist: {manifest_dir}")
if not manifest_dir.is_dir():
raise MaterializeError(f"Not a directory: {manifest_dir}")
manifest_path = manifest_dir / ".wp-materialize.json"
if not manifest_path.exists():
raise MaterializeError(f"Manifest not found: {manifest_path}")
return manifest_path
def _load_manifest_json(path: Path) -> Dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise MaterializeError(f"Invalid JSON in manifest: {exc}") from exc
if not isinstance(data, dict):
raise MaterializeError("Manifest must be a JSON object")
return data
def _write_manifest_json(path: Path, data: Dict[str, Any]) -> None:
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def _relative_to(path: Path, base: Path) -> str:
try:
relative = path.relative_to(base)
except ValueError as exc:
raise MaterializeError(f"Path is outside manifest directory: {path}") from exc
relative_str = relative.as_posix()
if relative_str in {".", ""}:
raise MaterializeError(f"Path must be inside manifest directory: {path}")
return relative_str
def _ensure_parent_exists(path: Path) -> None:
parent = path.parent
if not parent.exists():
raise MaterializeError(f"Directory does not exist: {parent}")
if not parent.is_dir():
raise MaterializeError(f"Not a directory: {parent}")