Compare commits
2
Commits
master
...
67ec4375b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67ec4375b3 | ||
|
|
6b0f52b577 |
@@ -16,9 +16,17 @@ homelab-scripts/
|
|||||||
├── maintenance/ # Tâches de fond
|
├── maintenance/ # Tâches de fond
|
||||||
│ ├── archive-old-sessions.sh # Archive sessions Hermes (3h)
|
│ ├── archive-old-sessions.sh # Archive sessions Hermes (3h)
|
||||||
│ └── check-obsolete.sh # Rapport éléments obsolètes (lundi)
|
│ └── check-obsolete.sh # Rapport éléments obsolètes (lundi)
|
||||||
└── web/ # Surveillance sites
|
├── web/ # Surveillance sites
|
||||||
├── site-test.py # Audit sécurité/UX laurentkeller.org
|
│ ├── site-test.py # Audit sécurité/UX laurentkeller.org
|
||||||
└── suivi-quotidien.py # Suivi quotidien Mon-site-web-photos
|
│ └── 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
|
## 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