Gramplets.fr - dsblank/gramps-connect GitHub Wiki

🌐 English · Deutsch · Español · Nederlands · 简体中文

Gramplets et la boutique d'extensions

Les Gramplets sont le système d'extensions de Gramps Connect : de petits programmes Python qui interrogent l'arbre et affichent un résultat — un tableau, un diagramme, du texte brut, ou du HTML. Il en existe deux sortes : les Gramplets, que vous lancez depuis le menu Gramplets dans leur propre fenêtre, et les Gramplets de vue, qui se placent dans un panneau sous une liste (Individu, Famille, ou tout autre type d'objet) et suivent ce que vous y sélectionnez. Ce qui les rend inhabituels, c'est où ils s'exécutent : chacun est du vrai Python, s'exécutant directement dans votre onglet de navigateur sous Pyodide (CPython compilé en WebAssembly), contre des wheels construits localement du propre modèle de données gramps.gen.lib de Gramps. Rien n'est installé sur votre machine, et rien ne s'exécute sur un serveur — voir Architecture.

Cela veut aussi dire que chaque Gramplet est modifiable. En ouvrir un depuis l'éditeur de Gramplets de l'application, et vous regardez (et pouvez changer) sa véritable source — pas une boîte noire derrière un panneau de réglages. Changer l'apparence d'un diagramme, ajuster ce qu'une recherche cherche, ou en écrire une nouvelle de zéro, tout cela sans quitter l'application.

Qui peut utiliser les Gramplets

Toute personne connectée à l'arbre peut exécuter ses Gramplets, depuis le menu Gramplets ou dans le panneau « Gramplets de vue » d'une liste. Seul le propriétaire de l'arbre ou un administrateur du site peut les écrire : en créer un, modifier le code d'un Gramplet, ou en installer, mettre à jour ou retirer un via la boutique de Gramplets. Pour tous les autres, la boutique, Nouveau Gramplet… et les crayons de modification n'apparaissent pas du tout.

Rôle Exécuter des Gramplets Écrire / installer des Gramplets
Invité ✓ (sauf les privés) —
Membre ✓ —
Contributeur ✓ —
Éditeur ✓ —
Propriétaire ✓ ✓
Admin ✓ ✓

Écrire un Gramplet demande plus que les droits habituels d'un éditeur, car son code s'exécute dans le navigateur de tous ceux qui l'utilisent : le modifier affecte donc l'arbre pour tout le monde, pas seulement vos propres modifications. Un Gramplet est stocké comme un objet média ordinaire portant l'étiquette Gramplet, donc les règles de confidentialité habituelles s'appliquent : s'il est marqué privé, les invités ne le verront pas.

Il en va de même pour Autoriser les modifications de données (voir Modifier des données) : seul un propriétaire ou un administrateur peut l'activer pour un Gramplet. N'importe qui peut faire un essai à blanc d'un tel Gramplet, qui n'enregistre rien ; enregistrer ses modifications demande des droits d'éditeur ou supérieurs (les propres permissions d'ajout, de modification et de suppression du serveur), donc pour les invités, membres et contributeurs, le bouton Exécuter de la fenêtre est désactivé.

Parcourir et installer

La boutique de Gramplets est un catalogue consultable à l'intérieur de l'application — ouvrez-la depuis Gramplets → Boutique de Gramplets…. Elle comporte deux onglets, Gramplets et Gramplets de vue ; installer, mettre à jour, ou retirer n'importe quelle entrée en un clic. Un Gramplet installé apparaît aussitôt dans le menu Gramplets (regroupé par catégorie) ; un Gramplet de vue installé s'ajoute à une liste avec le + du panneau « Gramplets de vue » de cette liste. Elle est publiée indépendamment des propres versions de Gramps Connect — le catalogue est du contenu statique (gramplet-store/ dans le dépôt) récupéré par l'application en cours d'exécution au moment voulu, pas intégré au build de l'application, de nouveaux Gramplets peuvent donc apparaître dans la boutique sans que personne ne mette à jour Gramps Connect lui-même.

Des exemples déjà présents dans le catalogue incluent ces Gramplets, qui se lancent depuis le menu Gramplets :

