Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67ec4375b3 | ||
|
|
6b0f52b577 |
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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")
|
||||
@@ -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'])}")
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/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 ──────────────────────────────────────────────
|
||||
# WARNING: Do NOT hardcode secrets. Use env vars or Hermes config.
|
||||
CLIENT_ID = os.environ.get("GOOGLE_OAUTH_CLIENT_ID",
|
||||
"1090672727072-4buk3522j52pqut02m22rcnto31938jl.apps.googleusercontent.com")
|
||||
CLIENT_SECRET = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
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()
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user