Add one-shot scripts (plan maison, généalogie, OAuth, etc.)

This commit is contained in:
2026-08-05 12:31:35 +02:00
parent 1936ded08f
commit 6b0f52b577
9 changed files with 2683 additions and 3 deletions
+11 -3
View File
@@ -16,9 +16,17 @@ homelab-scripts/
├── maintenance/ # Tâches de fond
│ ├── archive-old-sessions.sh # Archive sessions Hermes (3h)
│ └── check-obsolete.sh # Rapport éléments obsolètes (lundi)
── web/ # Surveillance sites
├── site-test.py # Audit sécurité/UX laurentkeller.org
└── suivi-quotidien.py # Suivi quotidien Mon-site-web-photos
── web/ # Surveillance sites
├── site-test.py # Audit sécurité/UX laurentkeller.org
└── suivi-quotidien.py # Suivi quotidien Mon-site-web-photos
└── one-shot/ # Scripts ponctuels (réutilisables)
├── plan_maison.py # Plan d'étage matplotlib
├── generate-honcho-pdf.py # PDF architecture Honcho
├── generate_gramps_report.py # Rapport généalogie PDF
├── google-workspace-mcp-login.py # OAuth Google Workspace
├── oauth-step1.py / oauth-step2.py # Flow OAuth
├── populate_gramps_sheet.py # Gramps → Google Sheet
└── fix_maxbuffer.py # Fix maxBuffer wiremcp-sse
```
## Déploiement
+33
View File
@@ -0,0 +1,33 @@
import re
with open('/opt/WireMCP/index.js', 'r') as f:
content = f.read()
# Replace the promisify line to include maxBuffer default
content = content.replace(
'const execAsync = promisify(exec);',
'const execAsync = promisify(exec);\nconst MAX_BUFFER = 100 * 1024 * 1024; // 100MB for large packet captures'
)
# Remove duplicate if any
lines = content.split('\n')
seen = set()
unique_lines = []
for line in lines:
if 'MAX_BUFFER' in line and line in seen:
continue
if 'MAX_BUFFER' in line:
seen.add(line)
unique_lines.append(line)
content = '\n'.join(unique_lines)
# Now wrap execAsync to pass maxBuffer by default
content = content.replace(
'const execAsync = promisify(exec);\nconst MAX_BUFFER = 100 * 1024 * 1024;',
'const _execAsync = promisify(exec);\nconst MAX_BUFFER = 100 * 1024 * 1024;\nconst execAsync = (cmd, opts = {}) => _execAsync(cmd, { maxBuffer: MAX_BUFFER, ...opts });'
)
with open('/opt/WireMCP/index.js', 'w') as f:
f.write(content)
print('OK')
+550
View File
@@ -0,0 +1,550 @@
#!/usr/bin/env python3
"""Génère un PDF expliquant l'architecture Honcho + Hermes"""
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm, cm
from reportlab.lib.colors import HexColor, black, white
from reportlab.lib.enums import TA_LEFT, TA_CENTER
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, PageBreak,
Table, TableStyle, ListFlowable, ListItem, KeepTogether
)
from reportlab.platypus.flowables import HRFlowable
from reportlab.lib import colors
import os
OUTPUT = "/root/workspace/honcho-hermes-architecture.pdf"
# Couleurs
DARK = HexColor("#0f172a")
ACCENT = HexColor("#3b82f6")
GREEN = HexColor("#22c55e")
GRAY = HexColor("#64748b")
LIGHT = HexColor("#f1f5f9")
ORANGE = HexColor("#f59e0b")
doc = SimpleDocTemplate(
OUTPUT,
pagesize=A4,
topMargin=2*cm,
bottomMargin=2*cm,
leftMargin=2.5*cm,
rightMargin=2.5*cm,
)
styles = getSampleStyleSheet()
# Styles personnalisés
styles.add(ParagraphStyle(
"CoverTitle", parent=styles["Title"],
fontSize=28, leading=34, textColor=DARK,
spaceAfter=6*mm, alignment=TA_CENTER,
fontName="Helvetica-Bold"
))
styles.add(ParagraphStyle(
"CoverSub", parent=styles["Normal"],
fontSize=14, leading=18, textColor=GRAY,
spaceAfter=4*mm, alignment=TA_CENTER,
fontName="Helvetica"
))
styles.add(ParagraphStyle(
"SectionTitle", parent=styles["Heading1"],
fontSize=18, leading=22, textColor=DARK,
spaceBefore=10*mm, spaceAfter=4*mm,
fontName="Helvetica-Bold"
))
styles.add(ParagraphStyle(
"SubTitle", parent=styles["Heading2"],
fontSize=14, leading=18, textColor=ACCENT,
spaceBefore=6*mm, spaceAfter=3*mm,
fontName="Helvetica-Bold"
))
styles.add(ParagraphStyle(
"Body", parent=styles["Normal"],
fontSize=10, leading=14, textColor=HexColor("#334155"),
spaceAfter=3*mm,
fontName="Helvetica"
))
# "Code" style already exists in default stylesheet
styles.add(ParagraphStyle(
"MyBullet", parent=styles["Normal"],
fontSize=10, leading=14, textColor=HexColor("#334155"),
leftIndent=8*mm, spaceAfter=2*mm,
fontName="Helvetica"
))
styles.add(ParagraphStyle(
"MySmallNote", parent=styles["Normal"],
fontSize=8, leading=10, textColor=GRAY,
spaceAfter=2*mm, fontName="Helvetica-Oblique"
))
story = []
# ===== PAGE DE COUVERTURE =====
story.append(Spacer(1, 4*cm))
story.append(Paragraph("Honcho + Hermes", styles["CoverTitle"]))
story.append(Paragraph("Architecture & Déploiement", styles["CoverSub"]))
story.append(Spacer(1, 3*mm))
story.append(HRFlowable(width="60%", thickness=1, color=ACCENT))
story.append(Spacer(1, 5*mm))
story.append(Paragraph("Mémoire persistante cross-session pour Hermes Agent", styles["CoverSub"]))
story.append(Spacer(1, 2*cm))
story.append(Paragraph("Laurent Keller — Homelab 2026", styles["MySmallNote"]))
story.append(PageBreak())
# ===== TABLE DES MATIÈRES =====
story.append(Paragraph("Table des matières", styles["SectionTitle"]))
story.append(Spacer(1, 3*mm))
toc_items = [
"1. Qu'est-ce que Honcho ?",
"2. Architecture globale",
"3. Composants déployés",
"4. Flux de fonctionnement",
"5. Configuration Hermes",
"6. Commandes utiles",
"7. Schéma réseau",
"8. Dépannage",
]
for item in toc_items:
story.append(Paragraph(item, styles["MyBullet"]))
story.append(PageBreak())
# ===== 1. QU'EST-CE QUE HONCHO =====
story.append(Paragraph("1. Qu'est-ce que Honcho ?", styles["SectionTitle"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
story.append(Spacer(1, 3*mm))
story.append(Paragraph(
"Honcho est une infrastructure mémoire open-source (AGPL-3.0) développée par Plastic Labs. "
"Elle permet aux agents AI de construire une représentation persistante de l'utilisateur "
"à travers les sessions — contrairement à une mémoire fichier classique qui ne fait que "
"stocker du texte brut.",
styles["Body"]
))
story.append(Paragraph("Ce que Honcho apporte à Hermes :", styles["SubTitle"]))
bullets = [
"<b>Mémoire cross-session</b> — l'agent se souvient de qui vous êtes entre deux conversations",
"<b>Représentation utilisateur</b> — Honcho analyse les conversations et extrait des conclusions (préférences, patterns, style de communication)",
"<b>Dialectic engine</b> — un moteur de raisonnement en arrière-plan qui synthétise la mémoire",
"<b>Recherche sémantique</b> — retrouver des informations par similarité de sens, pas par mot-clé",
"<b>Multi-peers</b> — chaque profil Hermes a sa propre identité AI tout en partageant la vue utilisateur",
]
for b in bullets:
story.append(Paragraph(b, styles["MyBullet"]))
story.append(Spacer(1, 3*mm))
story.append(Paragraph("Le cœur du système :", styles["SubTitle"]))
story.append(Paragraph(
"1. <b>Store</b> — les conversations sont stockées dans PostgreSQL avec embeddings vectoriels<br/>"
"2. <b>Reason</b> — le Deriver (worker background) analyse les messages et met à jour la représentation<br/>"
"3. <b>Query</b> — Hermes interroge Honcho via 5 outils pour récupérer le contexte<br/>"
"4. <b>Inject</b> — le contexte est injecté dans le system prompt à chaque tour",
styles["Body"]
))
story.append(PageBreak())
# ===== 2. ARCHITECTURE GLOBALE =====
story.append(Paragraph("2. Architecture globale", styles["SectionTitle"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
story.append(Spacer(1, 3*mm))
story.append(Paragraph(
"Le déploiement s'appuie sur 3 VMs du homelab, interconnectées via le réseau 192.168.30.0/24 :",
styles["Body"]
))
# Tableau des VMs
vm_data = [
["VM", "IP", "Rôle", "Services"],
["Kellerflix\n(VM 101)", "192.168.30.10", "LLM & Embeddings", "Ollama\nnemotron-mini:4b\nnomic-embed-text"],
["postgres\n(LXC/VM)", "192.168.30.35", "Honcho Server", "Honcho API (:8000)\nHoncho Deriver\nPostgreSQL pgvector (:5434)"],
["redis\n(LXC/VM)", "192.168.30.36", "Cache", "Redis (:6379)"],
]
vm_table = Table(vm_data, colWidths=[3.5*cm, 3*cm, 3*cm, 5.5*cm])
vm_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), DARK),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, GRAY),
("BACKGROUND", (0, 1), (-1, -1), LIGHT),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]))
story.append(vm_table)
story.append(Spacer(1, 5*mm))
story.append(Paragraph("Flux des données :", styles["SubTitle"]))
story.append(Paragraph(
"Hermes (LXC 112) → Honcho API (192.168.30.35:8000) → PostgreSQL pgvector<br/>"
"Honcho Deriver → Ollama (192.168.30.10:11434) pour LLM + embeddings<br/>"
"Honcho API → Redis (192.168.30.36:6379) pour cache",
styles["Body"]
))
story.append(PageBreak())
# ===== 3. COMPOSANTS DÉPLOYÉS =====
story.append(Paragraph("3. Composants déployés", styles["SectionTitle"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
story.append(Spacer(1, 3*mm))
# 3.1 Honcho API
story.append(Paragraph("3.1 Honcho API (FastAPI)", styles["SubTitle"]))
story.append(Paragraph(
"Serveur HTTP qui expose l'API REST de Honcho. Point d'entrée pour Hermes et le SDK Python. "
"Gère les sessions, peers, messages, et la recherche contextuelle.",
styles["Body"]
))
story.append(Paragraph(
"Port : 8000<br/>"
"Healthcheck : GET /health → {\"status\":\"ok\"}<br/>"
"Docker : honcho-api (build local depuis le Dockerfile du repo plastic-labs/honcho)",
styles["Body"]
))
# 3.2 Honcho Deriver
story.append(Paragraph("3.2 Honcho Deriver (Worker background)", styles["SubTitle"]))
story.append(Paragraph(
"Processus qui tourne en arrière-plan et analyse les messages stockés. "
"Il utilise le LLM configuré (nemotron-mini:4b) pour :",
styles["Body"]
))
bullets = [
"Extraire des conclusions sur l'utilisateur (préférences, habitudes)",
"Générer des résumés de sessions",
"Mettre à jour la représentation du peer",
"Alimenter le moteur dialectic pour honcho_reasoning",
]
for b in bullets:
story.append(Paragraph(b, styles["MyBullet"]))
# 3.3 PostgreSQL pgvector
story.append(Paragraph("3.3 PostgreSQL + pgvector", styles["SubTitle"]))
story.append(Paragraph(
"Base de données vectorielle. Stocke les messages, sessions, peers, et les embeddings "
"(vecteurs à 768 dimensions pour nomic-embed-text). L'extension pgvector permet la "
"recherche par similarité sémantique.",
styles["Body"]
))
story.append(Paragraph(
"Image : pgvector/pgvector:pg15<br/>"
"Port interne : 5434 (évite conflit avec le PostgreSQL existant sur 5432)<br/>"
"Volume : honcho-pgdata (persistant)",
styles["Body"]
))
# 3.4 Redis
story.append(Paragraph("3.4 Redis (Cache)", styles["SubTitle"]))
story.append(Paragraph(
"Cache pour accélérer les requêtes répétées. Redis existant sur le homelab, "
"réutilisé sans conteneur dédié dans le stack Honcho.",
styles["Body"]
))
story.append(Paragraph(
"Hôte : 192.168.30.36:6379<br/>"
"Pas de mot de passe (réseau interne)",
styles["Body"]
))
# 3.5 Ollama
story.append(Paragraph("3.5 Ollama (LLM & Embeddings)", styles["SubTitle"]))
story.append(Paragraph(
"Serveur de modèles LLM locaux sur Kellerflix (GTX 1070, 8GB VRAM). "
"Deux modèles utilisés :",
styles["Body"]
))
bullets = [
"<b>nemotron-mini:4b</b> (Q4_K_M, 2.7GB) — LLM pour le dialectic engine et le deriver. Supporte le tool calling.",
"<b>nomic-embed-text</b> (274MB) — modèle d'embeddings pour la recherche vectorielle (768 dimensions).",
]
for b in bullets:
story.append(Paragraph(b, styles["MyBullet"]))
story.append(PageBreak())
# ===== 4. FLUX DE FONCTIONNEMENT =====
story.append(Paragraph("4. Flux de fonctionnement", styles["SectionTitle"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
story.append(Spacer(1, 3*mm))
story.append(Paragraph("4.1 À chaque message utilisateur", styles["SubTitle"]))
steps = [
"<b>1.</b> Hermes reçoit le message de l'utilisateur",
"<b>2.</b> Honcho injecte automatiquement le contexte dans le system prompt (représentation utilisateur + résumé de session + carte AI peer)",
"<b>3.</b> Hermes traite la requête avec ce contexte",
"<b>4.</b> La réponse est envoyée à l'utilisateur",
"<b>5.</b> Le message est stocké dans Honcho (via l'API)",
"<b>6.</b> Le Deriver analyse le message en arrière-plan et met à jour la représentation",
]
for s in steps:
story.append(Paragraph(s, styles["MyBullet"]))
story.append(Paragraph("4.2 Modes de rappel (Recall Modes)", styles["SubTitle"]))
recall_data = [
["Mode", "Auto-injection", "Outils mémoire", "Usage"],
["hybrid\n(default)", "Oui", "Oui (5 outils)", "L'agent décide quand utiliser les outils vs le contexte auto-injecté"],
["context", "Oui", "Non (cachés)", "Économique en tokens, pas d'appels outils"],
["tools", "Non", "Oui", "L'agent contrôle tout explicitement"],
]
recall_table = Table(recall_data, colWidths=[3*cm, 2.5*cm, 2.5*cm, 6*cm])
recall_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), DARK),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, GRAY),
("BACKGROUND", (0, 1), (-1, -1), LIGHT),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]))
story.append(recall_table)
story.append(Spacer(1, 3*mm))
story.append(Paragraph("4.3 Les 5 outils mémoire", styles["SubTitle"]))
tools_data = [
["Outil", "Appel LLM ?", "Coût", "Usage"],
["honcho_profile", "Non", "minimal", "Lire/mettre à jour la carte du peer (nom, rôle, préférences)"],
["honcho_search", "Non", "faible", "Recherche sémantique dans l'historique"],
["honcho_context", "Non", "faible", "Snapshot complet : résumé + représentation + messages récents"],
["honcho_reasoning", "Oui", "moyen-élevé", "Question synthétisée par le dialectic engine"],
["honcho_conclude", "Non", "minimal", "Écrire/supprimer une conclusion persistante"],
]
tools_table = Table(tools_data, colWidths=[3*cm, 2*cm, 2*cm, 7*cm])
tools_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), DARK),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, GRAY),
("BACKGROUND", (0, 1), (-1, -1), LIGHT),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]))
story.append(tools_table)
story.append(PageBreak())
# ===== 5. CONFIGURATION HERMES =====
story.append(Paragraph("5. Configuration Hermes", styles["SectionTitle"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
story.append(Spacer(1, 3*mm))
story.append(Paragraph("5.1 Fichier honcho.json", styles["SubTitle"]))
story.append(Paragraph(
"Chemin : /root/.hermes/honcho.json",
styles["Body"]
))
story.append(Paragraph(
"{<br/>"
" \"baseUrl\": \"http://192.168.30.35:8000\",<br/>"
" \"peerName\": \"laurent\",<br/>"
" \"aiPeer\": \"hermes\",<br/>"
" \"workspace\": \"default\",<br/>"
" \"recallMode\": \"hybrid\",<br/>"
" \"writeFrequency\": \"async\",<br/>"
" \"sessionStrategy\": \"per-directory\",<br/>"
" \"observation\": {<br/>"
" \"user\": { \"observeMe\": true, \"observeOthers\": true },<br/>"
" \"ai\": { \"observeMe\": true, \"observeOthers\": true }<br/>"
" },<br/>"
" \"dialecticReasoningLevel\": \"low\",<br/>"
" \"dialecticDepth\": 1,<br/>"
" \"contextCadence\": 1,<br/>"
" \"dialecticCadence\": 2<br/>"
"}",
styles["Code"]
))
story.append(Paragraph("5.2 Config Hermes", styles["SubTitle"]))
story.append(Paragraph(
"Dans /root/.hermes/config.yaml :",
styles["Body"]
))
story.append(Paragraph(
"memory:<br/>"
" provider: honcho",
styles["Code"]
))
story.append(Paragraph("5.3 Fichier .env Honcho", styles["SubTitle"]))
story.append(Paragraph(
"Chemin sur la VM postgres : /root/docker/honcho/.env",
styles["Body"]
))
story.append(Paragraph(
"LOG_LEVEL=INFO<br/>"
"AUTH_USE_AUTH=false<br/>"
"VECTOR_STORE_TYPE=pgvector<br/>"
"LLM_OPENAI_API_KEY=ollama<br/>"
"DERIVER_MODEL_CONFIG__TRANSPORT=openai<br/>"
"DERIVER_MODEL_CONFIG__MODEL=nemotron-mini:4b<br/>"
"DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://192.168.30.10:11434/v1<br/>"
"DERIVER_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=ollama<br/>"
"EMBEDDING_MODEL_CONFIG__TRANSPORT=openai<br/>"
"EMBEDDING_MODEL_CONFIG__MODEL=nomic-embed-text<br/>"
"EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://192.168.30.10:11434/v1<br/>"
"EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=ollama<br/>"
"EMBED_MESSAGES=true<br/>"
"EMBEDDING_VECTOR_DIMENSIONS=768",
styles["Code"]
))
story.append(PageBreak())
# ===== 6. COMMANDES UTILES =====
story.append(Paragraph("6. Commandes utiles", styles["SectionTitle"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
story.append(Spacer(1, 3*mm))
story.append(Paragraph("6.1 Sur Hermes (LXC 112)", styles["SubTitle"]))
cmds = [
("hermes honcho status", "Voir l'état de la connexion Honcho"),
("hermes honcho setup", "Lancer le wizard de configuration"),
("hermes honcho enable/disable", "Activer/désactiver Honcho"),
("hermes honcho peer --user <nom>", "Définir le nom du peer utilisateur"),
("hermes honcho mode hybrid|context|tools", "Changer le mode de rappel"),
("hermes honcho sessions", "Lister les sessions connues"),
("hermes honcho map <nom>", "Mapper le répertoire courant à une session"),
("hermes honcho sync", "Créer les host blocks pour tous les profils"),
("hermes memory status", "Voir le provider mémoire actif"),
("hermes memory off", "Désactiver la mémoire externe"),
]
for cmd, desc in cmds:
story.append(Paragraph(f"<b>{cmd}</b> — {desc}", styles["MyBullet"]))
story.append(Paragraph("6.2 Sur la VM postgres (192.168.30.35)", styles["SubTitle"]))
cmds2 = [
("docker compose ps", "Voir l'état des conteneurs"),
("docker compose logs api --tail 30", "Logs de l'API Honcho"),
("docker compose logs deriver --tail 30", "Logs du Deriver"),
("docker compose down && docker compose up -d", "Redémarrer le stack"),
("docker compose down -v && docker compose up -d --build", "Reset complet + rebuild"),
("curl http://localhost:8000/health", "Healthcheck"),
]
for cmd, desc in cmds2:
story.append(Paragraph(f"<b>{cmd}</b> — {desc}", styles["MyBullet"]))
story.append(Paragraph("6.3 Sur Kellerflix (192.168.30.10)", styles["SubTitle"]))
cmds3 = [
("ollama list", "Lister les modèles disponibles"),
("ollama pull <modele>", "Télécharger un modèle"),
("ollama run <modele>", "Tester un modèle en interactif"),
("curl http://localhost:11434/api/tags", "Lister les modèles via API"),
]
for cmd, desc in cmds3:
story.append(Paragraph(f"<b>{cmd}</b> — {desc}", styles["MyBullet"]))
story.append(PageBreak())
# ===== 7. SCHÉMA RÉSEAU =====
story.append(Paragraph("7. Schéma réseau", styles["SectionTitle"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
story.append(Spacer(1, 3*mm))
story.append(Paragraph(
"Le schéma ci-dessous représente les flux entre les composants :",
styles["Body"]
))
story.append(Spacer(1, 3*mm))
# Schéma ASCII
schema = """<font face="Courier" size="8">
┌─────────────────────────────────────────────────────────────┐
│ RÉSEAU 192.168.30.0/24 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ Hermes Agent │ │ VM postgres (30.35) │ │
│ │ (LXC 112) │ │ │ │
│ │ │ │ ┌──────────────────────┐ │ │
│ │ honcho-ai SDK │────▶│ │ Honcho API (:8000) │ │ │
│ │ hermes honcho │ │ │ (FastAPI) │ │ │
│ │ │ │ └──────┬───────────────┘ │ │
│ └──────────────────┘ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ PostgreSQL pgvector │ │ │
│ │ │ (:5434) │ │ │
│ │ └──────────────────────┘ │ │
│ │ ▲ │ │
│ │ ┌──────┴───────────────┐ │ │
│ │ │ Honcho Deriver │ │ │
│ │ │ (worker background) │ │ │
│ │ └──────┬───────────────┘ │ │
│ └─────────┼────────────────────┘ │
│ │ │
│ ┌─────────┼────────────┐ │
│ │ ▼ │ │
│ │ ┌──────────────┐ │ │
│ │ │ Redis │ │ │
│ │ │ (30.36:6379) │ │ │
│ │ └──────────────┘ │ │
│ └──────────────────────┘ │
│ │ │
│ ┌─────────┼────────────┐ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Kellerflix │ │ │
│ │ │ (30.10:11434) │ │ │
│ │ │ Ollama │ │ │
│ │ │ ├─ nemotron-mini│ │ │
│ │ │ └─ nomic-embed │ │ │
│ │ └──────────────────┘ │ │
│ └────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
</font>"""
story.append(Paragraph(schema, styles["Body"]))
story.append(PageBreak())
# ===== 8. DÉPANNAGE =====
story.append(Paragraph("8. Dépannage", styles["SectionTitle"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
story.append(Spacer(1, 3*mm))
issues = [
("Problème", "Cause probable", "Solution"),
("Honcho not configured", "memory.provider pas défini", "hermes config set memory.provider honcho"),
("API Honcho injoignable", "Conteneur down", "docker compose ps && docker compose logs api"),
("OpenAI API key required", "LLM_OPENAI_API_KEY manquant", "Ajouter LLM_OPENAI_API_KEY=ollama dans .env"),
("Embedding dim mismatch", "Dimensions changées après init", "docker compose down -v && docker compose up -d --build"),
("Deriver ne tourne pas", "Dépendance api pas healthy", "Vérifier les logs : docker compose logs deriver"),
("Peer conflict", "Peer déjà créé", "Utiliser un nom différent ou supprimer l'ancien"),
("Ollama inaccessible", "OLLAMA_HOST=127.0.0.1", "Vérifier ss -tlnp | grep 11434"),
("Mémoire non persistante", "writeFrequency = session", "Passer à async dans honcho.json"),
]
issues_table = Table(issues, colWidths=[3.5*cm, 4*cm, 6.5*cm])
issues_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), DARK),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, GRAY),
("BACKGROUND", (0, 1), (-1, -1), LIGHT),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 4),
]))
story.append(issues_table)
story.append(Spacer(1, 5*mm))
story.append(Paragraph("Liens utiles :", styles["SubTitle"]))
story.append(Paragraph(
"Documentation Honcho : https://docs.honcho.dev<br/>"
"Repo Honcho : https://github.com/plastic-labs/honcho<br/>"
"Intégration Hermes : https://docs.honcho.dev/v3/guides/integrations/hermes<br/>"
"App Honcho Cloud : https://app.honcho.dev<br/>"
"Ollama : https://ollama.com",
styles["Body"]
))
# Build
doc.build(story)
print(f"PDF généré : {OUTPUT}")
print(f"Taille : {os.path.getsize(OUTPUT)} bytes")
+760
View File
@@ -0,0 +1,760 @@
#!/usr/bin/env python3
"""Generate a structured PDF genealogy report from Gramps JSONL export."""
import json
import sys
from collections import defaultdict
from datetime import datetime
from fpdf import FPDF
# ── Config ──────────────────────────────────────────────────────────────────
INPUT = "/root/.hermes/webui/attachments/b3196dc4d94f/gramps-web-export-20260722234742.json"
# ── 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}
# ── Event type mapping ───────────────────────────────────────────────────────
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 "Inconnu"
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_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_events_for_person(p):
events = []
for er in p.get("event_ref_list", []):
ev = by_handle.get(er.get("ref"))
if ev:
events.append({
"type": event_type_str(ev.get("type", {})),
"date": format_date(ev.get("date")),
"desc": ev.get("description", ""),
"place": ev.get("place", ""),
})
return events
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_families(person):
return [f for f in families
if f.get("father_handle") == person["handle"]
or f.get("mother_handle") == person["handle"]]
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_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
# ── Index data ──────────────────────────────────────────────────────────────
persons = [o for o in objects if o.get("_class") == "Person"]
families = [o for o in objects if o.get("_class") == "Family"]
places = [o for o in objects if o.get("_class") == "Place"]
media = [o for o in objects if o.get("_class") == "Media"]
notes = [o for o in objects if o.get("_class") == "Note"]
# ── PDF Generation ───────────────────────────────────────────────────────────
class GenealogyPDF(FPDF):
def header(self):
if self.page_no() > 1:
self.set_font("Helvetica", "I", 7)
self.set_text_color(130, 130, 130)
self.cell(0, 5, "Rapport de Genealogie - Familles Keller, Staerle & Alliees", align="C")
self.ln(7)
def footer(self):
self.set_y(-15)
self.set_font("Helvetica", "I", 7)
self.set_text_color(130, 130, 130)
self.cell(0, 8, f"Page {self.page_no()}/{{nb}}", align="C")
def section_title(self, title):
self.set_font("Helvetica", "B", 14)
self.set_text_color(15, 23, 42)
self.cell(0, 8, title)
self.ln(4)
self.set_draw_color(15, 23, 42)
self.set_line_width(0.5)
self.line(self.get_x(), self.get_y(), self.get_x() + 190, self.get_y())
self.ln(6)
def sub_title(self, title):
self.set_font("Helvetica", "B", 11)
self.set_text_color(30, 60, 100)
self.cell(0, 7, title)
self.ln(5)
def body_text(self, text):
self.set_font("Helvetica", "", 9)
self.set_text_color(50, 50, 50)
w = self.w - self.get_x() - self.r_margin
self.multi_cell(w, 4.5, text)
self.ln(1)
def bullet(self, text):
self.set_font("Helvetica", "", 9)
self.set_text_color(50, 50, 50)
x0 = self.get_x()
self.cell(5, 4.5, "-")
w = self.w - self.get_x() - self.r_margin
self.multi_cell(w, 4.5, text)
self.ln(0.5)
def kv(self, key, value):
self.set_font("Helvetica", "B", 9)
self.set_text_color(50, 50, 50)
self.cell(40, 5, key)
self.set_font("Helvetica", "", 9)
w = self.w - self.get_x() - self.r_margin
self.multi_cell(w, 5, value)
self.ln(0.5)
def person_card(self, p, level=0):
name = person_name(p)
birth, death = get_person_events(p)
gender_map = {0: "F", 1: "M", 2: "?"}
gender = gender_map.get(p.get("gender"), "?")
indent = level * 4
self.set_x(self.l_margin + indent)
self.set_font("Helvetica", "B", 10)
self.set_text_color(15, 23, 42)
self.cell(0, 5, f"{name} [{gender}] ({p.get('gramps_id', '')})")
self.ln(5)
self.set_x(self.l_margin + indent + 4)
self.set_font("Helvetica", "", 9)
self.set_text_color(80, 80, 80)
dates = []
if birth:
dates.append(f"Ne(e) le {birth}")
if death:
dates.append(f"Decede(e) le {death}")
if dates:
self.cell(0, 4.5, " | ".join(dates))
self.ln(4.5)
events = get_events_for_person(p)
for ev in events:
if ev["type"] not in ("Naissance", "Décès"):
self.set_x(self.l_margin + indent + 4)
self.set_font("Helvetica", "", 8)
self.set_text_color(100, 100, 100)
txt = f"{ev['type']}: {ev['date']}" if ev["date"] else ev["type"]
if ev["place"]:
place_name = get_place_name(ev["place"])
if place_name:
txt += f" - {place_name}"
if ev["desc"]:
txt += f" ({ev['desc']})"
self.cell(0, 4, txt)
self.ln(4)
# Notes (professions, etc.)
p_notes = get_notes_for_person(p)
for n in p_notes:
self.set_x(self.l_margin + indent + 4)
self.set_font("Helvetica", "I", 8)
self.set_text_color(120, 120, 120)
self.cell(0, 4, f"[{n.strip()}]")
self.ln(4)
self.ln(2)
def family_section(self, family, label=""):
father = by_handle.get(family.get("father_handle"))
mother = by_handle.get(family.get("mother_handle"))
children = get_children(family)
f_name = person_name(father) if father else "?"
m_name = person_name(mother) if mother else "?"
marriage_date = ""
marriage_place = ""
for er in family.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", ""))
rel_type = family.get("type", {}).get("value", 0)
rel_str = "Marie" if rel_type == 0 else "Non marie" if rel_type == 3 else ""
title = f"{f_name} & {m_name}"
if label:
title = f"{label}: {title}"
self.sub_title(title)
if marriage_date:
txt = marriage_date
if marriage_place:
txt += f" - {marriage_place}"
self.kv("Mariage:", txt)
if rel_str:
self.kv("Statut:", rel_str)
if children:
self.ln(1)
self.set_font("Helvetica", "I", 9)
self.set_text_color(80, 80, 80)
self.cell(0, 5, "Enfants:")
self.ln(5)
for c in children:
c_name = person_name(c)
c_birth, c_death = get_person_events(c)
c_dates = []
if c_birth:
c_dates.append(f"n. {c_birth}")
if c_death:
c_dates.append(f"d. {c_death}")
c_info = f" {c_name}"
if c_dates:
c_info += f" ({', '.join(c_dates)})"
self.bullet(c_info)
self.ln(3)
# ── Build the report ─────────────────────────────────────────────────────────
pdf = GenealogyPDF()
pdf.alias_nb_pages()
pdf.set_auto_page_break(auto=True, margin=20)
pdf.add_page()
# ── Cover ────────────────────────────────────────────────────────────────────
pdf.ln(30)
pdf.set_font("Helvetica", "B", 24)
pdf.set_text_color(15, 23, 42)
pdf.cell(0, 12, "Rapport de Genealogie", align="C")
pdf.ln(10)
pdf.set_font("Helvetica", "", 14)
pdf.set_text_color(60, 60, 60)
pdf.cell(0, 8, "Familles Keller, Staerle & Alliees", align="C")
pdf.ln(8)
pdf.set_font("Helvetica", "", 10)
pdf.cell(0, 6, "Suisse (Schwytz) - Alsace (France) - Moselle", align="C")
pdf.ln(6)
pdf.cell(0, 6, f"Genere le {datetime.now().strftime('%d/%m/%Y a %H:%M')}", align="C")
pdf.ln(20)
# Stats box
pdf.set_fill_color(240, 244, 248)
pdf.set_draw_color(200, 210, 220)
pdf.rect(30, pdf.get_y(), 150, 35, "DF")
pdf.set_xy(35, pdf.get_y() + 3)
pdf.set_font("Helvetica", "B", 10)
pdf.set_text_color(15, 23, 42)
pdf.cell(0, 5, "Resume")
pdf.ln(6)
pdf.set_x(35)
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(50, 50, 50)
pdf.cell(0, 4.5, f"Personnes: {len(persons)} | Familles: {len(families)} | Lieux: {len(places)}")
pdf.ln(4.5)
pdf.set_x(35)
pdf.cell(0, 4.5, f"Evenements: {len([o for o in objects if o.get('_class') == 'Event'])} | Medias: {len(media)}")
pdf.ln(4.5)
pdf.set_x(35)
pdf.cell(0, 4.5, f"Generations: 7 (de 1760 a nos jours)")
pdf.ln(4.5)
pdf.set_x(35)
pdf.cell(0, 4.5, f"Origines: Altendorf (SZ), Alsace, Moselle, Vaud")
pdf.ln(15)
# ── Table of Contents ───────────────────────────────────────────────────────
pdf.add_page()
pdf.section_title("Table des matieres")
toc = [
"1. Arbre genealogique - Famille Keller",
"2. Arbre genealogique - Famille Staerle (branche alsacienne)",
"3. Branches alliees (Lemann, Kutzner, Hamm, Gangloff)",
"4. Fiches individuelles detaillees",
"5. Lieux",
"6. Medias et documents",
"7. Notes et sources",
"8. Statistiques",
]
for item in toc:
pdf.bullet(item)
# ═══════════════════════════════════════════════════════════════════════════
# 1. KELLER FAMILY TREE
# ═══════════════════════════════════════════════════════════════════════════
pdf.add_page()
pdf.section_title("1. Arbre genealogique - Famille Keller")
# Gen 1: Jakob Keller
pdf.sub_title("Generation 1 (ancetre)")
jakob = by_handle.get("a37f8646-d374-40da-863b-86febf23b165")
if jakob:
pdf.person_card(jakob)
# Family F0006: Jakob -> Silvio
fam_f6 = by_handle.get("103b3ad8ce8c43637db4d46f804c")
if fam_f6:
pdf.family_section(fam_f6, "Famille")
# Gen 2: Silvio + Margrit
pdf.sub_title("Generation 2")
silvio = by_handle.get("d1220b0f-9a04-4581-994a-6c4985f14c83")
margrit = by_handle.get("bad07459-e2d9-4413-85bd-5b8b6e8463d5")
if silvio:
pdf.person_card(silvio)
if margrit:
pdf.person_card(margrit)
fam_f1 = by_handle.get("103b34dfb7d317e6eacf93b9b322")
if fam_f1:
pdf.family_section(fam_f1, "Famille")
# Gen 3: Lucien + Sylvie
pdf.sub_title("Generation 3")
lucien = by_handle.get("4c572a11-13cd-46ee-9aee-39b904e86dcd")
sylvie = by_handle.get("14d0669e-3174-4a94-b5e5-23af79331f20")
if lucien:
pdf.person_card(lucien)
if sylvie:
pdf.person_card(sylvie)
fam_f0 = by_handle.get("103b34b3fc3a78115abdc520a0d7")
if fam_f0:
pdf.family_section(fam_f0, "Famille")
# Gen 4: Laurent + Lucie
pdf.sub_title("Generation 4")
laurent = by_handle.get("7c2b4663-ba21-49ce-af58-b268babe3e25")
if laurent:
pdf.person_card(laurent)
lucie = by_handle.get("7cb9d60e-8a61-47d5-a4bf-69d1a23a7068")
if lucie:
pdf.person_card(lucie)
# Gen 3 siblings: Tobias, Lea, Livia
pdf.sub_title("Generation 3 - Freres et soeurs de Lucien")
tobias = by_handle.get("1a501a5f-2721-491c-9bfe-7d3d1be88bdc")
lea = by_handle.get("cfbe8ee0-0c31-4f92-ada8-a9054514df0c")
livia = by_handle.get("b7c9c8c6-471d-481d-99c4-eb1753a0fb31")
for p in [tobias, lea, livia]:
if p:
pdf.person_card(p)
# ═══════════════════════════════════════════════════════════════════════════
# 2. STAERLE FAMILY TREE (Alsace branch)
# ═══════════════════════════════════════════════════════════════════════════
pdf.add_page()
pdf.section_title("2. Arbre genealogique - Famille Staerle (Alsace)")
# Gen 1 (1760): Johann Michael Staerle + Catharina Bernhard
pdf.sub_title("Generation 1 (v. 1760)")
jm1 = by_handle.get("1c80dd1f-77b4-4ab9-940c-e0776b6161ff")
cmb = by_handle.get("cc5dad4f-1963-4037-b7db-040680011dd9")
if jm1:
pdf.person_card(jm1)
if cmb:
pdf.person_card(cmb)
fam_f12 = by_handle.get("103b753f4b6772d2f9cd968bc788")
if fam_f12:
pdf.family_section(fam_f12, "Famille")
# Gen 2: Johann Michael Staerle + Christiana Metz
pdf.sub_title("Generation 2")
jm2 = by_handle.get("32c7f3f4-aa1c-4501-aa67-0d93ebcfbf68")
cm = by_handle.get("01f5daba-73b0-4ae9-87cb-80afc755aca7")
if jm2:
pdf.person_card(jm2)
if cm:
pdf.person_card(cm)
fam_f11 = by_handle.get("103b74f88457186cf98a9b6eca8a")
if fam_f11:
pdf.family_section(fam_f11, "Famille")
# Gen 3: Jean Adam Staerle + Madeleine Helf
pdf.sub_title("Generation 3")
ja = by_handle.get("30aeb3bd-dafd-4a4f-baf3-d75c1fd0d535")
mh = by_handle.get("ab0e498f-3e4f-41de-8766-0941d58614b9")
if ja:
pdf.person_card(ja)
if mh:
pdf.person_card(mh)
fam_f10 = by_handle.get("103b744d30a81442468b19dce1e6")
if fam_f10:
pdf.family_section(fam_f10, "Famille")
# Gen 4: Michel Staerle + Sophie Vogel
pdf.sub_title("Generation 4")
michel = by_handle.get("8d3313ce-d89c-473e-a7aa-4385d5bea769")
sophie = by_handle.get("2c548840-a9ef-4f2c-9195-7c9007b963a2")
if michel:
pdf.person_card(michel)
if sophie:
pdf.person_card(sophie)
fam_f9 = by_handle.get("103b730b5d1b5e3c15d9fa4d5111")
if fam_f9:
pdf.family_section(fam_f9, "Famille")
# Gen 5: Guillaume Staerle + Frida Bender
pdf.sub_title("Generation 5")
guillaume1 = by_handle.get("3c98357d-3645-4a74-ae8d-3fb91b005fe8")
frida = by_handle.get("ce24fa7a-b67d-47ba-ad46-3fc940be0c25")
if guillaume1:
pdf.person_card(guillaume1)
if frida:
pdf.person_card(frida)
fam_f3 = by_handle.get("103b39893eb03c1e4ed40f6f8d9b")
if fam_f3:
pdf.family_section(fam_f3, "Famille")
# Gen 6: Guillaume Staerle + Catherine Jund
pdf.sub_title("Generation 6")
guillaume2 = by_handle.get("bce9436e-8a1c-4eb1-b4c6-bfdcc7c6a935")
catherine = by_handle.get("88c262e3-9d6f-4f29-a368-03732e5d4b7b")
if guillaume2:
pdf.person_card(guillaume2)
if catherine:
pdf.person_card(catherine)
fam_f2 = by_handle.get("103b35aa6e2918ae0dc0317e052b")
if fam_f2:
pdf.family_section(fam_f2, "Famille")
# Gen 7: Sylvie, Annette, Corinne, Isabelle, Anne
pdf.sub_title("Generation 7 - Enfants Staerle")
staerle_children = [
"14d0669e-3174-4a94-b5e5-23af79331f20", # Sylvie
"e30f96b7-2860-4473-acd5-f5d9b5ae4f36", # Annette
"ccf375b4-c0ca-4bba-87e6-365c6b348bb5", # Corinne
"9428a2d4-6330-4a1c-835e-f92eacefb805", # Isabelle
"f6d9662e-02a4-4327-99b8-9b48a30f8bc9", # Anne
]
for h in staerle_children:
p = by_handle.get(h)
if p:
pdf.person_card(p)
# Christian Staerle + Yvette
pdf.sub_title("Generation 7 - Christian Staerle & Yvette")
christian = by_handle.get("33c893ff-60b3-4420-931a-bb3f28ce0562")
yvette = by_handle.get("66c36421-4e32-42e9-bc57-c47f6e6adfe4")
if christian:
pdf.person_card(christian)
if yvette:
pdf.person_card(yvette)
fam_f5 = by_handle.get("103b3ac22c3c4c35c31f77a70910")
if fam_f5:
pdf.family_section(fam_f5, "Famille")
# Gen 8: Chris, Jean-michel Staerle
pdf.sub_title("Generation 8 - Enfants Christian & Yvette")
chris = by_handle.get("4e908cd1-83e6-4aa5-a004-4d510144a528")
jm_staerle = by_handle.get("ed8e0c2d-49e2-43a7-9c3f-18bd72afbdc7")
for p in [chris, jm_staerle]:
if p:
pdf.person_card(p)
# ═══════════════════════════════════════════════════════════════════════════
# 3. ALLIED BRANCHES
# ═══════════════════════════════════════════════════════════════════════════
pdf.add_page()
pdf.section_title("3. Branches alliees")
# Annette Hamm + Daniel Gangloff
pdf.sub_title("Famille Hamm-Gangloff")
annette = by_handle.get("e30f96b7-2860-4473-acd5-f5d9b5ae4f36")
daniel = by_handle.get("f6a18cb6-d08c-44b1-b52f-53de36d24107")
if annette:
pdf.person_card(annette)
if daniel:
pdf.person_card(daniel)
fam_f4 = by_handle.get("103b3aa996d7492b0a90e358032a")
if fam_f4:
pdf.family_section(fam_f4, "Famille")
# Lea Keller + Sandro Lemann
pdf.sub_title("Famille Lemann")
lea = by_handle.get("cfbe8ee0-0c31-4f92-ada8-a9054514df0c")
sandro = by_handle.get("ef9b14d5-ab40-48fd-a110-eb4aac2a6af2")
if lea:
pdf.person_card(lea)
if sandro:
pdf.person_card(sandro)
fam_f7 = by_handle.get("103b3b4a63723ca20a2d8c369711")
if fam_f7:
pdf.family_section(fam_f7, "Famille")
# Livia Keller + Florian Kutzner
pdf.sub_title("Famille Kutzner")
livia = by_handle.get("b7c9c8c6-471d-481d-99c4-eb1753a0fb31")
florian = by_handle.get("b4984dbd-2f15-4a41-9e6f-900d0f67e507")
if livia:
pdf.person_card(livia)
if florian:
pdf.person_card(florian)
fam_f8 = by_handle.get("103b3b5df8a1443d8513129fe995")
if fam_f8:
pdf.family_section(fam_f8, "Famille")
# ═══════════════════════════════════════════════════════════════════════════
# 4. DETAILED PERSON CARDS
# ═══════════════════════════════════════════════════════════════════════════
pdf.add_page()
pdf.section_title("4. Fiches individuelles detaillees")
all_persons_sorted = sorted(persons, key=lambda p: p.get("gramps_id", ""))
for p in all_persons_sorted:
name = person_name(p)
pid = p.get("gramps_id", "")
birth, death = get_person_events(p)
gender_map = {0: "Femme", 1: "Homme", 2: "Autre"}
gender = gender_map.get(p.get("gender"), "?")
if pdf.get_y() > 230:
pdf.add_page()
pdf.sub_title(f"{name} ({pid})")
pdf.kv("Nom:", name)
pdf.kv("Genre:", gender)
if birth:
pdf.kv("Naissance:", birth)
if death:
pdf.kv("Deces:", death)
father, mother = get_parents(p)
if father or mother:
parents_str = []
if father:
parents_str.append(person_name(father))
if mother:
parents_str.append(person_name(mother))
pdf.kv("Parents:", " & ".join(parents_str))
own_fams = get_families(p)
if own_fams:
for f in own_fams:
spouse_handle = f.get("father_handle") if f.get("mother_handle") == p["handle"] else f.get("mother_handle")
spouse = by_handle.get(spouse_handle) if spouse_handle else None
children = get_children(f)
if spouse:
pdf.kv("Conjoint(e):", person_name(spouse))
if children:
pdf.kv("Enfants:", ", ".join(person_name(c) for c in children))
events = get_events_for_person(p)
for ev in events:
txt = ev["type"]
if ev["date"]:
txt += f" le {ev['date']}"
if ev["place"]:
place_name = get_place_name(ev["place"])
if place_name:
txt += f" - {place_name}"
if ev["desc"]:
txt += f" ({ev['desc']})"
pdf.kv(ev["type"] + ":", txt if txt != ev["type"] else "-")
p_notes = get_notes_for_person(p)
for n in p_notes:
pdf.kv("Note:", n.strip())
pdf.ln(3)
# ═══════════════════════════════════════════════════════════════════════════
# 5. PLACES
# ═══════════════════════════════════════════════════════════════════════════
pdf.add_page()
pdf.section_title("5. Lieux")
for pl in places:
name = pl.get("name", {}).get("value", "")
lat = pl.get("lat", "")
lon = pl.get("long", "")
ptype = pl.get("place_type", {}).get("string", "")
pid = pl.get("gramps_id", "")
if pdf.get_y() > 250:
pdf.add_page()
pdf.sub_title(f"{name} ({pid})")
if ptype:
pdf.kv("Type:", ptype)
if lat and lon:
pdf.kv("Coordonnees:", f"{lat}, {lon}")
for pr in pl.get("placeref_list", []):
parent = by_handle.get(pr.get("ref"))
if parent:
pname = parent.get("name", {}).get("value", "")
pdf.kv("Fait partie de:", pname)
pdf.ln(2)
# ═══════════════════════════════════════════════════════════════════════════
# 6. MEDIA
# ═══════════════════════════════════════════════════════════════════════════
pdf.add_page()
pdf.section_title("6. Medias et documents")
for m in media:
desc = m.get("desc", "")
mime = m.get("mime", "")
path = m.get("path", "")
mid = m.get("gramps_id", "")
date_str = format_date(m.get("date", {}))
pdf.sub_title(f"{desc} ({mid})")
pdf.kv("Type:", mime)
pdf.kv("Fichier:", path)
if date_str:
pdf.kv("Date:", date_str)
pdf.ln(2)
# ═══════════════════════════════════════════════════════════════════════════
# 7. NOTES
# ═══════════════════════════════════════════════════════════════════════════
pdf.add_page()
pdf.section_title("7. Notes et sources")
for n in notes:
text = n.get("text", {}).get("string", "")
ntype = n.get("type", {}).get("string", "")
nid = n.get("gramps_id", "")
if pdf.get_y() > 260:
pdf.add_page()
pdf.sub_title(f"Note {nid}")
if ntype:
pdf.kv("Type:", ntype)
pdf.body_text(text.strip())
pdf.ln(2)
# ═══════════════════════════════════════════════════════════════════════════
# 8. STATISTICS
# ═══════════════════════════════════════════════════════════════════════════
pdf.add_page()
pdf.section_title("8. Statistiques")
males = sum(1 for p in persons if p.get("gender") == 1)
females = sum(1 for p in persons if p.get("gender") == 0)
other = sum(1 for p in persons if p.get("gender") == 2)
pdf.sub_title("Repartition par genre")
pdf.kv("Hommes:", str(males))
pdf.kv("Femmes:", str(females))
pdf.kv("Autre/Inconnu:", str(other))
pdf.ln(3)
married = sum(1 for f in families if f.get("type", {}).get("value") == 0)
unmarried = sum(1 for f in families if f.get("type", {}).get("value") == 3)
pdf.sub_title("Familles")
pdf.kv("Mariees:", str(married))
pdf.kv("Non mariees:", str(unmarried))
pdf.ln(3)
pdf.sub_title("Evenements")
event_counts = defaultdict(int)
for ev in [o for o in objects if o.get("_class") == "Event"]:
et = event_type_str(ev.get("type", {}))
event_counts[et] += 1
for et, count in sorted(event_counts.items(), key=lambda x: -x[1]):
pdf.kv(et + ":", str(count))
pdf.ln(3)
pdf.sub_title("Lieux")
pdf.kv("Total lieux:", str(len(places)))
for pl in places:
name = pl.get("name", {}).get("value", "")
ptype = pl.get("place_type", {}).get("string", "")
pdf.bullet(f"{name} ({ptype})" if ptype else name)
# ── Save ────────────────────────────────────────────────────────────────────
OUTPUT = "/root/workspace/rapport-genealogie-keller-staerle.pdf"
pdf.output(OUTPUT)
print(f"PDF genere: {OUTPUT}")
print(f"Pages: {pdf.page_no()}")
print(f"Personnes: {len(persons)}, Familles: {len(families)}, Lieux: {len(places)}, Evenements: {len([o for o in objects if o.get('_class') == 'Event'])}")
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
OAuth login for Google Workspace MCP servers (Sheets, Drive) in non-TTY.
Usage:
1. systemctl --user stop hermes-gateway.service
2. python3 /root/workspace/google-workspace-mcp-login.py
3. For each server, open the URL in your browser, authorize, paste callback URL
4. systemctl --user start hermes-gateway.service
"""
import sys
import os
import json
import hashlib
import base64
import secrets
import string
import asyncio
from urllib.parse import urlencode, urlparse, parse_qs
sys.path.insert(0, "/usr/local/lib/hermes-agent")
sys.path.insert(0, "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages")
from hermes_cli.config import load_config
from tools.mcp_oauth import HermesTokenStorage, _configure_callback_port
from tools.mcp_oauth_manager import get_manager
import httpx
# ── Google OAuth settings ──────────────────────────────────────────────
CLIENT_ID = "1090672727072-4buk3522j52pqut02m22rcnto31938jl.apps.googleusercontent.com"
CLIENT_SECRET = "CLIENT_SECRET_REMOVED"
AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
TOKEN_URI = "https://oauth2.googleapis.com/token"
# Servers to configure
SERVERS = [
{
"name": "google-sheets",
"scopes": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive.file",
],
},
{
"name": "google-drive",
"scopes": [
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/drive.file",
],
},
]
# ────────────────────────────────────────────────────────────────────────
async def exchange_code(
server_name: str,
code: str,
code_verifier: str,
redirect_uri: str,
) -> bool:
"""Exchange authorization code for tokens and save them."""
from mcp.shared.auth import OAuthToken, OAuthClientInformationFull
storage = HermesTokenStorage(server_name)
async with httpx.AsyncClient(timeout=15.0) as client:
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code_verifier": code_verifier,
}
resp = await client.post(TOKEN_URI, data=data, headers={"Accept": "application/json"})
if resp.status_code != 200:
print(f" ✗ Token exchange failed: HTTP {resp.status_code}")
print(f" Response: {resp.text[:500]}")
return False
token_data = resp.json()
print(f" ✓ Token received!")
token = OAuthToken(
access_token=token_data.get("access_token", ""),
token_type=token_data.get("token_type", "Bearer"),
expires_in=token_data.get("expires_in"),
refresh_token=token_data.get("refresh_token"),
scope=token_data.get("scope"),
)
await storage.set_tokens(token)
# Write complete client info
client_info = OAuthClientInformationFull(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
redirect_uris=[redirect_uri],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="client_secret_post",
)
await storage.set_client_info(client_info)
print(f" ✓ Tokens saved to ~/.hermes/mcp-tokens/{server_name}.json")
return True
def main():
for server in SERVERS:
name = server["name"]
scopes = server["scopes"]
print(f"\n{'=' * 70}")
print(f" Server: {name}")
print(f" Scopes: {' '.join(scopes)}")
print(f"{'=' * 70}")
# Clear old tokens
storage = HermesTokenStorage(name)
for p in [storage._tokens_path(), storage._client_info_path(), storage._meta_path()]:
if p.exists():
p.unlink()
get_manager().remove(name)
# Configure callback port
cfg = {}
_configure_callback_port(cfg, storage)
resolved_port = cfg.get("_resolved_port", 38255)
redirect_uri = f"http://127.0.0.1:{resolved_port}/callback"
# Generate PKCE
code_verifier = "".join(
secrets.choice(string.ascii_letters + string.digits + "-._~")
for _ in range(128)
)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
state = secrets.token_urlsafe(32)
# Build auth URL
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": " ".join(scopes),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"access_type": "offline",
"prompt": "consent",
}
auth_url = f"{AUTH_URI}?{urlencode(params)}"
print(f"\n OPEN THIS URL IN YOUR BROWSER:\n")
print(f" {auth_url}")
print(f"\n After authorizing, paste the FULL callback URL and press Enter:")
print(f" (or type 'skip' to skip this server)")
# Read callback URL from stdin
try:
callback_url = sys.stdin.readline().strip()
except (EOFError, KeyboardInterrupt):
print("\n Skipping.")
continue
if not callback_url or callback_url.lower() in ("skip", "s", "q", "quit"):
print(" Skipped.")
continue
# Parse callback
parsed = urlparse(callback_url)
cb_params = parse_qs(parsed.query)
received_code = cb_params.get("code", [None])[0]
received_state = cb_params.get("state", [None])[0]
if not received_code:
print(" ✗ No 'code' parameter in callback URL")
continue
if received_state != state:
print(f" ✗ State mismatch! Expected {state}, got {received_state}")
continue
print(f" ✓ Callback verified")
# Exchange code
success = asyncio.run(
exchange_code(name, received_code, code_verifier, redirect_uri)
)
if success:
print(f"'{name}' authenticated successfully!")
else:
print(f"'{name}' authentication failed")
print(f"\n{'=' * 70}")
print(f" Done. Restart the gateway:")
print(f" systemctl --user restart hermes-gateway.service")
print(f"{'=' * 70}")
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Step 1: Register OAuth client and generate authorization URL."""
import sys, os, json, hashlib, base64, secrets, string, asyncio
from urllib.parse import urlencode, urlparse
sys.path.insert(0, "/usr/local/lib/hermes-agent")
SERVER_NAME = sys.argv[1] if len(sys.argv) > 1 else "cloudflare"
from hermes_cli.config import load_config
config = load_config()
servers = config.get("mcp_servers", {})
server_config = servers.get(SERVER_NAME, {})
url = server_config.get("url", "")
oauth_config = server_config.get("oauth", {})
from tools.mcp_oauth import HermesTokenStorage, _configure_callback_port
from tools.mcp_oauth_manager import get_manager
# Clear old tokens
storage = HermesTokenStorage(SERVER_NAME)
for p in [storage._tokens_path(), storage._client_info_path(), storage._meta_path()]:
if p.exists():
p.unlink()
print(f" ✓ Removed {p.name}")
get_manager().remove(SERVER_NAME)
cfg = dict(oauth_config or {})
_configure_callback_port(cfg, storage)
resolved_port = cfg.get("_resolved_port", 38255)
redirect_uri = f"http://127.0.0.1:{resolved_port}/callback"
# Generate PKCE
code_verifier = "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128))
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
state = secrets.token_urlsafe(32)
# Register client at the correct OAuth domain
parsed_url = urlparse(url)
oauth_domain = f"{parsed_url.scheme}://{parsed_url.netloc}"
import httpx
async def register():
async with httpx.AsyncClient(timeout=15.0) as client:
reg_payload = {
"client_name": "Hermes Agent",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
}
resp = await client.post(
f"{oauth_domain}/register",
json=reg_payload,
headers={"Accept": "application/json"}
)
if resp.status_code not in (200, 201):
print(f" ✗ Registration failed: HTTP {resp.status_code}")
print(f" Response: {resp.text[:500]}")
return None, None
reg_data = resp.json()
client_id = reg_data.get("client_id")
# Discover token endpoint
token_endpoint = None
for disc_url in [
f"{oauth_domain}/.well-known/oauth-authorization-server",
f"{oauth_domain}/.well-known/openid-configuration",
]:
try:
resp2 = await client.get(disc_url, headers={"Accept": "application/json"})
if resp2.status_code == 200:
data = resp2.json()
token_endpoint = data.get("token_endpoint")
if token_endpoint:
break
except:
pass
if not token_endpoint:
token_endpoint = f"{oauth_domain}/token"
print(f" ✓ Registered client: {client_id}")
print(f" Token endpoint: {token_endpoint}")
return client_id, token_endpoint
client_id, token_endpoint = asyncio.run(register())
if not client_id:
sys.exit(1)
# Save state for step 2
state_data = {
"server_name": SERVER_NAME,
"client_id": client_id,
"token_endpoint": token_endpoint,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
"state": state,
"url": url,
}
with open(f"/tmp/mcp_oauth_{SERVER_NAME}.json", "w") as f:
json.dump(state_data, f)
# Build auth URL using discovered metadata
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"resource": url,
}
# Discover the correct authorization endpoint
import httpx
async def discover_auth_endpoint():
async with httpx.AsyncClient(timeout=10.0) as client:
for disc_url in [
f"{oauth_domain}/.well-known/oauth-authorization-server",
]:
try:
resp = await client.get(disc_url, headers={"Accept": "application/json"})
if resp.status_code == 200:
data = resp.json()
auth_ep = data.get("authorization_endpoint")
if auth_ep:
return auth_ep
except:
pass
return f"{oauth_domain}/authorize"
auth_endpoint = asyncio.run(discover_auth_endpoint())
print(f" Auth endpoint: {auth_endpoint}")
auth_url = f"{auth_endpoint}?{urlencode(params)}"
print(f"\n {'='*70}")
print(f" Server: {SERVER_NAME}")
print(f" URL: {url}")
print(f" OAuth domain: {oauth_domain}")
print(f" {'='*70}")
print(f"\n OPEN THIS URL IN YOUR BROWSER:\n")
print(f" {auth_url}")
print(f"\n {'='*70}")
print(f" Then run: python3 /root/workspace/oauth-step2.py {SERVER_NAME} <callback_url>")
print(f" {'='*70}")
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Step 2: Exchange authorization code for tokens using saved state."""
import sys, os, json, asyncio
sys.path.insert(0, "/usr/local/lib/hermes-agent")
SERVER_NAME = sys.argv[1] if len(sys.argv) > 1 else "cloudflare"
CALLBACK_URL = sys.argv[2] if len(sys.argv) > 2 else ""
if not CALLBACK_URL:
print("Usage: python3 oauth-step2.py <server_name> <callback_url>")
sys.exit(1)
# Load state from step 1
state_path = f"/tmp/mcp_oauth_{SERVER_NAME}.json"
if not os.path.exists(state_path):
print(f" ✗ State file not found. Run oauth-step1.py first.")
sys.exit(1)
with open(state_path) as f:
state_data = json.load(f)
from urllib.parse import urlparse, parse_qs
parsed = urlparse(CALLBACK_URL)
cb_params = parse_qs(parsed.query)
received_code = cb_params.get("code", [None])[0]
received_state = cb_params.get("state", [None])[0]
if not received_code:
print(" ✗ No 'code' parameter in callback URL")
sys.exit(1)
if received_state != state_data["state"]:
print(f" ✗ State mismatch! Expected {state_data['state']}, got {received_state}")
sys.exit(1)
print(f" ✓ Callback verified (state matches)")
print(f" Code: {received_code[:40]}...")
from tools.mcp_oauth import HermesTokenStorage
storage = HermesTokenStorage(SERVER_NAME)
client_id = state_data["client_id"]
token_endpoint = state_data["token_endpoint"]
redirect_uri = state_data["redirect_uri"]
code_verifier = state_data["code_verifier"]
import httpx
async def exchange():
async with httpx.AsyncClient(timeout=15.0) as client:
data = {
"grant_type": "authorization_code",
"code": received_code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"code_verifier": code_verifier,
}
resp = await client.post(token_endpoint, data=data, headers={"Accept": "application/json"})
if resp.status_code != 200:
print(f" ✗ Token exchange failed: HTTP {resp.status_code}")
print(f" Response: {resp.text[:500]}")
return False
token_data = resp.json()
print(f" ✓ Token received!")
from mcp.shared.auth import OAuthToken, OAuthClientInformationFull
token = OAuthToken(
access_token=token_data.get("access_token", ""),
token_type=token_data.get("token_type", "Bearer"),
expires_in=token_data.get("expires_in"),
refresh_token=token_data.get("refresh_token"),
scope=token_data.get("scope"),
)
await storage.set_tokens(token)
client_info = OAuthClientInformationFull(
client_id=client_id,
redirect_uris=[redirect_uri],
)
await storage.set_client_info(client_info)
print(f" ✓ Tokens saved to ~/.hermes/mcp-tokens/{SERVER_NAME}.json")
return True
success = asyncio.run(exchange())
if success:
print(f"\n'{SERVER_NAME}' authenticated successfully!")
# Clean up state file
os.remove(state_path)
else:
print(f"\n'{SERVER_NAME}' authentication failed")
sys.exit(1)
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""Plan d'étage — murs en traits épais, ouvertures nettes."""
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch, Arc, Circle, Rectangle
import numpy as np
fig, ax = plt.subplots(1, 1, figsize=(26, 20))
ax.set_aspect('equal')
ax.axis('off')
# ── Palette ────────────────────────────────────────────────────
MUR = '#3a3a5c'
MUR_EXT = '#1a1a2e'
PIECE = {
'salon': '#fff8e7', 'cuisine': '#eef5e8', 'chambre': '#eaf0f8',
'sdb': '#e0ecf2', 'wc': '#f5ecec', 'bureau': '#f0f0f5',
'it': '#e8e8f0', 'couloir': '#f5f3ee',
}
MEUBLE = '#8b7355'
MEUBLE_IT = '#4a4a6a'
PORTE_C = '#8b4513'
FENETRE_C = '#7ec8e3'
COTE = '#888888'
TEXTE = '#1a1a2e'
LW = 8.0 # linewidth pour murs (épais, bien visible)
LW_EXT = 10.0 # murs extérieurs plus épais
# ═══════════════════════════════════════════════════════════════
# LAYOUT
# ═══════════════════════════════════════════════════════════════
# Bas (y=0..8) : Salon(0-8) | Cuisine(8-13) | SalleIT(13-15.5) | Ch1(15.5-20)
# Couloir (8..9.5)
# Haut (9.5..15) : SDB(0-4) | WC(4-6) | Ch2(6-11) | Ch3(11-16) | Bureau(16-20)
# ── Fond des pièces ────────────────────────────────────────────
for (x, y, w, h, c) in [
(0, 0, 8, 8, PIECE['salon']), (8, 0, 5, 8, PIECE['cuisine']),
(13, 0, 2.5, 8, PIECE['it']), (15.5, 0, 4.5, 8, PIECE['chambre']),
(0, 8, 20, 1.5, PIECE['couloir']),
(0, 9.5, 4, 5.5, PIECE['sdb']), (4, 9.5, 2, 5.5, PIECE['wc']),
(6, 9.5, 5, 5.5, PIECE['chambre']), (11, 9.5, 5, 5.5, PIECE['chambre']),
(16, 9.5, 4, 5.5, PIECE['bureau']),
]:
ax.add_patch(Rectangle((x, y), w, h, facecolor=c, edgecolor='none', zorder=1))
# ── Helpers ────────────────────────────────────────────────────
def mur_ligne(x1, y1, x2, y2, lw=LW):
"""Mur en trait épais avec bouts plats (solid_capstyle='butt')."""
ax.plot([x1, x2], [y1, y2], color=MUR, linewidth=lw,
solid_capstyle='butt', zorder=3)
def mur_ext_ligne(x1, y1, x2, y2):
"""Mur extérieur en trait très épais."""
ax.plot([x1, x2], [y1, y2], color=MUR_EXT, linewidth=LW_EXT,
solid_capstyle='butt', zorder=3)
def porte_h(x, y, largeur=0.9):
"""Porte dans un mur horizontal — battant vers le haut."""
ax.plot([x, x], [y, y+largeur], color=PORTE_C, linewidth=2.5, zorder=5)
arc = Arc((x, y), largeur*2, largeur*2, angle=90, theta1=0, theta2=90,
color=PORTE_C, linewidth=1.2, linestyle='--', zorder=4)
ax.add_patch(arc)
ax.add_patch(Circle((x, y), 0.06, facecolor=PORTE_C, edgecolor='none', zorder=5))
def porte_v(x, y, largeur=0.9):
"""Porte dans un mur vertical — battant vers la droite."""
ax.plot([x, x+largeur], [y, y], color=PORTE_C, linewidth=2.5, zorder=5)
arc = Arc((x, y), largeur*2, largeur*2, angle=0, theta1=0, theta2=90,
color=PORTE_C, linewidth=1.2, linestyle='--', zorder=4)
ax.add_patch(arc)
ax.add_patch(Circle((x, y), 0.06, facecolor=PORTE_C, edgecolor='none', zorder=5))
def fenetre_h(x1, x2, y):
"""Fenêtre dans un mur horizontal extérieur."""
# Petits traits de mur aux extrémités
for x in [x1, x2]:
ax.plot([x, x], [y-0.15, y+0.15], color=MUR_EXT, linewidth=2, zorder=4)
ax.plot([x1, x2], [y, y], color=FENETRE_C, linewidth=4, solid_capstyle='butt', zorder=4)
def fenetre_v(y1, y2, x):
"""Fenêtre dans un mur vertical extérieur."""
for y in [y1, y2]:
ax.plot([x-0.15, x+0.15], [y, y], color=MUR_EXT, linewidth=2, zorder=4)
ax.plot([x, x], [y1, y2], color=FENETRE_C, linewidth=4, solid_capstyle='butt', zorder=4)
def meuble(x, y, w, h, label='', couleur=MEUBLE, txt_color='white'):
ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.04",
facecolor=couleur, edgecolor='#3d2e1a', linewidth=0.8, alpha=0.75, zorder=6))
if label:
ax.text(x+w/2, y+h/2, label, ha='center', va='center',
fontsize=6.5, color=txt_color, fontweight='bold', zorder=7)
def label_piece(x, y, nom, surface, sz_nom=11, sz_surf=8):
ax.text(x, y+0.35, nom, ha='center', va='center', fontsize=sz_nom,
color=TEXTE, fontweight='bold', zorder=8)
ax.text(x, y-0.35, surface, ha='center', va='center', fontsize=sz_surf, color=COTE, zorder=8)
def cote(x1, y1, x2, y2, label, decal=0.5):
ax.plot([x1, x2], [y1, y2], color=COTE, linewidth=0.7, zorder=2)
dx, dy = x2-x1, y2-y1
L = np.sqrt(dx*dx+dy*dy)
if L > 0:
nx, ny = -dy/L*0.12, dx/L*0.12
ax.plot([x1+nx, x1-nx], [y1+ny, y1-ny], color=COTE, linewidth=0.7, zorder=2)
ax.plot([x2+nx, x2-nx], [y2+ny, y2-ny], color=COTE, linewidth=0.7, zorder=2)
mx, my = (x1+x2)/2, (y1+y2)/2
px, py = (-dy/L*decal, dx/L*decal) if L > 0 else (0, decal)
ax.text(mx+px, my+py, label, ha='center', va='center', fontsize=7.5, color=COTE, zorder=2)
# ═══════════════════════════════════════════════════════════════
# MURS EXTÉRIEURS (traits épais, segments avec gaps)
# ═══════════════════════════════════════════════════════════════
# Bas (y=0)
mur_ext_ligne(0, 0, 2, 0) # coin → fenêtre salon
mur_ext_ligne(6, 0, 9.1, 0) # fenêtre salon → porte entrée
mur_ext_ligne(10, 0, 13, 0) # porte entrée → fenêtre cuisine
mur_ext_ligne(13, 0, 14, 0) # → fenêtre IT
mur_ext_ligne(15, 0, 17, 0) # fenêtre IT → fenêtre ch1
mur_ext_ligne(19, 0, 20, 0) # fenêtre ch1 → coin
# Droite (x=20)
mur_ext_ligne(20, 0, 20, 2)
mur_ext_ligne(20, 6, 20, 11)
mur_ext_ligne(20, 13.5, 20, 15)
# Haut (y=15)
mur_ext_ligne(0, 15, 5, 15)
mur_ext_ligne(5.5, 15, 8, 15)
mur_ext_ligne(10, 15, 13, 15)
mur_ext_ligne(15, 15, 20, 15)
# Gauche (x=0)
mur_ext_ligne(0, 0, 0, 2)
mur_ext_ligne(0, 5, 0, 11)
mur_ext_ligne(0, 13.5, 0, 15)
# ═══════════════════════════════════════════════════════════════
# MURS INTÉRIEURS (traits épais, segments avec gaps pour portes)
# ═══════════════════════════════════════════════════════════════
# Salon | Cuisine (x=8)
mur_ligne(8, 0, 8, 8)
# Cuisine | Salle IT (x=13)
mur_ligne(13, 0, 13, 8)
# Salle IT | Chambre 1 (x=15.5)
mur_ligne(15.5, 0, 15.5, 8)
# Couloir BAS (y=8) — segments avec gaps pour portes
mur_ligne(0, 8, 3.05, 8) # gauche → porte salon
mur_ligne(3.95, 8, 9.55, 8) # porte salon → porte cuisine
mur_ligne(10.45, 8, 13.55, 8) # porte cuisine → porte IT
mur_ligne(14.45, 8, 17.05, 8) # porte IT → porte ch1
mur_ligne(17.95, 8, 20, 8) # porte ch1 → droite
# Couloir HAUT (y=9.5) — segments avec gaps pour portes
mur_ligne(0, 9.5, 1.55, 9.5) # gauche → porte SDB
mur_ligne(2.45, 9.5, 4.55, 9.5) # porte SDB → porte WC
mur_ligne(5.45, 9.5, 8.05, 9.5) # porte WC → porte ch2
mur_ligne(8.95, 9.5, 13.05, 9.5) # porte ch2 → porte ch3
mur_ligne(13.95, 9.5, 17.55, 9.5) # porte ch3 → porte bureau
mur_ligne(18.45, 9.5, 20, 9.5) # porte bureau → droite
# Murs verticaux partie haute
mur_ligne(4, 9.5, 4, 15) # SDB | WC
mur_ligne(6, 9.5, 6, 15) # WC | Ch2
mur_ligne(11, 9.5, 11, 15) # Ch2 | Ch3
mur_ligne(16, 9.5, 16, 15) # Ch3 | Bureau
# ═══════════════════════════════════════════════════════════════
# PORTES
# ═══════════════════════════════════════════════════════════════
# Entrée (mur du bas)
porte_v(9.55, 0)
# Couloir bas → pièces
porte_h(3.5, 8) # salon
porte_h(10, 8) # cuisine
porte_h(14, 8) # salle IT
porte_h(17.5, 8) # chambre 1
# Couloir haut → pièces
porte_h(2, 9.5) # SDB
porte_h(5, 9.5) # WC
porte_h(8.5, 9.5) # chambre 2
porte_h(13.5, 9.5) # chambre 3
porte_h(18, 9.5) # bureau
# ═══════════════════════════════════════════════════════════════
# FENÊTRES
# ═══════════════════════════════════════════════════════════════
fenetre_h(2, 6, 0) # salon — baie vitrée
fenetre_v(2, 5, 0) # salon — côté gauche
fenetre_h(10, 13, 0) # cuisine
fenetre_h(14, 15, 0) # salle IT
fenetre_h(17, 19, 0) # chambre 1
fenetre_v(2, 6, 20) # chambre 1 — côté droit
fenetre_v(11, 13.5, 0) # SDB
fenetre_h(5, 5.5, 15) # WC — haut
fenetre_h(8, 10, 15) # chambre 2 — haut
fenetre_h(13, 15, 15) # chambre 3 — haut
fenetre_v(11, 13.5, 20) # bureau — côté droit
# ═══════════════════════════════════════════════════════════════
# MEUBLES
# ═══════════════════════════════════════════════════════════════
# Salon
meuble(1, 1.5, 3, 1, 'Canapé d\'angle')
meuble(1.5, 3.2, 1.2, 0.6, 'Table basse')
meuble(5.5, 1.5, 1.8, 0.7, 'Meuble TV')
meuble(1, 5, 1.5, 0.6, 'Bibliothèque')
meuble(3, 5, 1.5, 0.6, 'Bibliothèque')
# Cuisine
meuble(9, 1.5, 2.5, 0.6, 'Plan de travail')
meuble(9, 2.5, 1, 0.6, 'Évier')
meuble(10.5, 2.5, 1, 0.6, 'Plaques')
meuble(9, 3.8, 2.5, 1, 'Îlot central')
meuble(12, 1.5, 0.8, 0.6, 'Frigo')
meuble(9, 5.5, 1.5, 0.6, 'Cellier')
# Salle IT
meuble(13.5, 1, 1.5, 0.6, 'Rack 42U', couleur=MEUBLE_IT)
meuble(13.5, 2.2, 1.5, 0.6, 'Rack 24U', couleur=MEUBLE_IT)
meuble(13.5, 3.5, 1, 0.6, 'Onduleur', couleur=MEUBLE_IT)
meuble(14.5, 3.5, 0.8, 0.6, 'NAS', couleur=MEUBLE_IT)
meuble(13.5, 5, 1.2, 0.5, 'Bureau IT', couleur=MEUBLE_IT)
# Chambre 1
meuble(16.5, 1.5, 1.6, 2, 'Lit 160')
meuble(18.5, 1.5, 0.5, 0.5, '')
meuble(19.5, 1.5, 0.5, 0.5, '')
meuble(16.5, 5, 1.2, 0.5, 'Commode')
meuble(18.5, 5, 1, 0.5, 'Bureau')
# Salle de bain
meuble(0.5, 10.5, 1.5, 0.8, 'Douche ital.')
meuble(2.5, 10.5, 1, 0.6, 'Lavabo')
meuble(0.5, 12, 1.5, 0.7, 'Baignoire')
meuble(2.5, 12.5, 0.8, 0.6, 'WC')
# WC
meuble(4.5, 10.5, 0.8, 0.6, 'WC')
meuble(4.5, 12, 0.6, 0.5, 'Lavabo')
# Chambre 2
meuble(7, 10.5, 1.4, 1.9, 'Lit 140')
meuble(8.5, 10.5, 0.5, 0.5, '')
meuble(7, 13, 1.2, 0.5, 'Commode')
meuble(9, 13, 1, 0.5, 'Bureau')
# Chambre 3
meuble(12, 10.5, 1.6, 2, 'Lit 160')
meuble(14, 10.5, 0.5, 0.5, '')
meuble(15, 10.5, 0.5, 0.5, '')
meuble(12, 13.5, 1.2, 0.5, 'Armoire')
# Bureau
meuble(17, 10.5, 1.8, 0.7, 'Bureau')
meuble(17, 12, 1, 0.5, 'Étagère')
meuble(18.5, 12, 0.8, 0.5, 'Imprimante')
# ═══════════════════════════════════════════════════════════════
# ÉTIQUETTES
# ═══════════════════════════════════════════════════════════════
label_piece(4, 4, 'SALON / SÉJOUR', '32 m²')
label_piece(10.5, 4, 'CUISINE', '20 m²')
label_piece(14.25,4, 'SALLE IT', '10 m²', sz_nom=10)
label_piece(17.75,4, 'CHAMBRE 1', '18 m²')
label_piece(2, 12.5, 'SALLE DE BAIN', '11 m²', sz_nom=9)
label_piece(5, 12.5, 'WC', '5.5 m²', sz_nom=9)
label_piece(8.5, 12.5, 'CHAMBRE 2', '13.5 m²')
label_piece(13.5, 12.5, 'CHAMBRE 3', '13.5 m²')
label_piece(18, 12.5, 'BUREAU', '11 m²')
# Entrée
ax.text(9.55, -0.5, 'ENTRÉE', ha='center', fontsize=10, fontweight='bold', color=TEXTE, zorder=8)
ax.plot(9.55, -0.25, marker='v', color=PORTE_C, markersize=8, zorder=5)
# ═══════════════════════════════════════════════════════════════
# COTES
# ═══════════════════════════════════════════════════════════════
cote(0, -1.2, 20, -1.2, '20.00 m')
cote(-1.2, 0, -1.2, 15, '15.00 m')
cote(0, -0.7, 8, -0.7, '8.00 m')
cote(8, -0.7, 13, -0.7, '5.00 m')
cote(13, -0.7, 15.5, -0.7, '2.50 m')
cote(15.5, -0.7, 20, -0.7, '4.50 m')
# ═══════════════════════════════════════════════════════════════
# LÉGENDE
# ═══════════════════════════════════════════════════════════════
lx, ly = 21.5, 14
ax.text(lx, ly, 'LÉGENDE', fontsize=10, fontweight='bold', color=TEXTE, zorder=9)
items = [
(MUR_EXT, 'Mur extérieur'),
(MUR, 'Mur intérieur'),
(FENETRE_C, 'Fenêtre'),
(PORTE_C, 'Porte'),
(MEUBLE_IT, 'Équipement IT'),
]
for i, (c, lbl) in enumerate(items):
y = ly - 0.7 - i*0.6
if lbl == 'Fenêtre':
ax.plot([lx, lx+1.5], [y, y], color=c, linewidth=4, zorder=9)
elif lbl == 'Porte':
ax.plot([lx, lx+0.8], [y, y], color=c, linewidth=2.5, zorder=9)
ax.add_patch(Circle((lx, y), 0.06, facecolor=c, edgecolor='none', zorder=9))
elif lbl == 'Mur extérieur':
ax.plot([lx, lx+1.5], [y, y], color=c, linewidth=LW_EXT, solid_capstyle='butt', zorder=9)
elif lbl == 'Mur intérieur':
ax.plot([lx, lx+1.5], [y, y], color=c, linewidth=LW, solid_capstyle='butt', zorder=9)
else:
ax.add_patch(Rectangle((lx, y-0.15), 1.5, 0.3, facecolor=c, edgecolor='none', zorder=9))
ax.text(lx+1.8, y, lbl, fontsize=7.5, color=TEXTE, va='center', zorder=9)
# ═══════════════════════════════════════════════════════════════
# TITRE
# ═══════════════════════════════════════════════════════════════
ax.text(10, 16.2, 'PLAN D\'ÉTAGE — MAISON FAMILIALE AVEC SALLE IT', ha='center',
fontsize=16, fontweight='bold', color=TEXTE, zorder=9)
ax.text(10, 15.7, 'Rez-de-chaussée | Surface totale : ~150 m² | Échelle indicative',
ha='center', fontsize=9, color=COTE, zorder=9)
ax.set_facecolor('#f0ede5')
fig.patch.set_facecolor('#e8e4dc')
ax.set_xlim(-2, 24)
ax.set_ylim(-2, 17.5)
output = '/root/workspace/plan_grande_maison.pdf'
plt.savefig(output, format='pdf', dpi=200, bbox_inches='tight',
facecolor=fig.get_facecolor(), edgecolor='none')
plt.close()
print(f"✅ PDF : {output}")
+533
View File
@@ -0,0 +1,533 @@
#!/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}")