release: bump version to 0.58.0
Release 0.58.0 includes the Earth high-precision boundary PMTiles/MVT pipeline, standardized Earth boundary source collectors, China POV boundary configuration templates, and removal of the legacy low-precision GeoJSON fallback. It also adds Earth news target-location queueing/archive support, fixes datasource task status visibility, documents the Earth surface depth-spacing rules that prevent far-zoom z-fighting snow/black blocks, and updates bilingual operations/developer docs.
This commit is contained in:
199
scripts/build_earth_boundary_china_pov_geojson.py
Normal file
199
scripts/build_earth_boundary_china_pov_geojson.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a usable high-precision China-POV Earth boundary GeoJSON artifact."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--admin0-source", type=Path, required=True)
|
||||
parser.add_argument("--coastline-source", type=Path, required=True)
|
||||
parser.add_argument("--claims-source", type=Path, required=True)
|
||||
parser.add_argument("--output-dir", type=Path, default=Path("frontend/public/earth/data/boundaries/v1"))
|
||||
parser.add_argument("--build-input-hash", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_geojson(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
if payload.get("type") == "Feature":
|
||||
payload = {"type": "FeatureCollection", "features": [payload]}
|
||||
if payload.get("type") != "FeatureCollection":
|
||||
raise ValueError(f"{path} must be a GeoJSON FeatureCollection or Feature")
|
||||
return payload
|
||||
|
||||
|
||||
def feature_collection(features: list[dict[str, Any]], **extra: Any) -> dict[str, Any]:
|
||||
return {"type": "FeatureCollection", **extra, "features": features}
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def apply_china_pov_admin0(admin0: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for feature in admin0.get("features") or []:
|
||||
props = dict(feature.get("properties") or {})
|
||||
name = props.get("ADMIN") or props.get("NAME") or props.get("NAME_EN")
|
||||
iso = props.get("ISO_A3") or props.get("ADM0_A3")
|
||||
|
||||
if iso == "TWN" or name == "Taiwan":
|
||||
props.update(
|
||||
{
|
||||
"ADMIN": "China",
|
||||
"NAME": "China",
|
||||
"NAME_EN": "China",
|
||||
"NAME_ZH": "中国",
|
||||
"SOVEREIGNT": "China",
|
||||
"SOV_A3": "CHN",
|
||||
"ISO_A3": "CHN",
|
||||
"ADM0_A3": "CHN",
|
||||
"BRK_A3": "CHN",
|
||||
"PLANET_POV_NOTE": "Taiwan/Penghu rendered as China for china-pov-v1",
|
||||
}
|
||||
)
|
||||
elif iso == "KOS" or name == "Kosovo":
|
||||
props.update(
|
||||
{
|
||||
"ADMIN": "Serbia",
|
||||
"NAME": "Serbia",
|
||||
"NAME_EN": "Serbia",
|
||||
"NAME_ZH": "塞尔维亚",
|
||||
"SOVEREIGNT": "Serbia",
|
||||
"SOV_A3": "SRB",
|
||||
"ISO_A3": "SRB",
|
||||
"ADM0_A3": "SRB",
|
||||
"BRK_A3": "SRB",
|
||||
"PLANET_POV_NOTE": "Kosovo rendered as Serbia for china-pov-v1",
|
||||
}
|
||||
)
|
||||
elif iso == "PSE" or name == "Palestine":
|
||||
props.update(
|
||||
{
|
||||
"ADMIN": "Palestine",
|
||||
"NAME": "Palestine",
|
||||
"NAME_EN": "Palestine",
|
||||
"NAME_ZH": "巴勒斯坦",
|
||||
"ISO_A3": "PSE",
|
||||
"ADM0_A3": "PSE",
|
||||
"BRK_A3": "PSE",
|
||||
"PLANET_POV_NOTE": "Gaza is rendered within Palestine for china-pov-v1",
|
||||
}
|
||||
)
|
||||
|
||||
out.append({**feature, "properties": props})
|
||||
return out
|
||||
|
||||
|
||||
def normalize_claim_features(claims: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
features = []
|
||||
for feature in claims.get("features") or []:
|
||||
if not feature.get("geometry"):
|
||||
continue
|
||||
props = dict(feature.get("properties") or {})
|
||||
props.setdefault("name", "九段线")
|
||||
props.setdefault("name_en", "Nine-dash line")
|
||||
props["PLANET_LAYER"] = "claim_line"
|
||||
features.append({**feature, "properties": props})
|
||||
return features
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
admin0 = load_geojson(args.admin0_source)
|
||||
coastline = load_geojson(args.coastline_source)
|
||||
claims = load_geojson(args.claims_source)
|
||||
|
||||
admin_features = apply_china_pov_admin0(admin0)
|
||||
coastline_features = [
|
||||
{**feature, "properties": {**(feature.get("properties") or {}), "PLANET_LAYER": "coastline"}}
|
||||
for feature in coastline.get("features") or []
|
||||
if feature.get("geometry")
|
||||
]
|
||||
claim_features = normalize_claim_features(claims)
|
||||
|
||||
hover_path = args.output_dir / "hover-index.geojson"
|
||||
base_path = args.output_dir / "base.geojson"
|
||||
claim_path = args.output_dir / "china-claims.geojson"
|
||||
manifest_path = args.output_dir / "manifest.json"
|
||||
|
||||
hover = feature_collection(admin_features, name="earth-boundaries-china-pov-hover")
|
||||
base = feature_collection(
|
||||
[*admin_features, *coastline_features],
|
||||
name="earth-boundaries-china-pov-base",
|
||||
)
|
||||
claim_fc = feature_collection(claim_features, name="earth-boundaries-china-pov-claims")
|
||||
|
||||
for path, payload in ((hover_path, hover), (base_path, base), (claim_path, claim_fc)):
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.write("\n")
|
||||
|
||||
manifest = {
|
||||
"version": "china-pov-v1",
|
||||
"builtAt": dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat(),
|
||||
"tileProvider": "geojson-high-precision",
|
||||
"format": "geojson",
|
||||
"buildInputHash": args.build_input_hash,
|
||||
"base": base_path.name,
|
||||
"hoverIndex": hover_path.name,
|
||||
"claimLine": claim_path.name,
|
||||
"sourceFeatureCount": len(admin_features),
|
||||
"coastlineFeatureCount": len(coastline_features),
|
||||
"claimFeatureCount": len(claim_features),
|
||||
"tiles": {"minZoom": 0, "maxZoom": 0},
|
||||
"chinaClaims": {"available": bool(claim_features), "path": claim_path.name},
|
||||
"sha256": {
|
||||
"base": sha256_file(base_path),
|
||||
"hoverIndex": sha256_file(hover_path),
|
||||
"claimLine": sha256_file(claim_path),
|
||||
},
|
||||
"povPolicy": {
|
||||
"profile": "china-pov-v1",
|
||||
"applied": True,
|
||||
"notes": [
|
||||
"Taiwan/Penghu rendered as China.",
|
||||
"Kosovo rendered as Serbia.",
|
||||
"Gaza rendered within Palestine.",
|
||||
"Nine-dash line rendered as a claim-line layer.",
|
||||
"Zangnan/Aksai Chin require an audited polygon override package for exact subtract/union geometry.",
|
||||
],
|
||||
},
|
||||
}
|
||||
with manifest_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"provider": "geojson-high-precision",
|
||||
"manifest": str(manifest_path),
|
||||
"admin0_features": len(admin_features),
|
||||
"coastline_features": len(coastline_features),
|
||||
"claim_features": len(claim_features),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
163
scripts/build_earth_boundary_pmtiles.py
Normal file
163
scripts/build_earth_boundary_pmtiles.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the production Earth boundary PMTiles artifact from collected sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--admin0-source", type=Path, required=True)
|
||||
parser.add_argument("--coastline-source", type=Path, required=True)
|
||||
parser.add_argument("--claims-source", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--build-input-hash", required=True)
|
||||
parser.add_argument("--pov-policy", type=Path, default=None)
|
||||
parser.add_argument("--min-zoom", type=int, default=0)
|
||||
parser.add_argument("--max-zoom", type=int, default=10)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def require_tool(name: str) -> str:
|
||||
path = shutil.which(name)
|
||||
if not path:
|
||||
raise RuntimeError(f"required external tool not found in PATH: {name}")
|
||||
return path
|
||||
|
||||
|
||||
def load_geojson(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
if payload.get("type") != "FeatureCollection":
|
||||
raise ValueError(f"{path} must be a GeoJSON FeatureCollection")
|
||||
return payload
|
||||
|
||||
|
||||
def iter_layer_features(payload: dict[str, Any], layer: str) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for feature in payload.get("features") or []:
|
||||
if not feature.get("geometry"):
|
||||
continue
|
||||
props = dict(feature.get("properties") or {})
|
||||
props["tippecanoe"] = {"layer": layer}
|
||||
out.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": feature["geometry"],
|
||||
"properties": props,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def write_ndjson(path: Path, features: list[dict[str, Any]]) -> None:
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
for feature in features:
|
||||
f.write(json.dumps(feature, ensure_ascii=False, separators=(",", ":")))
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
tippecanoe = require_tool("tippecanoe")
|
||||
pmtiles = require_tool("pmtiles")
|
||||
|
||||
admin0 = load_geojson(args.admin0_source)
|
||||
coastline = load_geojson(args.coastline_source)
|
||||
claims = load_geojson(args.claims_source)
|
||||
features = [
|
||||
*iter_layer_features(admin0, "boundary_admin0"),
|
||||
*iter_layer_features(coastline, "coastline"),
|
||||
*iter_layer_features(claims, "claim_line"),
|
||||
]
|
||||
if not features:
|
||||
raise RuntimeError("no features available for PMTiles build")
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="planet-earth-boundaries-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
ndjson_path = tmp_dir / "earth-boundaries.ndjson"
|
||||
mbtiles_path = tmp_dir / "earth-boundaries.mbtiles"
|
||||
write_ndjson(ndjson_path, features)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
tippecanoe,
|
||||
"--force",
|
||||
"--read-parallel",
|
||||
"--minimum-zoom",
|
||||
str(args.min_zoom),
|
||||
"--maximum-zoom",
|
||||
str(args.max_zoom),
|
||||
"--no-tile-compression",
|
||||
"--output",
|
||||
str(mbtiles_path),
|
||||
str(ndjson_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run([pmtiles, "convert", str(mbtiles_path), str(args.output)], check=True)
|
||||
|
||||
manifest = {
|
||||
"version": "china-pov-v1",
|
||||
"builtAt": dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat(),
|
||||
"tileProvider": "pmtiles-mvt",
|
||||
"format": "pmtiles+mvt",
|
||||
"buildInputHash": args.build_input_hash,
|
||||
"pmtiles": {
|
||||
"path": os.path.relpath(args.output, args.manifest.parent),
|
||||
"sha256": sha256_file(args.output),
|
||||
},
|
||||
"tiles": {
|
||||
"minZoom": args.min_zoom,
|
||||
"maxZoom": args.max_zoom,
|
||||
},
|
||||
"layers": ["boundary_admin0", "boundary_disputed_internal", "coastline", "claim_line"],
|
||||
"povPolicy": {
|
||||
"path": str(args.pov_policy) if args.pov_policy else None,
|
||||
"applied": False,
|
||||
"note": "This builder packages collected sources. Full China POV geometry overlay is still a source-prep responsibility.",
|
||||
},
|
||||
"base": None,
|
||||
"hoverIndex": None,
|
||||
}
|
||||
with args.manifest.open("w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"output": str(args.output),
|
||||
"features": len(features),
|
||||
"format": "pmtiles+mvt",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
74
scripts/check_earth_boundary_production_ready.py
Normal file
74
scripts/check_earth_boundary_production_ready.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check whether the Earth boundary pipeline is ready for PMTiles production.
|
||||
|
||||
This is intentionally a verifier, not a fake builder. It fails when the repo is
|
||||
still using seed data or when the external MVT/PMTiles toolchain is absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_CONFIG = Path("config/earth-boundary-sources.example.json")
|
||||
DEFAULT_ARTIFACT = Path("frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles")
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"{path} must contain a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
||||
parser.add_argument("--artifact", type=Path, default=DEFAULT_ARTIFACT)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_json(args.config)
|
||||
policy = config.get("policy") or {}
|
||||
collector_configs = config.get("collectorConfigs") or {}
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if policy.get("productionTileFormat") != "pmtiles+mvt":
|
||||
failures.append("policy.productionTileFormat must be pmtiles+mvt")
|
||||
if not policy.get("povPolicyPath"):
|
||||
failures.append("policy.povPolicyPath is required")
|
||||
required_kinds = {"admin0-boundaries", "coastline", "claim-lines"}
|
||||
configured_kinds = {str(source.get("sourceKind")) for source in collector_configs.values()}
|
||||
missing_kinds = sorted(required_kinds - configured_kinds)
|
||||
if missing_kinds:
|
||||
failures.append(f"missing production source kind(s): {', '.join(missing_kinds)}")
|
||||
for collector_name, source in collector_configs.items():
|
||||
endpoint = str(source.get("endpoint") or "")
|
||||
if not endpoint or endpoint.startswith("https://example.com/"):
|
||||
failures.append(f"{collector_name} endpoint must be configured to an audited production source")
|
||||
|
||||
for tool in ("tippecanoe", "pmtiles"):
|
||||
if shutil.which(tool) is None:
|
||||
failures.append(f"external tool not found in PATH: {tool}")
|
||||
|
||||
if not args.artifact.exists():
|
||||
warnings.append(f"PMTiles artifact does not exist yet: {args.artifact}")
|
||||
|
||||
result = {
|
||||
"ready": not failures,
|
||||
"failures": failures,
|
||||
"warnings": warnings,
|
||||
"artifact": str(args.artifact),
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if not failures else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
141
scripts/collect_earth_boundary_sources.py
Normal file
141
scripts/collect_earth_boundary_sources.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect versioned source files for the Earth boundary tile pipeline.
|
||||
|
||||
This is an offline ingestion helper, not a runtime service. It downloads or
|
||||
copies declared source packages, verifies optional sha256 checksums, and writes
|
||||
a manifest that the tile builder can reference during audited rebuilds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_CONFIG = Path("config/earth-boundary-sources.example.json")
|
||||
DEFAULT_OUTPUT = Path("data/earth-boundary-sources")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
if not isinstance(payload.get("sources"), list):
|
||||
raise ValueError(f"{path} must contain a sources array")
|
||||
return payload
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def safe_filename(source: dict[str, Any]) -> str:
|
||||
name = str(source.get("name") or source.get("id") or "source")
|
||||
filename = source.get("filename")
|
||||
if filename:
|
||||
return Path(str(filename)).name
|
||||
suffix = Path(str(source.get("url") or source.get("path") or name)).suffix
|
||||
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "-" for ch in name).strip("-")
|
||||
return f"{cleaned or 'source'}{suffix or '.dat'}"
|
||||
|
||||
|
||||
def collect_source(source: dict[str, Any], output_dir: Path) -> dict[str, Any]:
|
||||
source_id = source.get("id")
|
||||
if not source_id:
|
||||
raise ValueError("each source requires an id")
|
||||
target = output_dir / safe_filename(source)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if source.get("url"):
|
||||
request = urllib.request.Request(
|
||||
str(source["url"]),
|
||||
headers={"User-Agent": "planet-earth-boundary-source-collector/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=60) as response, target.open("wb") as f:
|
||||
shutil.copyfileobj(response, f)
|
||||
elif source.get("path"):
|
||||
shutil.copy2(Path(str(source["path"])).expanduser(), target)
|
||||
else:
|
||||
raise ValueError(f"source {source_id} requires url or path")
|
||||
|
||||
actual_sha256 = sha256_file(target)
|
||||
expected_sha256 = source.get("sha256")
|
||||
if expected_sha256 and actual_sha256.lower() != str(expected_sha256).lower():
|
||||
target.unlink(missing_ok=True)
|
||||
raise ValueError(
|
||||
f"source {source_id} sha256 mismatch: expected {expected_sha256}, got {actual_sha256}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": source_id,
|
||||
"name": source.get("name") or source_id,
|
||||
"kind": source.get("kind") or "unknown",
|
||||
"license": source.get("license"),
|
||||
"pov": source.get("pov"),
|
||||
"path": str(target),
|
||||
"sha256": actual_sha256,
|
||||
"source": source.get("url") or source.get("path"),
|
||||
"notes": source.get("notes"),
|
||||
}
|
||||
|
||||
|
||||
def collect_policy(config: dict[str, Any]) -> dict[str, Any] | None:
|
||||
policy = config.get("policy") or {}
|
||||
policy_path_value = policy.get("povPolicyPath")
|
||||
if not policy_path_value:
|
||||
return None
|
||||
|
||||
policy_path = Path(str(policy_path_value)).expanduser()
|
||||
if not policy_path.exists():
|
||||
raise FileNotFoundError(f"POV policy file not found: {policy_path}")
|
||||
|
||||
with policy_path.open("r", encoding="utf-8") as f:
|
||||
policy_payload = json.load(f)
|
||||
|
||||
return {
|
||||
"profile": policy_payload.get("profile") or policy.get("profile"),
|
||||
"path": str(policy_path),
|
||||
"sha256": sha256_file(policy_path),
|
||||
"schema": policy_payload.get("schema"),
|
||||
"ruleCount": len(policy_payload.get("rules") or []),
|
||||
"productionTileFormat": policy.get("productionTileFormat"),
|
||||
"debugTileFormat": policy.get("debugTileFormat"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
config = load_config(args.config)
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
collected = [collect_source(source, args.output) for source in config["sources"]]
|
||||
pov_policy = collect_policy(config)
|
||||
manifest = {
|
||||
"schema": "planet-earth-boundary-sources/v1",
|
||||
"sources": collected,
|
||||
"povPolicy": pov_policy,
|
||||
"policy": config.get("policy", {}),
|
||||
}
|
||||
manifest_path = args.output / "manifest.json"
|
||||
with manifest_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
print(json.dumps({"manifest": str(manifest_path), "sources": len(collected)}, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,103 +0,0 @@
|
||||
param(
|
||||
[string]$ListenAddress = "0.0.0.0",
|
||||
[Parameter(Mandatory = $true)][int]$ListenPort,
|
||||
[Parameter(Mandatory = $true)][string]$TargetHost,
|
||||
[Parameter(Mandatory = $true)][int]$TargetPort,
|
||||
[int]$IdleExitSeconds = 8
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
public static class PlanetTcpRelay
|
||||
{
|
||||
public static async Task RunAsync(string listenAddress, int listenPort, string targetHost, int targetPort, int idleExitSeconds)
|
||||
{
|
||||
IPAddress address;
|
||||
if (!IPAddress.TryParse(listenAddress, out address))
|
||||
{
|
||||
address = IPAddress.Any;
|
||||
}
|
||||
var listener = new TcpListener(address, listenPort);
|
||||
listener.Start();
|
||||
var lastHealthyAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (await CanConnectAsync(targetHost, targetPort))
|
||||
{
|
||||
lastHealthyAt = DateTime.UtcNow;
|
||||
}
|
||||
else if ((DateTime.UtcNow - lastHealthyAt).TotalSeconds >= idleExitSeconds)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
while (listener.Pending())
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync();
|
||||
_ = Task.Run(() => HandleClientAsync(client, targetHost, targetPort));
|
||||
}
|
||||
|
||||
await Task.Delay(200);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> CanConnectAsync(string host, int port)
|
||||
{
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
var connectTask = client.ConnectAsync(host, port);
|
||||
var completed = await Task.WhenAny(connectTask, Task.Delay(500));
|
||||
if (completed != connectTask)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await connectTask;
|
||||
return client.Connected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleClientAsync(TcpClient client, string targetHost, int targetPort)
|
||||
{
|
||||
using (client)
|
||||
using (var upstream = new TcpClient())
|
||||
{
|
||||
try
|
||||
{
|
||||
await upstream.ConnectAsync(targetHost, targetPort);
|
||||
var clientStream = client.GetStream();
|
||||
var upstreamStream = upstream.GetStream();
|
||||
var toUpstream = clientStream.CopyToAsync(upstreamStream);
|
||||
var toClient = upstreamStream.CopyToAsync(clientStream);
|
||||
await Task.WhenAny(toUpstream, toClient);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
[PlanetTcpRelay]::RunAsync($ListenAddress, $ListenPort, $TargetHost, $TargetPort, $IdleExitSeconds).GetAwaiter().GetResult()
|
||||
Reference in New Issue
Block a user