[READ-ONLY] Mirror of https://github.com/mrgnw/cv.
0

Configure Feed

Select the types of activity you want to include in your feed.

cv / scripts / json5schema.py
11 kB 349 lines
1#!/usr/bin/env python3 2# /// script 3# requires-python = ">=3.13" 4# dependencies = [ 5# "json5", 6# ] 7# /// 8"""Convert a version JSON5 file into JSON Resume schema (still JSON5 output).""" 9 10from __future__ import annotations 11 12import argparse 13import datetime as dt 14import json 15import json5 # type: ignore 16from pathlib import Path 17from typing import Any, Iterable 18from urllib.parse import urlparse 19 20 21def parse_args() -> argparse.Namespace: 22 parser = argparse.ArgumentParser( 23 description="Convert a version JSON5 file into JSON Resume format" 24 ) 25 parser.add_argument("input", type=Path, help="Path to the source JSON5 file") 26 parser.add_argument( 27 "output", 28 nargs="?", 29 type=Path, 30 help="Destination JSON5 file (defaults to stdout)", 31 ) 32 parser.add_argument( 33 "--schema-url", 34 default="https://jsonresume.org/schema", 35 help="Schema reference comment to include in the output", 36 ) 37 parser.add_argument( 38 "--merge", 39 type=Path, 40 help="Optional JSON5 file with defaults to merge into the input before converting", 41 ) 42 parser.add_argument( 43 "--json", 44 action="store_true", 45 help="Emit strict JSON instead of JSON5 (omits header comments)", 46 ) 47 return parser.parse_args() 48 49 50def main() -> None: 51 args = parse_args() 52 base_data: dict[str, Any] = {} 53 if args.merge: 54 base_data = json5.load(args.merge.open("r", encoding="utf-8")) 55 56 version_data = json5.load(args.input.open("r", encoding="utf-8")) 57 merged = deep_merge(base_data, version_data) 58 project_catalog = load_project_catalog() 59 resume = to_json_resume(merged, project_catalog) 60 61 if args.json: 62 payload = json.dumps(resume, indent=2) 63 output = f"{payload}\n" 64 else: 65 payload = json5.dumps(resume, indent=2, trailing_commas=False) 66 header = _header_comment(args.schema_url, args.input) 67 output = f"{header}\n{payload}\n" 68 69 if args.output: 70 args.output.write_text(output, encoding="utf-8") 71 else: 72 print(output, end="") 73 74 75def to_json_resume( 76 source: dict[str, Any], project_catalog: dict[str, dict[str, Any]] 77) -> dict[str, Any]: 78 basics = build_basics(source) 79 work = build_work(source) 80 skills = build_skills(source) 81 projects = build_projects(source, project_catalog) 82 education = build_education(source) 83 interests = build_interests(source) 84 meta = build_meta(source) 85 86 resume: dict[str, Any] = { 87 "basics": basics, 88 "work": work, 89 "skills": skills, 90 "projects": projects, 91 "education": education, 92 } 93 94 if interests: 95 resume["interests"] = interests 96 if meta: 97 resume["meta"] = meta 98 99 return resume 100 101 102def build_basics(source: dict[str, Any]) -> dict[str, Any]: 103 basics: dict[str, Any] = {"name": source.get("name", "Unknown")} 104 if title := source.get("title"): 105 basics["label"] = title 106 if email := source.get("email"): 107 basics["email"] = email 108 if url := _clean_string(source.get("portfolio")): 109 basics["url"] = url 110 summary = _clean_string(source.get("summary")) 111 basics["summary"] = summary if summary is not None else "" 112 basics["location"] = _build_location(source.get("location")) 113 profiles: list[dict[str, Any]] = [] 114 for entry in _iter_dicts(source.get("profiles")): 115 cleaned: dict[str, Any] = {} 116 for key, value in entry.items(): 117 cleaned_value = _clean_string(value) 118 if cleaned_value is None: 119 continue 120 cleaned[key] = cleaned_value 121 if "url" in cleaned and not _is_valid_uri(cleaned["url"]): 122 cleaned.pop("url") 123 if cleaned: 124 profiles.append(cleaned) 125 126 if github := _clean_string(source.get("github")): 127 username = github.rstrip("/").split("/")[-1] 128 gh_url = github if _is_valid_uri(github) else None 129 gh_profile = { 130 "network": "GitHub", 131 "username": username, 132 } 133 if gh_url: 134 gh_profile["url"] = gh_url 135 if not any(_same_profile(p, gh_profile) for p in profiles): 136 profiles.append(gh_profile) 137 138 basics["profiles"] = profiles 139 return basics 140 141 142def build_work(source: dict[str, Any]) -> list[dict[str, Any]]: 143 work_entries = [] 144 for job in _iter_dicts(source.get("experience")): 145 start_date = job.get("start") 146 end_date = job.get("end") 147 if end_date is None: 148 end_date = _today_iso() 149 entry: dict[str, Any] = { 150 "name": job.get("company"), 151 "position": job.get("title"), 152 "startDate": start_date, 153 "endDate": end_date, 154 "highlights": job.get("achievements", []), 155 } 156 skills = job.get("skills") 157 if isinstance(skills, list) and skills: 158 entry["keywords"] = skills 159 work_entries.append(entry) 160 return work_entries 161 162 163def build_skills(source: dict[str, Any]) -> list[dict[str, Any]]: 164 blocks: list[dict[str, Any]] = [] 165 if (core := _ensure_list(source.get("skills"))): 166 blocks.append({"name": "Core", "keywords": core}) 167 if (secondary := _ensure_list(source.get("secondarySkills"))): 168 blocks.append({"name": "Secondary", "keywords": secondary}) 169 return blocks 170 171 172def build_projects( 173 source: dict[str, Any], project_catalog: dict[str, dict[str, Any]] 174) -> list[dict[str, Any]]: 175 projects: list[dict[str, Any]] = [] 176 for item in source.get("projects", []) or []: 177 if isinstance(item, str): 178 entry: dict[str, Any] = {"name": item} 179 catalog = project_catalog.get(item) 180 elif isinstance(item, dict): 181 entry = {} 182 for key, value in item.items(): 183 cleaned_value = _clean_string(value) 184 if cleaned_value is None: 185 continue 186 entry[key] = cleaned_value 187 catalog = project_catalog.get(entry.get("name")) 188 else: 189 continue 190 if isinstance(item, dict): 191 if "skills" in item and "keywords" not in entry: 192 skills = _ensure_list(item.get("skills")) 193 if skills: 194 entry["keywords"] = skills 195 if catalog: 196 if "url" not in entry and catalog.get("url"): 197 entry["url"] = catalog["url"] 198 if "description" not in entry and catalog.get("description"): 199 entry["description"] = catalog["description"] 200 if "keywords" not in entry: 201 catalog_skills = _ensure_list(catalog.get("skills")) 202 if catalog_skills: 203 entry["keywords"] = catalog_skills 204 if entry: 205 projects.append(entry) 206 return projects 207 208 209def build_education(source: dict[str, Any]) -> list[dict[str, Any]]: 210 education = [] 211 for item in _iter_dicts(source.get("education")): 212 entry: dict[str, Any] = { 213 "institution": item.get("provider"), 214 "studyType": item.get("degree"), 215 "area": item.get("summary"), 216 } 217 if year := item.get("year"): 218 entry["endDate"] = _normalize_year(year) 219 education.append(entry) 220 return education 221 222 223def build_interests(source: dict[str, Any]) -> list[dict[str, Any]]: 224 keywords = _ensure_list(source.get("keywords")) 225 return [{"name": "Keywords", "keywords": keywords}] if keywords else [] 226 227 228def build_meta(source: dict[str, Any]) -> dict[str, Any]: 229 meta: dict[str, Any] = {} 230 if "matchScore" in source: 231 meta["matchScore"] = source["matchScore"] 232 if "matchFactors" in source: 233 meta["matchFactors"] = source["matchFactors"] 234 return meta 235 236 237def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: 238 result: dict[str, Any] = {} 239 for key in base.keys() | overlay.keys(): 240 left = base.get(key) 241 right = overlay.get(key) 242 if isinstance(left, dict) and isinstance(right, dict): 243 result[key] = deep_merge(left, right) 244 elif right is not None: 245 result[key] = right 246 else: 247 result[key] = left 248 return result 249 250 251def load_project_catalog() -> dict[str, dict[str, Any]]: 252 catalog: dict[str, dict[str, Any]] = {} 253 script_dir = Path(__file__).resolve().parent 254 default_path = script_dir.parent / "src" / "lib" / "projects.jsonc" 255 if not default_path.exists(): 256 return catalog 257 258 try: 259 data = json5.load(default_path.open("r", encoding="utf-8")) 260 except Exception: 261 return catalog 262 263 if not isinstance(data, list): 264 return catalog 265 266 for entry in data: 267 if isinstance(entry, dict): 268 name = _clean_string(entry.get("name")) 269 if name: 270 catalog[str(name)] = entry 271 return catalog 272 273 274def _clean_string(value: Any) -> Any: 275 if isinstance(value, str): 276 trimmed = value.strip() 277 return trimmed or None 278 return value 279 280 281def _same_profile(left: dict[str, Any], right: dict[str, Any]) -> bool: 282 left_network = str(left.get("network", "")).lower() 283 right_network = str(right.get("network", "")).lower() 284 if left_network and right_network and left_network == right_network: 285 return True 286 287 left_url = _clean_string(left.get("url")) 288 right_url = _clean_string(right.get("url")) 289 if left_url and right_url: 290 return left_url.rstrip("/") == right_url.rstrip("/") 291 292 return False 293 294 295def _is_valid_uri(value: str) -> bool: 296 parsed = urlparse(value) 297 return bool(parsed.scheme and parsed.netloc) 298 299 300def _iter_dicts(value: Any) -> Iterable[dict[str, Any]]: 301 if isinstance(value, list): 302 for item in value: 303 if isinstance(item, dict): 304 yield item 305 306 307def _ensure_list(value: Any) -> list[Any]: 308 return value if isinstance(value, list) else [] 309 310 311def _normalize_year(value: Any) -> str | None: 312 text = str(value).strip() 313 if not text: 314 return None 315 return f"{text}-01-01" if text.isdigit() and len(text) == 4 else text 316 317 318def _header_comment(schema_url: str, input_path: Path) -> str: 319 timestamp = dt.datetime.now(dt.timezone.utc).isoformat() 320 relative = input_path.resolve() 321 return "\n".join( 322 [ 323 f"// JSON Resume schema: {schema_url}", 324 f"// Source: {relative}", 325 f"// Generated: {timestamp}", 326 ] 327 ) 328 329 330def _today_iso() -> str: 331 return dt.date.today().isoformat() 332 333 334def _build_location(value: Any) -> dict[str, Any]: 335 allowed_keys = {"address", "postalCode", "city", "countryCode", "region"} 336 location: dict[str, Any] = {} 337 if isinstance(value, dict): 338 for key, val in value.items(): 339 if key in allowed_keys: 340 cleaned = _clean_string(val) 341 if cleaned is not None: 342 location[key] = cleaned 343 if "address" not in location: 344 location["address"] = "" 345 return location 346 347 348if __name__ == "__main__": 349 main()