[READ-ONLY] Mirror of https://github.com/mrgnw/cv.
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()