Files
homelab-scripts/one-shot/populate_gramps_sheet.py
T

534 lines
21 KiB
Python

#!/usr/bin/env python3
"""Parse Gramps JSONL and populate a well-structured Google Sheet."""
import json
import urllib.request
import urllib.parse
from collections import defaultdict
INPUT = "/root/.hermes/webui/attachments/8647800d8e2c/gramps-web-export-20260725204536.json"
SPREADSHEET_ID = "1uRokfwe3-9PyYlKY8Vnv3fkbQxIh_E2X_QIk3QpqN6c"
# ── Load data ──────────────────────────────────────────────────────────
objects = []
with open(INPUT) as f:
for line in f:
line = line.strip()
if line:
if "|" in line and line[0].isdigit():
line = line.split("|", 1)[1]
objects.append(json.loads(line))
by_handle = {o["handle"]: o for o in objects}
persons = [o for o in objects if o.get("_class") == "Person"]
families = [o for o in objects if o.get("_class") == "Family"]
events = [o for o in objects if o.get("_class") == "Event"]
places = [o for o in objects if o.get("_class") == "Place"]
media_list = [o for o in objects if o.get("_class") == "Media"]
EVENT_TYPES = {
0: "Mariage", 1: "Mariage", 2: "Divorce",
3: "Décès", 4: "Inhumation", 5: "Obsèques",
6: "Naissance", 7: "Baptême", 8: "Adoption",
9: "Baptême (LDS)", 10: "Endowment (LDS)",
11: "Scellement (LDS)", 12: "Naissance", 13: "Décès",
19: "Inhumation",
}
def event_type_str(t):
return EVENT_TYPES.get(t.get("value"), f"Type {t.get('value')}")
def format_date(d):
if not d:
return ""
dv = d.get("dateval", [0, 0, 0])
y, m, day = dv[2], dv[1], dv[0]
if y == 0:
return ""
if m == 0:
return str(y)
if day == 0:
months = ["", "Jan", "Fév", "Mar", "Avr", "Mai", "Juin",
"Juil", "Aoû", "Sep", "Oct", "Nov", "Déc"]
return f"{months[m]} {y}"
return f"{day:02d}/{m:02d}/{y}"
def person_name(p):
if not p:
return ""
n = p.get("primary_name", {})
first = n.get("first_name", "").strip()
sur_list = n.get("surname_list", [])
sur = sur_list[0].get("surname", "").strip() if sur_list else ""
suffix = n.get("suffix", "").strip()
parts = [first, sur, suffix] if suffix else [first, sur]
return " ".join(p for p in parts if p).strip()
def get_place_name(handle):
pl = by_handle.get(handle)
if pl:
return pl.get("name", {}).get("value", "")
return ""
def get_notes_for_person(p):
notes = []
for nh in p.get("note_list", []):
n = by_handle.get(nh)
if n:
notes.append(n.get("text", {}).get("string", ""))
return notes
def get_person_events(p):
birth, death = "", ""
for er in p.get("event_ref_list", []):
ev = by_handle.get(er.get("ref"))
if ev:
t = ev.get("type", {}).get("value")
d = format_date(ev.get("date"))
if t == 12:
birth = d
elif t == 13:
death = d
return birth, death
def get_parents(person):
father, mother = None, None
for pf in person.get("parent_family_list", []):
fam = by_handle.get(pf)
if fam:
if fam.get("father_handle"):
father = by_handle.get(fam["father_handle"])
if fam.get("mother_handle"):
mother = by_handle.get(fam["mother_handle"])
return father, mother
def get_children(family):
children = []
for cr in family.get("child_ref_list", []):
child = by_handle.get(cr.get("ref"))
if child:
children.append(child)
return children
def get_spouses(person):
spouses = []
for f in families:
if f.get("father_handle") == person["handle"]:
s = by_handle.get(f.get("mother_handle"))
if s:
spouses.append(s)
elif f.get("mother_handle") == person["handle"]:
s = by_handle.get(f.get("father_handle"))
if s:
spouses.append(s)
return spouses
def get_children_for_person(person):
all_children = []
for f in families:
if f.get("father_handle") == person["handle"] or f.get("mother_handle") == person["handle"]:
all_children.extend(get_children(f))
return all_children
# ── Determine branch ───────────────────────────────────────────────────
KELLER_ROOT = "a37f8646-d374-40da-863b-86febf23b165"
STAERLE_ROOT = "1c80dd1f-77b4-4ab9-940c-e0776b6161ff"
def compute_generation(person, root_handle, max_depth=20, visited=None):
if visited is None:
visited = set()
if person["handle"] in visited or max_depth <= 0:
return None
visited.add(person["handle"])
if person["handle"] == root_handle:
return 1
father, mother = get_parents(person)
for parent in [father, mother]:
if parent:
gen = compute_generation(parent, root_handle, max_depth - 1, visited)
if gen:
return gen + 1
return None
def get_branch(person, visited=None):
if visited is None:
visited = set()
if person["handle"] in visited:
return "Autre", 99
visited.add(person["handle"])
keller_gen = compute_generation(person, KELLER_ROOT)
staerle_gen = compute_generation(person, STAERLE_ROOT)
if keller_gen:
return "Keller", keller_gen
if staerle_gen:
return "Staerle", staerle_gen
for s in get_spouses(person):
sk, sg = get_branch(s, visited)
if sk and sk != "Autre":
return f"Allié ({sk})", sg + 1
return "Autre", 99
# ── Build data ──────────────────────────────────────────────────────────
# 1. PERSONNES
person_rows = [["ID", "Prénom", "Nom", "Nom complet", "Genre", "Naissance", "Décès",
"Branche", "Génération", "Père", "Mère",
"Conjoint(s)", "Enfants", "Profession/Notes"]]
person_branches = []
for p in persons:
pid = p.get("gramps_id", "")
n = p.get("primary_name", {})
first = n.get("first_name", "").strip()
sur_list = n.get("surname_list", [])
sur = sur_list[0].get("surname", "").strip() if sur_list else ""
gender_map = {0: "F", 1: "M", 2: "?"}
gender = gender_map.get(p.get("gender"), "?")
birth, death = get_person_events(p)
branch, gen = get_branch(p)
father, mother = get_parents(p)
father_name = person_name(father) if father else ""
mother_name = person_name(mother) if mother else ""
spouses = get_spouses(p)
spouse_names = ", ".join(person_name(s) for s in spouses)
children = get_children_for_person(p)
child_names = ", ".join(person_name(c) for c in children)
notes = "; ".join(get_notes_for_person(p))
person_rows.append([pid, first, sur, f"{first} {sur}", gender, birth, death,
branch, gen, father_name, mother_name,
spouse_names, child_names, notes])
person_branches.append(branch)
# 2. FAMILLES
family_rows = [["ID", "Père", "Mère", "Type", "Date mariage", "Lieu mariage", "Enfants"]]
for f in families:
fid = f.get("gramps_id", "")
father = by_handle.get(f.get("father_handle"))
mother = by_handle.get(f.get("mother_handle"))
f_name = person_name(father) if father else "?"
m_name = person_name(mother) if mother else "?"
rel_type = f.get("type", {}).get("value", 0)
rel_str = "Marié" if rel_type == 0 else "Non marié" if rel_type == 3 else ""
marriage_date, marriage_place = "", ""
for er in f.get("event_ref_list", []):
ev = by_handle.get(er.get("ref"))
if ev:
marriage_date = format_date(ev.get("date"))
marriage_place = get_place_name(ev.get("place", ""))
children = get_children(f)
enfants = ", ".join(person_name(c) for c in children)
family_rows.append([fid, f_name, m_name, rel_str, marriage_date, marriage_place, enfants])
# 3. ÉVÉNEMENTS
event_rows = [["ID", "Type", "Date", "Personne", "Lieu", "Description"]]
for ev in events:
eid = ev.get("gramps_id", "")
etype = event_type_str(ev.get("type", {}))
date = format_date(ev.get("date"))
place = get_place_name(ev.get("place", ""))
desc = ev.get("description", "")
person = ""
for p in persons:
for er in p.get("event_ref_list", []):
if er.get("ref") == ev["handle"]:
person = person_name(p)
break
if person:
break
event_rows.append([eid, etype, date, person, place, desc])
# 4. LIEUX
place_rows = [["ID", "Nom", "Type", "Latitude", "Longitude", "Fait partie de"]]
for pl in places:
pid = pl.get("gramps_id", "")
name = pl.get("name", {}).get("value", "")
ptype = pl.get("place_type", {}).get("string", "")
lat = pl.get("lat", "")
lon = pl.get("long", "")
parent_place = ""
for pr in pl.get("placeref_list", []):
parent = by_handle.get(pr.get("ref"))
if parent:
parent_place = parent.get("name", {}).get("value", "")
place_rows.append([pid, name, ptype, lat, lon, parent_place])
# 5. MÉDIAS
media_rows = [["ID", "Description", "Type MIME", "Chemin", "Date"]]
for m in media_list:
mid = m.get("gramps_id", "")
desc = m.get("desc", "")
mime = m.get("mime", "")
path = m.get("path", "")
date_str = format_date(m.get("date", {}))
media_rows.append([mid, desc, mime, path, date_str])
# 6. STATISTIQUES
stats_rows = [
["Métrique", "Valeur"],
["Total personnes", len(persons)],
["Hommes", sum(1 for p in persons if p.get("gender") == 1)],
["Femmes", sum(1 for p in persons if p.get("gender") == 0)],
["Total familles", len(families)],
["Familles mariées", sum(1 for f in families if f.get("type", {}).get("value") == 0)],
["Total événements", len(events)],
["Naissances", sum(1 for ev in events if ev.get("type", {}).get("value") in (6, 12))],
["Décès", sum(1 for ev in events if ev.get("type", {}).get("value") in (3, 13))],
["Mariages", sum(1 for ev in events if ev.get("type", {}).get("value") in (0, 1))],
["Total lieux", len(places)],
["Total médias", len(media_list)],
["", ""],
["Branche Keller", ""],
[" Personnes Keller", sum(1 for b in person_branches if b == "Keller")],
["Branche Staerle", ""],
[" Personnes Staerle", sum(1 for b in person_branches if b and "Staerle" in b)],
]
# ── Write to Sheets ────────────────────────────────────────────────────
TOKEN = open("/tmp/gsheet_token.txt").read().strip()
def get_sheets():
url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}?fields=sheets.properties"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {TOKEN}"})
resp = json.loads(urllib.request.urlopen(req).read())
return resp.get("sheets", [])
def get_sheet_id(sheets, name):
for s in sheets:
if s["properties"]["title"] == name:
return s["properties"]["sheetId"]
return None
def clear_sheet(sheet_id):
"""Clear all content from a sheet."""
body = json.dumps({
"requests": [{
"updateCells": {
"range": {"sheetId": sheet_id},
"fields": "userEnteredValue"
}
}]
})
url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}:batchUpdate"
req = urllib.request.Request(url, data=body.encode(),
method="POST",
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
urllib.request.urlopen(req)
def add_sheet(title):
"""Add a new sheet, return its ID."""
body = json.dumps({
"requests": [{
"addSheet": {
"properties": {
"title": title,
"gridProperties": {"rowCount": 2000, "columnCount": 26}
}
}
}]
})
url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}:batchUpdate"
req = urllib.request.Request(url, data=body.encode(),
method="POST",
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
resp = json.loads(urllib.request.urlopen(req).read())
return resp["replies"][0]["addSheet"]["properties"]["sheetId"]
def write_sheet(sheet_id, rows):
"""Write rows to a sheet by ID."""
# Clear first
clear_sheet(sheet_id)
# Write data
body = json.dumps({"values": rows, "majorDimension": "ROWS"})
# Use the sheet title from the ID — we need to find it
write_url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}/values/A1?valueInputOption=USER_ENTERED"
write_req = urllib.request.Request(write_url, data=body.encode(),
method="PUT",
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
resp = json.loads(urllib.request.urlopen(write_req).read())
return resp.get('updatedCells', 0)
def write_sheet_by_name(sheet_name, rows):
"""Write rows to a sheet by name."""
encoded_name = urllib.parse.quote(sheet_name)
range_name = f"{encoded_name}!A1"
body = json.dumps({"values": rows, "majorDimension": "ROWS"})
write_url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}/values/{range_name}?valueInputOption=USER_ENTERED"
write_req = urllib.request.Request(write_url, data=body.encode(),
method="PUT",
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
resp = json.loads(urllib.request.urlopen(write_req).read())
return resp.get('updatedCells', 0)
def format_sheet(sheet_id, num_cols, num_rows, name="Sheet", alt_color=None):
"""Apply formatting: frozen header, auto-filter, alternating colors."""
requests = []
# Freeze header row
requests.append({
"updateSheetProperties": {
"properties": {
"sheetId": sheet_id,
"gridProperties": {"frozenRowCount": 1}
},
"fields": "gridProperties.frozenRowCount"
}
})
# Auto-filter
if num_rows > 1:
requests.append({
"setBasicFilter": {
"filter": {
"range": {
"sheetId": sheet_id,
"startRowIndex": 0,
"endRowIndex": num_rows,
"startColumnIndex": 0,
"endColumnIndex": num_cols
}
}
}
})
# Header: bold white on dark blue
requests.append({
"repeatCell": {
"range": {
"sheetId": sheet_id,
"startRowIndex": 0, "endRowIndex": 1,
"startColumnIndex": 0, "endColumnIndex": num_cols
},
"cell": {
"userEnteredFormat": {
"textFormat": {"bold": True, "foregroundColor": {"red": 1, "green": 1, "blue": 1}},
"backgroundColor": {"red": 0.15, "green": 0.23, "blue": 0.35},
"verticalAlignment": "MIDDLE"
}
},
"fields": "userEnteredFormat(textFormat,backgroundColor,verticalAlignment)"
}
})
# Alternating row colors
if alt_color and num_rows > 1:
for i in range(1, num_rows):
if i % 2 == 0:
requests.append({
"repeatCell": {
"range": {
"sheetId": sheet_id,
"startRowIndex": i, "endRowIndex": i + 1,
"startColumnIndex": 0, "endColumnIndex": num_cols
},
"cell": {"userEnteredFormat": {"backgroundColor": alt_color}},
"fields": "userEnteredFormat.backgroundColor"
}
})
# Auto-resize columns
requests.append({
"autoResizeDimensions": {
"dimensions": {
"sheetId": sheet_id,
"dimension": "COLUMNS",
"startIndex": 0, "endIndex": num_cols
}
}
})
body = json.dumps({"requests": requests})
url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}:batchUpdate"
req = urllib.request.Request(url, data=body.encode(),
method="POST",
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
urllib.request.urlopen(req)
print(f" ✓ {name}: formatted")
# ── Main ───────────────────────────────────────────────────────────────
print("Setting up sheets...")
sheets = get_sheets()
existing_names = {s["properties"]["title"] for s in sheets}
# Add missing sheets
for name in ["Médias", "Statistiques"]:
if name not in existing_names:
add_sheet(name)
print(f" ✓ Added sheet '{name}'")
# Write data to all sheets
print("\nWriting data...")
sheets = get_sheets()
for name, rows in [("Personnes", person_rows), ("Familles", family_rows),
("Événements", event_rows), ("Lieux", place_rows),
("Médias", media_rows), ("Statistiques", stats_rows)]:
sid = get_sheet_id(sheets, name)
if sid:
cells = write_sheet_by_name(name, rows)
print(f" ✓ {name}: {cells} cells written")
else:
print(f" ✗ {name}: sheet not found")
# Format all sheets
print("\nFormatting...")
sheets = get_sheets()
formats = [
("Personnes", person_rows, {"red": 0.95, "green": 0.97, "blue": 1.0}),
("Familles", family_rows, {"red": 1.0, "green": 0.97, "blue": 0.95}),
("Événements", event_rows, {"red": 0.95, "green": 0.95, "blue": 0.95}),
("Lieux", place_rows, {"red": 0.97, "green": 0.97, "blue": 0.95}),
("Médias", media_rows, {"red": 0.95, "green": 0.95, "blue": 0.97}),
("Statistiques", stats_rows, {"red": 0.95, "green": 0.95, "blue": 0.95}),
]
for name, rows, color in formats:
sid = get_sheet_id(sheets, name)
if sid:
format_sheet(sid, len(rows[0]), len(rows), name=name, alt_color=color)
# ── Color-code by branch on Personnes ──────────────────────────────────
print("\nColoring by branch...")
sid_p = get_sheet_id(sheets, "Personnes")
if sid_p:
branch_colors = {
"Keller": {"red": 0.85, "green": 0.92, "blue": 1.0},
"Staerle": {"red": 0.85, "green": 1.0, "blue": 0.85},
}
for i, branch in enumerate(person_branches):
row_idx = i + 1
color = None
for key, c in branch_colors.items():
if branch and key in branch:
color = c
break
if color:
req_body = json.dumps({
"requests": [{
"repeatCell": {
"range": {
"sheetId": sid_p,
"startRowIndex": row_idx, "endRowIndex": row_idx + 1,
"startColumnIndex": 0, "endColumnIndex": len(person_rows[0])
},
"cell": {"userEnteredFormat": {"backgroundColor": color}},
"fields": "userEnteredFormat.backgroundColor"
}
}]
})
url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}:batchUpdate"
req = urllib.request.Request(url, data=req_body.encode(),
method="POST",
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
urllib.request.urlopen(req)
print(f" ✓ Personnes: {len(person_branches)} rows colored by branch")
print(f"\n✅ Done! Open: https://docs.google.com/spreadsheets/d/{SPREADSHEET_ID}/edit")
print(f" Personnes: {len(person_rows)-1} | Familles: {len(family_rows)-1} | Événements: {len(event_rows)-1}")
print(f" Lieux: {len(place_rows)-1} | Médias: {len(media_rows)-1} | Statistiques: {len(stats_rows)-1}")