diff --git a/README.md b/README.md index e00ebcf..3cf6ddb 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,17 @@ homelab-scripts/ ├── maintenance/ # Tâches de fond │ ├── archive-old-sessions.sh # Archive sessions Hermes (3h) │ └── check-obsolete.sh # Rapport éléments obsolètes (lundi) -└── web/ # Surveillance sites - ├── site-test.py # Audit sécurité/UX laurentkeller.org - └── suivi-quotidien.py # Suivi quotidien Mon-site-web-photos +├── web/ # Surveillance sites +│ ├── site-test.py # Audit sécurité/UX laurentkeller.org +│ └── suivi-quotidien.py # Suivi quotidien Mon-site-web-photos +└── one-shot/ # Scripts ponctuels (réutilisables) + ├── plan_maison.py # Plan d'étage matplotlib + ├── generate-honcho-pdf.py # PDF architecture Honcho + ├── generate_gramps_report.py # Rapport généalogie PDF + ├── google-workspace-mcp-login.py # OAuth Google Workspace + ├── oauth-step1.py / oauth-step2.py # Flow OAuth + ├── populate_gramps_sheet.py # Gramps → Google Sheet + └── fix_maxbuffer.py # Fix maxBuffer wiremcp-sse ``` ## Déploiement diff --git a/one-shot/fix_maxbuffer.py b/one-shot/fix_maxbuffer.py new file mode 100644 index 0000000..521a681 --- /dev/null +++ b/one-shot/fix_maxbuffer.py @@ -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') diff --git a/one-shot/generate-honcho-pdf.py b/one-shot/generate-honcho-pdf.py new file mode 100644 index 0000000..2b8cf01 --- /dev/null +++ b/one-shot/generate-honcho-pdf.py @@ -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 = [ + "Mémoire cross-session — l'agent se souvient de qui vous êtes entre deux conversations", + "Représentation utilisateur — Honcho analyse les conversations et extrait des conclusions (préférences, patterns, style de communication)", + "Dialectic engine — un moteur de raisonnement en arrière-plan qui synthétise la mémoire", + "Recherche sémantique — retrouver des informations par similarité de sens, pas par mot-clé", + "Multi-peers — 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. Store — les conversations sont stockées dans PostgreSQL avec embeddings vectoriels
" + "2. Reason — le Deriver (worker background) analyse les messages et met à jour la représentation
" + "3. Query — Hermes interroge Honcho via 5 outils pour récupérer le contexte
" + "4. Inject — 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
" + "Honcho Deriver → Ollama (192.168.30.10:11434) pour LLM + embeddings
" + "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
" + "Healthcheck : GET /health → {\"status\":\"ok\"}
" + "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
" + "Port interne : 5434 (évite conflit avec le PostgreSQL existant sur 5432)
" + "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
" + "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 = [ + "nemotron-mini:4b (Q4_K_M, 2.7GB) — LLM pour le dialectic engine et le deriver. Supporte le tool calling.", + "nomic-embed-text (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 = [ + "1. Hermes reçoit le message de l'utilisateur", + "2. Honcho injecte automatiquement le contexte dans le system prompt (représentation utilisateur + résumé de session + carte AI peer)", + "3. Hermes traite la requête avec ce contexte", + "4. La réponse est envoyée à l'utilisateur", + "5. Le message est stocké dans Honcho (via l'API)", + "6. 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( + "{
" + " \"baseUrl\": \"http://192.168.30.35:8000\",
" + " \"peerName\": \"laurent\",
" + " \"aiPeer\": \"hermes\",
" + " \"workspace\": \"default\",
" + " \"recallMode\": \"hybrid\",
" + " \"writeFrequency\": \"async\",
" + " \"sessionStrategy\": \"per-directory\",
" + " \"observation\": {
" + " \"user\": { \"observeMe\": true, \"observeOthers\": true },
" + " \"ai\": { \"observeMe\": true, \"observeOthers\": true }
" + " },
" + " \"dialecticReasoningLevel\": \"low\",
" + " \"dialecticDepth\": 1,
" + " \"contextCadence\": 1,
" + " \"dialecticCadence\": 2
" + "}", + 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:
" + " 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
" + "AUTH_USE_AUTH=false
" + "VECTOR_STORE_TYPE=pgvector
" + "LLM_OPENAI_API_KEY=ollama
" + "DERIVER_MODEL_CONFIG__TRANSPORT=openai
" + "DERIVER_MODEL_CONFIG__MODEL=nemotron-mini:4b
" + "DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://192.168.30.10:11434/v1
" + "DERIVER_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=ollama
" + "EMBEDDING_MODEL_CONFIG__TRANSPORT=openai
" + "EMBEDDING_MODEL_CONFIG__MODEL=nomic-embed-text
" + "EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://192.168.30.10:11434/v1
" + "EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=ollama
" + "EMBED_MESSAGES=true
" + "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 ", "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 ", "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"{cmd} — {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"{cmd} — {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 ", "Télécharger un modèle"), + ("ollama run ", "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"{cmd} — {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 = """ +┌─────────────────────────────────────────────────────────────┐ +│ 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 │ │ │ +│ │ └──────────────────┘ │ │ +│ └────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +""" +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
" + "Repo Honcho : https://github.com/plastic-labs/honcho
" + "Intégration Hermes : https://docs.honcho.dev/v3/guides/integrations/hermes
" + "App Honcho Cloud : https://app.honcho.dev
" + "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") diff --git a/one-shot/generate_gramps_report.py b/one-shot/generate_gramps_report.py new file mode 100644 index 0000000..1500426 --- /dev/null +++ b/one-shot/generate_gramps_report.py @@ -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'])}") diff --git a/one-shot/google-workspace-mcp-login.py b/one-shot/google-workspace-mcp-login.py new file mode 100644 index 0000000..f282895 --- /dev/null +++ b/one-shot/google-workspace-mcp-login.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +OAuth login for Google Workspace MCP servers (Sheets, Drive) in non-TTY. + +Usage: + 1. systemctl --user stop hermes-gateway.service + 2. python3 /root/workspace/google-workspace-mcp-login.py + 3. For each server, open the URL in your browser, authorize, paste callback URL + 4. systemctl --user start hermes-gateway.service +""" +import sys +import os +import json +import hashlib +import base64 +import secrets +import string +import asyncio +from urllib.parse import urlencode, urlparse, parse_qs + +sys.path.insert(0, "/usr/local/lib/hermes-agent") +sys.path.insert(0, "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages") + +from hermes_cli.config import load_config +from tools.mcp_oauth import HermesTokenStorage, _configure_callback_port +from tools.mcp_oauth_manager import get_manager + +import httpx + +# ── Google OAuth settings ────────────────────────────────────────────── +CLIENT_ID = "1090672727072-4buk3522j52pqut02m22rcnto31938jl.apps.googleusercontent.com" +CLIENT_SECRET = "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" +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() diff --git a/one-shot/oauth-step1.py b/one-shot/oauth-step1.py new file mode 100644 index 0000000..023f000 --- /dev/null +++ b/one-shot/oauth-step1.py @@ -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} ") +print(f" {'='*70}") diff --git a/one-shot/oauth-step2.py b/one-shot/oauth-step2.py new file mode 100644 index 0000000..068d54f --- /dev/null +++ b/one-shot/oauth-step2.py @@ -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 ") + 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) diff --git a/one-shot/plan_maison.py b/one-shot/plan_maison.py new file mode 100644 index 0000000..e571951 --- /dev/null +++ b/one-shot/plan_maison.py @@ -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}") diff --git a/one-shot/populate_gramps_sheet.py b/one-shot/populate_gramps_sheet.py new file mode 100644 index 0000000..e2e0da6 --- /dev/null +++ b/one-shot/populate_gramps_sheet.py @@ -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}")