-
Notifications
You must be signed in to change notification settings - Fork 0
Gramplets
Gramplets are Gramps Connect's add-on system: small Python programs,
attached to a view (Person, Family, or any other object type — or "All"),
that query the tree and render a result — a table, a chart, plain text, or
HTML. What makes them unusual is where they run: each one is real
Python, executing directly in your browser tab under
Pyodide (CPython compiled to WebAssembly), against
locally-built wheels of Gramps' own gramps.gen.lib data model. Nothing is
installed on your machine, and nothing runs on a server — see
Architecture.
That also means every Gramplet is editable. Open one from inside the app's Gramplet editor, and you're looking at (and can change) its actual source — not a black box behind a settings panel. Change how a chart looks, tweak what a lookup searches for, or write a new one from scratch, all without leaving the app.
The Gramplet Store is a browsable catalog inside the app. Install, update,
or remove any entry with a click. It's published independently of Gramps
Connect's own releases — the catalog is static content
(gramplet-store/
in the repo) fetched by the running app at runtime, not bundled into the
app build, so new Gramplets can appear in the Store without anyone
upgrading Gramps Connect itself.
Examples already in the catalog include:
| Gramplet | What it does |
|---|---|
| Age-at-Death Histogram | Interactive plotly histogram of age at death across the tree (or the current filtered list) |
| All Relationships | Relationship calculations across the tree |
| Attributes / Backlinks / Children / Citations / Coordinates / Events / Gallery / Notes | Desktop-Gramplet-style detail panels for the selected record |
| Born In | Lookup by birthplace |
| Crossing Relationships | Finds relationships that cross generations in unusual ways |
| GOQL Query | Type a raw GOQL where clause, and pick field expressions as columns |
| Hello Table | The minimal starting example |
| Interactive Search | A live, as-you-type search Gramplet |
| People Explorer | A richer people-browsing Gramplet |
| Phonebook Surname Sort | Accent-folding surname sort (e.g. "Müller" sorts next to "Muller") |
| Relationship / Relationships | How two selected people are related |
| Selected Record | Reacts to whichever record is currently open |
| To-Do / Todo Tracker | Tracks research to-do items across the tree |
| Tree Statistics | A dashboard of record counts and derived percentages |
A Gramplet's code runs in a sandbox with a set of functions and objects
already available — no imports needed to reach the tree. The
gramplet_examples/
directory in the repo is a numbered tour through the API, from simplest to
most advanced; the same reference is available from inside the app itself
via the (i) "Writing a Gramplet" help button in the Gramplet editor.
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, order=None, limit=50)fetches People matching a query, as full records, in one call — and there's afamilies()/events()/places()/repositories()/sources()/citations()/media()/notes()/tags()for every other object type, same signature, just a different table. -
where=is written in GOQL — the exact same syntax as the search box on any list view, including reaching across relationships (birth.date.sortval) even when the field you're filtering on isn't one you're displaying. - No
awaitneeded even though this is a real network call under the hood — it's inserted for you automatically. -
row(*values)adds one row to the table. Passing a whole Person/Event/Place/... object (rather than a hand-picked field of it) renders it as a clickable link that already shows its full name/title and Gramps ID — clicking it opens a popup to view that record in List, Map, Graph, or Timeline.
Every record returned by people()/families()/etc. is a Gramps
"DataDict" — a plain dict of the object's fields, but with dot access on
top: person.primary_name.first_name is the same value as
person["primary_name"]["first_name"]. Reach for this whenever you want
to compute something from a field or show one row()'s own rendering
doesn't surface. row(), html(), and print() can each be called as
many times as you like, in any order — everything shows up in the order
you added it.
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>() is a cheap, whole-tree count — the total comes
back in a response header, no records are actually downloaded. db. get_number_of(object_type, where=...) is the conditional equivalent, for
when the count itself needs a filter.
A where= condition can reach across a relationship to filter on — a
person's birth event, a family's father, an event's place — even though
people()/families()/etc. always hand back a whole, unrelated object
with no way to ask for a related field back directly:
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,
)Getting a related field back for display (rather than just filtering on
it) is a separate, by-hand lookup through db's own methods —
db.get_event_from_handle(...), and so on — following the same handle
indirection Gramps' own data model uses internally.
Collections work too: any(c.given_name == 'Steve' for c in children)
matches a family if any child satisfies a condition, and len([c for c in children if c.gender == Person.MALE]) > 1 counts just the sons. See
GOQL for the full collections story,
including its current limit (one hop into a collection, no further
chaining past what's inside it).
record = get_selected()
if record is not None:
if isinstance(record, Person):
...get_selected() returns whichever record is currently open in the view's
own detail pane — None when nothing is selected, or when running from
the standalone editor preview (which has no view context at all). One
network fetch on first call in a run, then memoized for the rest of it.
isinstance(record, Person) (etc.) tells you what kind of record you got,
which matters for a Gramplet attached to "All" views. For this to update
live as you click through the list, turn on "Re-run automatically when the
selected record changes" in the Gramplet's own editor — left off (the
default), get_selected() still works, but only reflects whatever was
selected the last time the Gramplet happened to run for some other reason.
A Gramplet can layer its own query on top of whatever filter is currently applied to the view — either typed into the view's own search box or applied through the "Filters" picker (a saved filter or Custom Rule) — so filtering the list down to one branch of the tree narrows the Gramplet's own output the same way:
rows = filter(
"person",
where=and_filters(get_filter(), "birth.date.sortval is not None and death.date.sortval is not None"),
)This needs views: [...] set in the manifest (get_filter() only hands
back a where_expr matching the view's own object type) and
listensToFilter: true so the Gramplet actually re-renders when the
filter changes.
pygal, matplotlib, and plotly are all pre-bundled — a plain import (or,
for plotly, from plotly.subplots import make_subplots) works offline,
no %pip install needed. print(fig) on a plotly Figure, an SVG string
from pygal, or a matplotlib figure is recognized automatically and
rendered as a chart — no manual to_html()/embedding required.
For chart data, prefer filter(object_type, what=[...], where=..., limit=...)
over people()/families()/etc. when you only need one or two fields —
people() means a full-object network fetch per record, which is wasteful
for a chart that only plots, say, age at death.
col1, col2 = st.columns([2, 1])
with col1:
row("Wider column")
with col2:
row("Narrower column")st.columns(spec) lays out spec regions side by side — an int for that
many equal-width columns, or a list of weights (st.columns([2, 1])) for
proportional widths — and hands back one region per column. Anything
written inside with col: (row()/html()/print(), or another st.*
call, including a nested st.columns()) lands in that column instead of
at the top level; col.write(x) works the same way without a with
block.
%pip install unidecode
from unidecode import unidecode%pip install is Jupyter-magic syntax, rewritten into a real
await micropip.install([...]) call before your code runs — it must be
its own line, at the top, exactly like in a notebook (writing it inside an
if, for instance, won't work). This only works for pure-Python
packages with no compiled/C-extension code; pygal/matplotlib/plotly don't
need it because they're pre-bundled, but most small pure-Python utilities
on PyPI install fine this way.
One folder per Gramplet in
gramplet-store/,
named by its id (the folder name and the manifest's own id field must
match):
gramplet-store/
<slug>/
manifest.json # required
code.py # required -- the Gramplet's Python source
icon.png # optional (png/jpg/jpeg/svg/webp)
manifest.json fields:
| Field | Required | Meaning |
|---|---|---|
id |
yes | Must match the folder name. |
name |
yes | Shown as the entry's title in the Store. |
description |
yes | Shown under the name in the Store. |
version |
yes | Plain semver, e.g. "1.0.0" — bump it whenever code.py or the manifest changes, so installed copies can detect an update is available. |
author |
yes | Shown in the Store. |
category |
yes | A short tag ("example", "detail", "utility", "chart", ...) used to group/filter entries. |
views |
no | Which object-type views ("person", "family", ...) this Gramplet can be added to. Omit for "every type". |
listensToSelection |
no | Whether this Gramplet should re-run when the selected record changes. |
listensToFilter |
no | Whether this Gramplet should re-run when the active filter changes (search box or Filters picker). |
After adding or editing an entry, regenerate catalog.json — the single
file the app actually fetches:
npm --prefix app run build:gramplet-catalogThis validates every entry (required fields present, id matches its
folder, version looks like semver, code.py non-empty) and fails loudly
on the first problem rather than publishing a broken catalog.
Gramplets are read-only against the tree today — they can query
(filter()/get_object()) but not edit or create objects. There's also
no equivalent yet for other Gramps add-on types (tools, reports). See
Roadmap and Known Limitations for the current state of these.
Gramps Connect is part of the family of Gramps-based software.
Using the app
- Overview
- Installing
- Deploying
- Messaging
- GOQL (advanced search)
- Gramplets & Add-on Store
- Data Model & Editing
- FAQ
Building & contributing