Gramplet Ce qu'il fait
Age at Death by Decade Diagramme de l'âge médian au décès pour chaque décennie de naissance ou de décès
Mark Unsourced People Private Marque comme privées toutes les personnes sans aucune source — modifie des données, donc l'ouvrir fait un essai à blanc qui liste qui changerait, et Exécuter l'enregistre (voir Modifier des données)
People Without Sources Toutes les personnes sans citation sur elles-mêmes ni sur aucun de leurs événements (un parcours de tout l'arbre avec barre de progression)
Possible Duplicate People Personnes de même nom nées à quelques années d'écart, à vérifier et fusionner
Tag People Ajoute une étiquette choisie dans une liste à toutes les personnes du filtre Personnes actuel — modifie des données, donc c'est un essai à blanc jusqu'à ce que vous cliquiez sur Exécuter

et ces Gramplets de vue :

Gramplet de vue Ce qu'il fait
Age-at-Death Histogram Histogramme plotly interactif de l'âge au décès sur tout l'arbre (ou la liste actuellement filtrée)
All Relationships Calculs de relations sur tout l'arbre
Attributes / Backlinks / Children / Citations / Coordinates / Events / Gallery / Notes Panneaux de détail dans le style des Gramplets de bureau pour la fiche sélectionnée
Born In Recherche par lieu de naissance
Crossing Relationships Trouve les relations qui croisent les générations de façon inhabituelle
GOQL Query Taper une clause where GOQL brute, et choisir des expressions de champs comme colonnes
Hello Table L'exemple de départ minimal
People Search Une recherche d'Individus en direct, au fil de la frappe, par nom, sexe et date de décès
Phonebook Surname Sort Tri des noms de famille insensible aux accents (par ex. « Müller » se trie à côté de « Muller »)
Relationship / Relationships Comment deux personnes sélectionnées sont apparentées
Selected Record Réagit à la fiche actuellement ouverte
To-Do / Todo Tracker Suit les tâches de recherche à faire sur tout l'arbre
Tree Statistics Un tableau de bord des comptes de fiches et pourcentages dérivés

Dans chaque vue en liste (Personnes, Familles, Événements, …), les Gramplets de vue que vous avez ajoutés à cette vue apparaissent sous forme d'onglets dans un panneau Gramplets de vue repliable, sous le tableau. Le panneau est replié au départ, et un Gramplet de vue ne s'exécute que lorsque le panneau est ouvert : le déplier exécute l'onglet sélectionné. La première exécution d'une session charge aussi l'environnement Python du navigateur et peut donc prendre quelques secondes ; les suivantes sont rapides. Un panneau replié ne coûte rien, puisque Python n'est chargé qu'à son ouverture.

Les Gramplets dans leur propre fenêtre

Un Gramplet (par opposition à un Gramplet de vue) n'est lié à aucune liste. Choisissez-en un dans le menu Gramplets — regroupés en sous-menus par catégorie dès qu'il y a plus d'une catégorie — et il s'ouvre dans une fenêtre flottante :

  • Il s'exécute à l'ouverture. Un Gramplet qui a d'abord besoin d'une saisie — un nom de famille, un nombre de générations — dessine ses propres champs et un bouton Exécuter, avec les mêmes widgets que les Gramplets de vue.
  • Les Gramplets qui modifient des données (marqués ✎ dans le menu) font un essai à blanc à l'ouverture et ont des boutons Essai à blanc / Exécuter et Annuler — voir Modifier des données.
  • Il voit la liste derrière lui. Si une liste est ouverte dans la vue principale, le Gramplet voit son enregistrement sélectionné et son filtre actuel, tels qu'ils étaient au début de l'exécution ; naviguer ailleurs ensuite ne change pas une exécution en cours.
  • Progression et Arrêter. Un Gramplet plus long affiche une barre de progression pendant qu'il travaille ; Arrêter l'interrompt aussitôt et conserve ce qu'il avait produit jusque-là.
  • Continuer à travailler en attendant. Réduisez une fenêtre (▾) et elle devient une petite pastille en bas de l'écran qui montre où elle en est ; l'exécution continue. Quand un Gramplet réduit se termine, vous recevez une notification avec un bouton Afficher. Plusieurs Gramplets peuvent tourner en même temps (jusqu'à cinq fenêtres), chacun indépendamment, et ils restent ouverts quand vous passez d'une vue à l'autre.
  • Déplacer, redimensionner, agrandir. Faites glisser une fenêtre par sa barre de titre, redimensionnez-la par son coin, ou agrandissez-la (⤢, ou double-clic sur le titre). Chaque Gramplet mémorise la taille et la position de sa fenêtre dans votre navigateur.
  • Modification. Pour le propriétaire de l'arbre ou un administrateur du site (voir Qui peut utiliser les Gramplets), la barre de titre de la fenêtre comporte un crayon (✎) qui ouvre l'éditeur de Gramplets ; enregistrer relance la fenêtre avec vos modifications.
  • Fermeture. Pour relancer un Gramplet depuis le début, fermez sa fenêtre et choisissez-le à nouveau dans le menu. Fermer une fenêtre encore en cours d'exécution demande d'abord confirmation, de même que quitter ou recharger la page — fermer l'onglet du navigateur arrête toutes les exécutions.

Les résultats ne sont affichés que dans la fenêtre ; ils ne sont encore enregistrés nulle part.

Écrire un Gramplet

Le code d'un Gramplet s'exécute dans un bac à sable avec un ensemble de fonctions et d'objets déjà disponibles — aucun import nécessaire pour atteindre l'arbre. Le répertoire gramplet_examples/ du dépôt est un parcours numéroté à travers l'API, du plus simple au plus avancé ; la même référence est disponible depuis l'application elle-même via le bouton d'aide (i) « Écrire un Gramplet » dans l'éditeur de Gramplets.

Le plus petit Gramplet utile

matches = people(
    "gender == Person.MALE and birth.date.sortval >= Date('Jan 1, 1900')",
    limit=25,
)

columns("Person")
for person in matches:
    row(person)
  • people(where=None, what=None, order=None, limit=50) récupère les Individus correspondant à une requête, comme fiches complètes, en un seul appel — ou, avec what=["gramps_id", "primary_name"], seulement ces champs — et il existe un families()/events()/places()/ repositories()/sources()/citations()/media()/notes()/ tags() pour chaque autre type d'objet, même signature, juste une table différente.
  • where= s'écrit en GOQL — exactement la même syntaxe que le champ de recherche de n'importe quelle vue de liste, y compris atteindre à travers des relations (birth.date.sortval) même quand le champ filtré n'est pas un de ceux affichés.
  • Pas besoin de await bien que ce soit un véritable appel réseau sous le capot — il est inséré automatiquement.
  • row(*values) ajoute une ligne au tableau. Passer un objet Person/Event/Place/… entier (plutôt qu'un champ choisi à la main) le représente comme un lien cliquable qui montre déjà son nom/titre complet et son ID Gramps — cliquer dessus ouvre une fenêtre pour voir cette fiche en Liste, Carte, Graphe, ou Chronologie.
  • name_of(person) est le nom d'un individu sous forme de texte — prénom, puis noms de famille (« Root Ancestor »), comme row() les étiquette — pour un titre ou une phrase. Accepte aussi l'un de ses noms, p. ex. name_of(person.alternate_names[0]).

Chaque fiche renvoyée par people()/families()/etc. est un « DataDict » Gramps — un dict ordinaire des champs de l'objet, mais avec un accès par point en plus : person.primary_name.first_name est la même valeur que person["primary_name"]["first_name"]. Y recourir chaque fois qu'il faut calculer quelque chose à partir d'un champ ou montrer quelque chose que la propre représentation de row() ne fait pas apparaître. row(), html(), et print() peuvent chacun être appelés autant de fois que voulu, dans n'importe quel ordre — tout apparaît dans l'ordre où cela a été ajouté.

Afficher une fiche n'échoue jamais : print(person) ou f"{person}" montre une fiche entière comme row() l'étiquette (« Root Ancestor [I0001] »), une date comme une date, et tout le reste — un résultat de filter(), person.primary_name — comme ses champs bruts.

Compter à moindre coût sans télécharger l'arbre

total_people = db.get_number_of_people()
women = db.get_number_of("person", where="gender == Person.FEMALE")
no_birth_date = db.get_number_of("person", where="birth.date.sortval is None")

db.get_number_of_<type>() est un comptage économique sur tout l'arbre — le total revient dans un en-tête de réponse, aucune fiche n'est réellement téléchargée. db.get_number_of(object_type, where=...) est l'équivalent conditionnel, pour quand le comptage lui-même a besoin d'un filtre.

Atteindre à travers des relations dans une requête, et en ressortir pour l'affichage

Une condition where= peut atteindre à travers une relation pour filtrer dessus — l'événement de naissance d'une personne, le père d'une famille, le lieu d'un événement — même si people()/ families()/etc. renvoient toujours un objet entier, indépendant, sans moyen de redemander directement un champ lié :

born_and_died_same_place = people(
    "birth.place.title == death.place.title and birth.place.title is not None",
    order=[{"column": "surname", "direction": "asc"}],
    limit=50,
)

Récupérer un champ lié pour l'affichage (plutôt que simplement filtrer dessus) est une recherche séparée, à la main, via les propres méthodes de db — db.get_event_from_handle(...), et ainsi de suite — suivant la même indirection par handle que le propre modèle de données de Gramps utilise en interne.

Les collections fonctionnent aussi : any(c.given_name == 'Steve' for c in children) correspond à une famille si un enfant quelconque satisfait une condition, et len([c for c in children if c.gender == Person.MALE]) > 1 compte seulement les fils. Voir GOQL pour l'histoire complète des collections, y compris sa limite actuelle (un saut dans une collection, pas d'enchaînement supplémentaire au-delà de ce qu'elle contient).

Réagir à la fiche sélectionnée

record = get_selected()
if record is not None:
    if isinstance(record, Person):
        ...

get_selected() renvoie la fiche actuellement ouverte dans le propre panneau de détail de la vue — None quand rien n'est sélectionné, ou en s'exécutant depuis l'aperçu de l'éditeur autonome (qui n'a aucun contexte de vue du tout). Un seul appel réseau au premier appel d'une exécution, puis mémorisé pour le reste. isinstance(record, Person) (etc.) indique quel type de fiche a été obtenu, ce qui importe pour un Gramplet attaché aux vues « Toutes ». Pour que cela se mette à jour en direct en cliquant à travers la liste, activer « Réexécuter automatiquement quand la fiche sélectionnée change » dans le propre éditeur du Gramplet — désactivé (par défaut), get_selected() fonctionne toujours, mais ne reflète que ce qui était sélectionné la dernière fois que le Gramplet s'est exécuté pour une autre raison.

Réagir au filtre actif

Un Gramplet peut superposer sa propre requête à tout filtre actuellement appliqué à la vue — soit tapé dans le propre champ de recherche de la vue, soit appliqué via le sélecteur « Filtres » (un filtre enregistré ou une Règle personnalisée) — filtrer la liste pour la réduire à une branche de l'arbre restreint donc la propre sortie du Gramplet de la même façon :

rows = filter(
    "person",
    where=and_filters(get_filter(), "birth.date.sortval is not None and death.date.sortval is not None"),
)

Cela nécessite views: [...] défini dans le manifeste (get_filter() ne renvoie qu'un where_expr correspondant au propre type d'objet de la vue) et listensToFilter: true pour que le Gramplet se réexécute réellement quand le filtre change.

Diagrammes

pygal, matplotlib et plotly sont tous préinstallés — un simple import (ou, pour plotly, from plotly.subplots import make_subplots) fonctionne hors ligne, aucun %pip install nécessaire. print(fig) sur une Figure plotly, une chaîne SVG de pygal, ou une figure matplotlib est reconnu automatiquement et rendu comme un diagramme — aucun to_html()/intégration manuel nécessaire.

Pour les données de diagramme, passer what=[...] quand seuls un ou deux champs sont nécessaires — people() seul télécharge chaque champ de chaque fiche, ce qui est gaspillé pour un diagramme qui ne trace, disons, que l'âge au décès.

Disposition côte à côte

col1, col2 = st.columns([2, 1])
with col1:
    row("Colonne plus large")
with col2:
    row("Colonne plus étroite")

st.columns(spec) dispose spec régions côte à côte — un entier pour ce nombre de colonnes de largeur égale, ou une liste de poids (st.columns([2, 1])) pour des largeurs proportionnelles — et renvoie une région par colonne. Tout ce qui est écrit à l'intérieur de with col: (row()/html()/print(), ou un autre appel st.*, y compris un st.columns() imbriqué) atterrit dans cette colonne plutôt qu'au niveau supérieur ; col.write(x) fonctionne de la même façon sans bloc with.

Un Gramplet dans sa propre fenêtre

Gramplets → Nouveau Gramplet… ouvre l'éditeur de Gramplets prêt pour cette sorte (pour un Gramplet existant, réglez Sorte sur Gramplet — s'ouvre dans une fenêtre depuis le menu Gramplets). À la place des réglages de Gramplet de vue, il y a un champ Catégorie, qui le regroupe avec d'autres dans le menu Gramplets (par ex. chart) ; vide, il est rangé sous Autres.

Le code s'exécute dès que la fenêtre s'ouvre. Pour demander d'abord une saisie, dessinez-la avec les widgets st.* et faites le vrai travail derrière un st.button. Ils suivent les noms et arguments de Streamlit : st.button, st.text_input, st.number_input, st.checkbox, st.selectbox, st.radio, st.columns, st.progress, les boîtes de message st.info / st.success / st.warning / st.error, et st.session_state. Ils fonctionnent de la même façon dans les Gramplets de vue. Pour un Gramplet de longue durée, affichez une barre avec st.progress(value, text), comme dans Streamlit : value va de 0 à 100 (ou de 0.0 à 1.0), et le .progress(...) de la barre renvoyée la déplace sur place tandis que .empty() la retire — assez peu coûteux pour être appelé une fois par ligne. Cela fonctionne aussi dans les Gramplets de vue. people(..., limit=None) (et les autres fonctions de liste) renvoient toutes les correspondances au lieu des 50 premières :

surname = st.text_input("Surname", "Smith")
if st.button("Run"):
    everyone = people(f"primary_name.surname_list[0].surname == '{surname}'", limit=None)
    bar = st.progress(0, text="Checking people...")
    for i, person in enumerate(everyone):
        bar.progress((i + 1) / len(everyone))
        ...
    bar.empty()

Installer un paquet tiers

%pip install unidecode
from unidecode import unidecode

%pip install est une syntaxe magique de Jupyter, réécrite en un véritable appel await micropip.install([...]) avant l'exécution du code — cela doit être sur sa propre ligne, tout en haut, exactement comme dans un notebook (l'écrire à l'intérieur d'un if, par exemple, ne fonctionnera pas). Cela ne fonctionne que pour les paquets Python pur, sans code compilé/extension C ; pygal/matplotlib/plotly n'en ont pas besoin car ils sont préinstallés, mais la plupart des petits utilitaires Python purs sur PyPI s'installent bien de cette façon.

Modifier des données

Un Gramplet (pas un Gramplet de vue — ceux-là ne modifient jamais de données) peut modifier et supprimer des enregistrements une fois Autoriser les modifications de données activé dans l'éditeur de Gramplets. L'API est celle qu'utilise Gram.py Script dans Gramps de bureau : on modifie un enregistrement comme n'importe quel objet Python.

for person in people("len(citation_list) == 0", limit=None):
    person.private = True

Mark Unsourced People Private, dans la boutique, est un exemple complet : il trouve d'abord les personnes à moindre coût avec filter(), puis ne récupère et ne modifie que celles qui en ont besoin, au sein d'un groupe begin_changes() nommé. Tag People montre l'ajout à une liste (person.tag_list.append(tag.handle) — les enregistrements stockent les étiquettes sous forme de handles, pas de noms) et un st.selectbox() dans lequel vous devez choisir avant que quoi que ce soit ne change.

  • record.field = value modifie un champ, y compris imbriqué (person.primary_name.first_name = "Chris"). De même pour les modifications de listes (.append(), .pop(), ...) et toute méthode set_*(), par ex. person.set_privacy(True).
  • delete(record) supprime un enregistrement. Cela ne retire pas les références à celui-ci depuis d'autres enregistrements.
  • begin_changes("message") / end_changes() regroupent les modifications intermédiaires sous ce message. Facultatif : sans eux, toute l'exécution forme un seul groupe, nommé d'après le Gramplet.

Les enregistrements issus de people() et des autres fonctions au pluriel, de get_raw_object(), get_object(), get_selected() et db.* peuvent être modifiés. Les résultats de filter() ne le peuvent pas, puisqu'ils ne contiennent que certains champs d'un enregistrement. Demander deux fois le même enregistrement au cours d'une exécution renvoie le même objet, avec vos modifications dedans.

D'abord un essai à blanc. Dans l'éditeur de Gramplets, chaque exécution est un essai à blanc, et ouvrir la fenêtre du Gramplet depuis le menu Gramplets en fait un aussi. Rien n'est enregistré ; sous la sortie, un encadré jaune Essai à blanc liste chaque enregistrement qui changerait, avec l'ancienne et la nouvelle valeur de chaque champ modifié, et chaque enregistrement qui serait supprimé, avec le nombre d'autres enregistrements qui y font encore référence. Chaque enregistrement modifié est d'abord vérifié par rapport à son schéma Gramps, la même vérification que fait le serveur ; une valeur invalide arrête l'exécution et nomme le champ. Un point où un essai à blanc peut différer de l'exécution réelle : les recherches (filter(), count(), where=) s'exécutent sur le serveur, elles voient donc encore l'arbre tel qu'il était avant les modifications de l'exécution.

Ensuite, Exécuter. La fenêtre du Gramplet a des boutons Essai à blanc et Exécuter (le menu marque ces Gramplets d'un ✎). Exécuter enregistre chaque groupe de modifications au moment où le code le ferme — à end_changes(), ou à la fin de l'exécution — en une seule transaction, si bien qu'il apparaît comme une seule entrée dans l'historique de l'arbre. Tant que le résultat d'une exécution réelle est affiché, la barre de titre indique Enregistrement des modifications, et l'encadré sous la sortie devient vert et marque chaque groupe comme enregistré. Si le serveur refuse un groupe — très probablement parce que quelqu'un d'autre a modifié l'un de ses enregistrements après que le Gramplet l'a lu —, rien de ce groupe n'est enregistré, l'exécution s'arrête avec une erreur, et les groupes enregistrés avant lui le restent. Les widgets de la sortie (st.button("Apply"), etc.) réexécutent le code dans le mode d'où provient le résultat actuel, de sorte qu'un Gramplet peut montrer ce qu'il changerait lors d'un essai à blanc et l'appliquer après Exécuter.

Annuler. Après une exécution réelle, la fenêtre indique combien de groupes elle a enregistrés et affiche un bouton Annuler, qui les défait, le dernier en premier. Annuler refuse un groupe dont quelqu'un a modifié les enregistrements depuis, plutôt que d'écraser ces modifications. Il n'est proposé que pour le résultat actuel de la fenêtre : relancer le Gramplet, ou fermer sa fenêtre, y met fin.

Publier dans la boutique

Un dossier par Gramplet dans gramplet-store/, nommé d'après son id (le nom du dossier et le propre champ id du manifeste doivent correspondre) :

gramplet-store/
  <slug>/
    manifest.json   # requis
    code.py         # requis -- le code source Python du Gramplet
    icon.png        # optionnel (png/jpg/jpeg/svg/webp)

Champs de manifest.json :

Champ Requis Signification
id oui Doit correspondre au nom du dossier.
name oui Affiché comme titre de l'entrée dans la boutique.
description oui Affichée sous le nom dans la boutique.
version oui Semver simple, par ex. "1.0.0" — l'incrémenter à chaque changement de code.py ou du manifeste, pour que les copies installées puissent détecter qu'une mise à jour est disponible.
author oui Affiché dans la boutique.
category oui Une courte étiquette ("example", "detail", "utility", "chart", …) utilisée pour grouper/filtrer les entrées.
views non À quelles vues de type d'objet ("person", "family", …) ce Gramplet peut être ajouté. Omettre pour « tous les types ».
listensToSelection non Si ce Gramplet doit se réexécuter quand la fiche sélectionnée change.
listensToFilter non Si ce Gramplet doit se réexécuter quand le filtre actif change (champ de recherche ou sélecteur Filtres).
kind non "view" (par défaut) pour un Gramplet de vue ; "window" pour un Gramplet lancé depuis le menu Gramplets. views/listensTo* ne s'appliquent qu'aux Gramplets de vue.
allowChanges non true pour l'installer avec Autoriser les modifications de données activé (la boutique affiche un badge Modifie vos données). Autorisé seulement avec "kind": "window".

Après avoir ajouté ou modifié une entrée, régénérer catalog.json — le seul fichier que l'application récupère réellement :

npm --prefix app run build:gramplet-catalog

Cela valide chaque entrée (champs requis présents, id correspond à son dossier, version ressemble à du semver, code.py non vide, kind valide) et échoue bruyamment au premier problème plutôt que de publier un catalogue défectueux.

Ce que les Gramplets ne peuvent pas encore faire

Les Gramplets peuvent modifier et supprimer des enregistrements (voir Modifier des données) mais ne peuvent pas encore en créer de nouveaux. Il n'y a pas non plus encore d'équivalent pour les autres types d'add-ons de Gramps (outils, rapports). Voir Feuille de route et limitations connues pour l'état actuel de tout cela.

⚠️ **GitHub.com Fallback** ⚠️