diff --git a/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py new file mode 100644 index 0000000000..f088cd8fb5 --- /dev/null +++ b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py @@ -0,0 +1,36 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Javad Razavian +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +register( + GRAMPLET, + id="DateOfDeath", + name=_("Date of Death"), + description=_("a gramplet that displays death dates in sorted order"), + status=STABLE, + version = '1.1.1', + fname="DateOfDeathGramplet.py", + authors = ["Javad Razavian"], + authors_email = ["javadr@gmail.com"], + height=200, + gramplet="DateOfDeathGramplet", + gramps_target_version="6.1", + gramplet_title=_("Date of Death"), + help_url="Addon:DateOfDeathGramplet", +) diff --git a/DateOfDeathGramplet/DateOfDeathGramplet.py b/DateOfDeathGramplet/DateOfDeathGramplet.py new file mode 100644 index 0000000000..045bfc0a52 --- /dev/null +++ b/DateOfDeathGramplet/DateOfDeathGramplet.py @@ -0,0 +1,130 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Javad Razavian +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +from gramps.gen.plug import Gramplet +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.display.name import displayer as name_displayer +from gramps.gen.lib.date import Today, Date, gregorian +import gramps.gen.datehandler +from gramps.gen.plug.menu import EnumeratedListOption +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.gettext + + +class DateOfDeathGramplet(Gramplet): + def init(self): + self.set_text(_("No Family Tree loaded.")) + self.sort_mode = 'proximity' + + def build_options(self): + name_sort = _("Sort dates of death by") + self.opt_sort = EnumeratedListOption(name_sort, self.sort_mode) + self.opt_sort.add_item("proximity", _("Proximity to current date")) + self.opt_sort.add_item("month_day", _("Month and day")) + + self.add_option(self.opt_sort) + + def save_options(self): + self.sort_mode = self.opt_sort.get_value() + + def save_update_options(self, obj): + self.save_options() + self.gui.data = [self.sort_mode] + self.update() + + def on_load(self): + if len(self.gui.data) >= 1: + self.sort_mode = self.gui.data[0] + else: + self.sort_mode = 'proximity' + + def db_changed(self): + self.connect(self.dbstate.db, 'person-add', self.update) + self.connect(self.dbstate.db, 'person-delete', self.update) + self.connect(self.dbstate.db, 'person-update', self.update) + + def main(self): + self.set_text(_("Processing...")) + database = self.dbstate.db + self.result = [] + + for person in database.iter_people(): + death_ref = person.get_death_ref() + if not death_ref: + continue + death_event = database.get_event_from_handle(death_ref.ref) + date_of_death = death_event.get_date_object() + if not date_of_death.is_regular(): + continue + + self.__calculate(database, person) + + sort_by = self.opt_sort.get_value() + if sort_by == "proximity": + self.result.sort(key=lambda item: -item[0]) + else: + self.result.sort(key=lambda item: (item[1].get_month(), + item[1].get_day())) + self.clear_text() + + for diff_days, date, person, age in self.result: + name = person.get_primary_name() + displayer = gramps.gen.datehandler.displayer + self.append_text("{}: ".format(displayer.display(date))) + self.link(name_displayer.display_name(name), "Person", + person.handle) + if age: + self.append_text(" ({})\n".format(age)) + else: + self.append_text("\n") + self.append_text("", scroll_to="begin") + + def __calculate(self, database, person): + today = Today() + death_ref = person.get_death_ref() + if not death_ref: + return + death_event = database.get_event_from_handle(death_ref.ref) + date_of_death = death_event.get_date_object() + if not date_of_death.is_regular(): + return + + death_greg = gregorian(date_of_death) + death_this_year = Date(today.get_year(), + death_greg.get_month(), + death_greg.get_day()) + diff = today - death_this_year + diff_days = diff[1] * 30 + diff[2] + + birth_ref = person.get_birth_ref() + age = "" + if birth_ref: + birth = database.get_event_from_handle(birth_ref.ref) + birth_date = birth.get_date_object() + if birth_date.is_regular(): + age = date_of_death - birth_date + + if diff_days <= 0: + self.result.append((diff_days, date_of_death, person, age)) + else: + self.result.append((diff_days - 365, date_of_death, person, age)) diff --git a/DateOfDeathGramplet/po/ca-local.po b/DateOfDeathGramplet/po/ca-local.po new file mode 100644 index 0000000000..9c4f1da3d5 --- /dev/null +++ b/DateOfDeathGramplet/po/ca-local.po @@ -0,0 +1,36 @@ +msgid "" +msgstr "" +"Project-Id-Version: ca\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-09-03 03:01+0000\n" +"Last-Translator: Adolfo Jayme Barrientos \n" +"Language-Team: Catalan \n" +"Language: ca\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.13.1-dev\n" + +msgid "Date of Death" +msgstr "Data de defunció" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "un gramplet que mostra les dates de defunció ordenades" + +msgid "No Family Tree loaded." +msgstr "No hi ha cap arbre familiar carregat." + +msgid "Sort dates of death by" +msgstr "Ordena les dates de mort per" + +msgid "Month and day" +msgstr "Mes i dia" + +msgid "Proximity to current date" +msgstr "Proximitat a la data actual" + +msgid "Processing..." +msgstr "Processant…" diff --git a/DateOfDeathGramplet/po/da-local.po b/DateOfDeathGramplet/po/da-local.po new file mode 100644 index 0000000000..43b61a8af0 --- /dev/null +++ b/DateOfDeathGramplet/po/da-local.po @@ -0,0 +1,36 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-02-25 16:12+0000\n" +"Last-Translator: Kaj Arne Mikkelsen \n" +"Language-Team: Danish \n" +"Language: da\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.2-dev\n" + +msgid "Date of Death" +msgstr "Dødsdato" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "en gramplet der viser dødsdatoer sorteret" + +msgid "No Family Tree loaded." +msgstr "Ingen stamtræ indlæst." + +msgid "Sort dates of death by" +msgstr "Sorter dødsdatoerne efter" + +msgid "Month and day" +msgstr "Måned og dag" + +msgid "Proximity to current date" +msgstr "Nærhed til nuværende dato" + +msgid "Processing..." +msgstr "Behandler…" diff --git a/DateOfDeathGramplet/po/de-local.po b/DateOfDeathGramplet/po/de-local.po new file mode 100644 index 0000000000..31d8777e9f --- /dev/null +++ b/DateOfDeathGramplet/po/de-local.po @@ -0,0 +1,36 @@ +msgid "" +msgstr "" +"Project-Id-Version: de\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-05-19 21:02+0000\n" +"Last-Translator: Mirko Leonhäuser \n" +"Language-Team: German \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.12-dev\n" + +msgid "Date of Death" +msgstr "Todesdatum" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "ein Gramplet, das die Todesdaten sortiert anzeigt" + +msgid "No Family Tree loaded." +msgstr "Kein Stammbaum geladen." + +msgid "Sort dates of death by" +msgstr "Sortieren Sie die Sterbedaten nach" + +msgid "Month and day" +msgstr "Monat und Tag" + +msgid "Proximity to current date" +msgstr "Nähe zum aktuellen Datum" + +msgid "Processing..." +msgstr "Verarbeite…" diff --git a/DateOfDeathGramplet/po/es-local.po b/DateOfDeathGramplet/po/es-local.po new file mode 100644 index 0000000000..d900b6eb8e --- /dev/null +++ b/DateOfDeathGramplet/po/es-local.po @@ -0,0 +1,37 @@ +msgid "" +msgstr "" +"Project-Id-Version: GRAMPS 3.1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2026-05-04 21:37+0000\n" +"Last-Translator: Francisco Serrador \n" +"Language-Team: Spanish \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.17.1\n" + +msgid "Date of Death" +msgstr "Fecha de fallecimiento" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "un gramplet que muestra las fechas de fallecimiento ordenadas" +"un gramplet que muestra las fechas de fallecimiento ordenadas por mes y día" + +msgid "No Family Tree loaded." +msgstr "No hay ningún árbol familiar cargado." + +msgid "Sort dates of death by" +msgstr "Ordenar fechas de muerte por" + +msgid "Month and day" +msgstr "mes y dia" + +msgid "Proximity to current date" +msgstr "Proximidad a la fecha actual" + +msgid "Processing..." +msgstr "Procesando…" diff --git a/DateOfDeathGramplet/po/fa-local.po b/DateOfDeathGramplet/po/fa-local.po new file mode 100644 index 0000000000..a9d5d31f6d --- /dev/null +++ b/DateOfDeathGramplet/po/fa-local.po @@ -0,0 +1,39 @@ +msgid "" +msgstr "" +"Project-Id-Version: DateOfDeathGramplet\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2026-07-04 12:00+0000\n" +"Last-Translator: Javad Razavian \n" +"Language-Team: Persian \n" +"Language: fa\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Weblate 5.13-dev\n" + +msgid "Date of Death" +msgstr "تاریخ فوت" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "نموداری که تاریخ‌های مرگ را به ترتیب مرتب‌شده نمایش می‌دهد" + +msgid "No Family Tree loaded." +msgstr "هیچ شجره‌نامه‌ای بارگذاری نشده است." + +msgid "Sort dates of death by" +msgstr "تاریخ های مرگ را بر اساس مرتب کنید" + +msgid "Month and day" +msgstr "ماه و روز" + +msgid "Proximity to current date" +msgstr "نزدیکی به تاریخ فعلی" + +msgid "Processing..." +msgstr "در حال پردازش..." + +#~ msgid "a gramplet that displays death dates sorted by month and day" +#~ msgstr "یک گرمپلت که تاریخ‌های فوت را مرتب بر اساس ماه و روز نمایش می‌دهد" diff --git a/DateOfDeathGramplet/po/fi-local.po b/DateOfDeathGramplet/po/fi-local.po new file mode 100644 index 0000000000..c5895ae0e6 --- /dev/null +++ b/DateOfDeathGramplet/po/fi-local.po @@ -0,0 +1,38 @@ +msgid "" +msgstr "" +"Project-Id-Version: fi\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-02-20 13:28+0000\n" +"Last-Translator: Matti Niemelä \n" +"Language-Team: Finnish \n" +"Language: fi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.1-dev\n" +"Generated-By: pygettext.py 1.4\n" + +msgid "Date of Death" +msgstr "Kuolinpäivä" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "gramplet joka näyttää kuolinpäivät järjestettynä" +"gramplet joka näyttää kuolinpäivät järjestettynä kuukauden ja päivän mukaan" + +msgid "No Family Tree loaded." +msgstr "Ei sukupuuta ladattu." + +msgid "Sort dates of death by" +msgstr "Lajittele kuolinpäivämäärät" + +msgid "Month and day" +msgstr "Kuukausi ja päivä" + +msgid "Proximity to current date" +msgstr "Läheisyys nykyiseen päivämäärään" + +msgid "Processing..." +msgstr "Käsitellään…" diff --git a/DateOfDeathGramplet/po/fr-local.po b/DateOfDeathGramplet/po/fr-local.po new file mode 100644 index 0000000000..06b1c6e939 --- /dev/null +++ b/DateOfDeathGramplet/po/fr-local.po @@ -0,0 +1,36 @@ +msgid "" +msgstr "" +"Project-Id-Version: trunk\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2026-05-14 19:17+0000\n" +"Last-Translator: \"David D.\" \n" +"Language-Team: French \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n!=1);\n" +"X-Generator: Weblate 2026.5.dev0\n" + +msgid "Date of Death" +msgstr "Date de décès" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "un gramplet qui affiche les dates de décès triées" + +msgid "No Family Tree loaded." +msgstr "Aucun arbre généalogique chargé." + +msgid "Sort dates of death by" +msgstr "Trier les dates de décès par" + +msgid "Month and day" +msgstr "Mois et jour" + +msgid "Proximity to current date" +msgstr "Proximité à la date actuelle" + +msgid "Processing..." +msgstr "Traitement en cours…" diff --git a/DateOfDeathGramplet/po/he-local.po b/DateOfDeathGramplet/po/he-local.po new file mode 100644 index 0000000000..6fe19b9134 --- /dev/null +++ b/DateOfDeathGramplet/po/he-local.po @@ -0,0 +1,37 @@ +msgid "" +msgstr "" +"Project-Id-Version: Gramps 5.2.0 – mediamerge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-08-11 18:01+0000\n" +"Last-Translator: Avi Markovitz \n" +"Language-Team: Hebrew \n" +"Language: he\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && " +"n % 10 == 0) ? 2 : 3));\n" +"X-Generator: Weblate 5.13-dev\n" + +msgid "Date of Death" +msgstr "תאריך פטירה" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "גרמפלט המציג תאריכי פטירה ממוינים" + +msgid "No Family Tree loaded." +msgstr "לא נטען עץ משפחה." + +msgid "Sort dates of death by" +msgstr "מיין תאריכי פטירה לפי" + +msgid "Month and day" +msgstr "חודש ויום" + +msgid "Proximity to current date" +msgstr "קרבה לתאריך הנוכחי" + +msgid "Processing..." +msgstr "מעבד…" diff --git a/WordleGramplet/po/hr-local.po b/DateOfDeathGramplet/po/hr-local.po similarity index 52% rename from WordleGramplet/po/hr-local.po rename to DateOfDeathGramplet/po/hr-local.po index 5577ef922d..9d80f69a25 100644 --- a/WordleGramplet/po/hr-local.po +++ b/DateOfDeathGramplet/po/hr-local.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: Gramps 5.x\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" "PO-Revision-Date: 2025-03-02 14:58+0000\n" "Last-Translator: Milo Ivir \n" "Language-Team: Croatian =20) ? 1 : 2);\n" "X-Generator: Weblate 5.10.3-dev\n" -msgid "[Missing]" -msgstr "[Nedostaje]" +msgid "Date of Death" +msgstr "Datum smrti" -msgid "Number of font sizes" -msgstr "Broj veličina fonta" +msgid "a gramplet that displays death dates in sorted order" +msgstr "gramplet koji prikazuje datume smrti poredane" -msgid "Select filter to restrict list" -msgstr "Odaberi filtar za ograničavanje popisa" +msgid "No Family Tree loaded." +msgstr "Nije učitano obiteljsko stablo." -msgid "Wordle" -msgstr "Wordle" +msgid "Sort dates of death by" +msgstr "Poredaj datume smrti prema" -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet za izradu oblaka s riječima pomoću wordle.net" +msgid "Month and day" +msgstr "Mjesec i dan" + +msgid "Proximity to current date" +msgstr "Blizina trenutnog datuma" + +msgid "Processing..." +msgstr "Obrađujem…" diff --git a/DateOfDeathGramplet/po/hu-local.po b/DateOfDeathGramplet/po/hu-local.po new file mode 100644 index 0000000000..4e2a1578bc --- /dev/null +++ b/DateOfDeathGramplet/po/hu-local.po @@ -0,0 +1,38 @@ +msgid "" +msgstr "" +"Project-Id-Version: hu\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2026-02-21 14:09+0000\n" +"Last-Translator: Daniel Szollosi-Nagy \n" +"Language-Team: Hungarian \n" +"Language: hu\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 5.16.1-dev\n" + +msgid "Date of Death" +msgstr "Halálozási dátum" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "egy gramplet amely a halálozási dátumokat rendezve jeleníti meg" +"egy gramplet amely a halálozási dátumokat jeleníti meg hónap és nap szerint " +"rendezve" + +msgid "No Family Tree loaded." +msgstr "Nincs családfa betöltve." + +msgid "Sort dates of death by" +msgstr "A halálozás dátumának rendezése szerint" + +msgid "Month and day" +msgstr "Hónap és nap" + +msgid "Proximity to current date" +msgstr "Az aktuális dátum közelsége" + +msgid "Processing..." +msgstr "Feldolgozás…" diff --git a/DateOfDeathGramplet/po/it-local.po b/DateOfDeathGramplet/po/it-local.po new file mode 100644 index 0000000000..5268afef28 --- /dev/null +++ b/DateOfDeathGramplet/po/it-local.po @@ -0,0 +1,36 @@ +msgid "" +msgstr "" +"Project-Id-Version: gramps 3\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-09-06 11:01+0000\n" +"Last-Translator: Luigi Toscano \n" +"Language-Team: Italian \n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.14-dev\n" + +msgid "Date of Death" +msgstr "Data di morte" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "un gramplet che mostra le date di morte ordinate" + +msgid "No Family Tree loaded." +msgstr "Nessun albero genealogico caricato." + +msgid "Sort dates of death by" +msgstr "Ordina le date di morte per" + +msgid "Month and day" +msgstr "Mese e giorno" + +msgid "Proximity to current date" +msgstr "Prossimità alla data attuale" + +msgid "Processing..." +msgstr "Elaborazione in corso…" diff --git a/DateOfDeathGramplet/po/lt-local.po b/DateOfDeathGramplet/po/lt-local.po new file mode 100644 index 0000000000..2404535bef --- /dev/null +++ b/DateOfDeathGramplet/po/lt-local.po @@ -0,0 +1,40 @@ +msgid "" +msgstr "" +"Project-Id-Version: lt\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-08-27 08:02+0000\n" +"Last-Translator: Tadas Masiulionis \n" +"Language-Team: Lithuanian \n" +"Language: lt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"(n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Generator: Weblate 5.13\n" +"Generated-By: pygettext.py 1.4\n" +"X-Poedit-Language: Lithuanian\n" +"X-Poedit-Country: LITHUANIA\n" + +msgid "Date of Death" +msgstr "Mirties data" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "grampletas, rodantis mirties datas, surūšiuotas" + +msgid "No Family Tree loaded." +msgstr "Neįkeltas joks šeimos medis." + +msgid "Sort dates of death by" +msgstr "Rūšiuoti mirties datas pagal" + +msgid "Month and day" +msgstr "Mėnuo ir diena" + +msgid "Proximity to current date" +msgstr "Artumas iki dabartinės datos" + +msgid "Processing..." +msgstr "Apdorojama…" diff --git a/WordleGramplet/po/nb-local.po b/DateOfDeathGramplet/po/nb-local.po similarity index 51% rename from WordleGramplet/po/nb-local.po rename to DateOfDeathGramplet/po/nb-local.po index 0466f66036..ce32c6ae33 100644 --- a/WordleGramplet/po/nb-local.po +++ b/DateOfDeathGramplet/po/nb-local.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: nb\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" "PO-Revision-Date: 2026-06-01 12:35+0000\n" "Last-Translator: Harald Herreros \n" "Language-Team: Norwegian Bokmål \n" +"Language-Team: Dutch \n" +"Language: nl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.11-dev\n" + +msgid "Date of Death" +msgstr "Overlijdensdatum" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "een gramplet dat de overlijdensdata gesorteerd toont" + +msgid "No Family Tree loaded." +msgstr "Geen stamboom geladen." + +msgid "Sort dates of death by" +msgstr "Sorteer data van overlijden op" + +msgid "Month and day" +msgstr "Maand en dag" + +msgid "Proximity to current date" +msgstr "Nabijheid tot de huidige datum" + +msgid "Processing..." +msgstr "Bezig met verwerken…" diff --git a/DateOfDeathGramplet/po/pl-local.po b/DateOfDeathGramplet/po/pl-local.po new file mode 100644 index 0000000000..7112a05989 --- /dev/null +++ b/DateOfDeathGramplet/po/pl-local.po @@ -0,0 +1,41 @@ +msgid "" +msgstr "" +"Project-Id-Version: GRAMPS 3.1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-08-23 15:02+0000\n" +"Last-Translator: Krystian Safjan \n" +"Language-Team: Polish \n" +"Language: pl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " +"|| n%100>=20) ? 1 : 2);\n" +"X-Generator: Weblate 5.13\n" +"X-Poedit-Language: Polish\n" +"X-Poedit-Country: POLAND\n" +"X-Poedit-Basepath: .\n" +"X-Poedit-SearchPath-0: ~/.poedit/a\n" + +msgid "Date of Death" +msgstr "Data śmierci" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "gramplet wyświetlający daty śmierci w posortowanej kolejności" + +msgid "No Family Tree loaded." +msgstr "Nie załadowano drzewa genealogicznego." + +msgid "Sort dates of death by" +msgstr "Sortuj daty śmierci według" + +msgid "Month and day" +msgstr "Miesiąc i dzień" + +msgid "Proximity to current date" +msgstr "Bliskość aktualnej daty" + +msgid "Processing..." +msgstr "Przetwarzanie…" diff --git a/DateOfDeathGramplet/po/pt_BR-local.po b/DateOfDeathGramplet/po/pt_BR-local.po new file mode 100644 index 0000000000..917a57b127 --- /dev/null +++ b/DateOfDeathGramplet/po/pt_BR-local.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: trunk\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2012-08-26 20:57-0300\n" +"Last-Translator: André Marcelo Alvarenga \n" +"Language-Team: Brazilian Portuguese>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Lokalize 1.0\n" + +msgid "Date of Death" +msgstr "Data de falecimento" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "um gramplet que mostra as datas de falecimento ordenadas" + +msgid "No Family Tree loaded." +msgstr "Nenhuma árvore familiar carregada." + +msgid "Sort dates of death by" +msgstr "Classifique as datas da morte por" + +msgid "Month and day" +msgstr "Mês e dia" + +msgid "Proximity to current date" +msgstr "Proximidade da data atual" + +msgid "Processing..." +msgstr "Processando…" diff --git a/DateOfDeathGramplet/po/pt_PT-local.po b/DateOfDeathGramplet/po/pt_PT-local.po new file mode 100644 index 0000000000..eb31eef074 --- /dev/null +++ b/DateOfDeathGramplet/po/pt_PT-local.po @@ -0,0 +1,36 @@ +msgid "" +msgstr "" +"Project-Id-Version: gramps51\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-03-08 07:05+0000\n" +"Last-Translator: Pedro Albuquerque \n" +"Language-Team: Portuguese (Portugal) \n" +"Language: pt_PT\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.3-dev\n" + +msgid "Date of Death" +msgstr "Data de falecimento" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "um gramplet que mostra as datas de falecimento ordenadas" + +msgid "No Family Tree loaded." +msgstr "Nenhuma árvore genealógica carregada." + +msgid "Sort dates of death by" +msgstr "Classifique as datas da morte por" + +msgid "Month and day" +msgstr "Mês e dia" + +msgid "Proximity to current date" +msgstr "Proximidade da data atual" + +msgid "Processing..." +msgstr "A processar…" diff --git a/DateOfDeathGramplet/po/ru-local.po b/DateOfDeathGramplet/po/ru-local.po new file mode 100644 index 0000000000..e2dc56a80b --- /dev/null +++ b/DateOfDeathGramplet/po/ru-local.po @@ -0,0 +1,38 @@ +msgid "" +msgstr "" +"Project-Id-Version: gramps50\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2018-12-04 16:36+0300\n" +"Last-Translator: Ivan Komaritsyn \n" +"Language-Team: Russian\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Gtranslator 2.91.7\n" +"X-Poedit-Language: Russian\n" +"X-Poedit-Country: RUSSIAN FEDERATION\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" + +msgid "Date of Death" +msgstr "Дата смерти" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "грамплет, отображающий даты смерти в отсортированном порядке" + +msgid "No Family Tree loaded." +msgstr "Не загружено ни одного семейного древа." + +msgid "Sort dates of death by" +msgstr "Сортировать даты смерти по" + +msgid "Month and day" +msgstr "Месяц и день" + +msgid "Proximity to current date" +msgstr "Близость к текущей дате" + +msgid "Processing..." +msgstr "Обработка…" diff --git a/DateOfDeathGramplet/po/sk-local.po b/DateOfDeathGramplet/po/sk-local.po new file mode 100644 index 0000000000..ccdbb1554a --- /dev/null +++ b/DateOfDeathGramplet/po/sk-local.po @@ -0,0 +1,36 @@ +msgid "" +msgstr "" +"Project-Id-Version: GRAMPS 3.1.3\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2026-05-11 10:34+0000\n" +"Last-Translator: Milan \n" +"Language-Team: Slovak \n" +"Language: sk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);\n" +"X-Generator: Weblate 2026.5-dev\n" + +msgid "Date of Death" +msgstr "Dátum úmrtia" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "gramplet zobrazujúci dátumy úmrtia v zoradenom poradí" + +msgid "No Family Tree loaded." +msgstr "Nie je načítaný žiadny rodokmeň." + +msgid "Sort dates of death by" +msgstr "Zoradiť dátumy úmrtia podľa" + +msgid "Month and day" +msgstr "Mesiac a deň" + +msgid "Proximity to current date" +msgstr "Blízkosť k aktuálnemu dátumu" + +msgid "Processing..." +msgstr "Spracúvam…" diff --git a/DateOfDeathGramplet/po/sv-local.po b/DateOfDeathGramplet/po/sv-local.po new file mode 100644 index 0000000000..f8457f9616 --- /dev/null +++ b/DateOfDeathGramplet/po/sv-local.po @@ -0,0 +1,36 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-05-26 07:15+0000\n" +"Last-Translator: Pär Ekholm \n" +"Language-Team: Swedish \n" +"Language: sv\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.12-dev\n" + +msgid "Date of Death" +msgstr "Dödsdatum" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "en gramplet som visar dödsdatum sorterade" + +msgid "No Family Tree loaded." +msgstr "Inget släktträd laddat." + +msgid "Sort dates of death by" +msgstr "Sortera dödsdatum efter" + +msgid "Month and day" +msgstr "Månad och dag" + +msgid "Proximity to current date" +msgstr "Närhet till aktuellt datum" + +msgid "Processing..." +msgstr "Bearbetar…" diff --git a/DateOfDeathGramplet/po/template.pot b/DateOfDeathGramplet/po/template.pot new file mode 100644 index 0000000000..e744022d71 --- /dev/null +++ b/DateOfDeathGramplet/po/template.pot @@ -0,0 +1,39 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Date of Death" +msgstr "" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "" + +msgid "No Family Tree loaded." +msgstr "" + +msgid "Sort dates of death by" +msgstr "" + +msgid "Month and day" +msgstr "" + +msgid "Proximity to current date" +msgstr "" + +msgid "Processing..." +msgstr "" diff --git a/DateOfDeathGramplet/po/tr-local.po b/DateOfDeathGramplet/po/tr-local.po new file mode 100644 index 0000000000..7bda2f11d9 --- /dev/null +++ b/DateOfDeathGramplet/po/tr-local.po @@ -0,0 +1,39 @@ +msgid "" +msgstr "" +"Project-Id-Version: 4.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2026-05-30 20:01+0000\n" +"Last-Translator: Osman Öz \n" +"Language-Team: Turkish \n" +"Language: tr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 2026.6.dev0\n" +"Generated-By: pygettext.py 1.4\n" +"X-Language: tr\n" +"X-Source-Language: C\n" + +msgid "Date of Death" +msgstr "Ölüm tarihi" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "ölüm tarihlerini sıralı düzende gösteren bir gramplet" + +msgid "No Family Tree loaded." +msgstr "Hiçbir aile ağacı yüklenmedi." + +msgid "Sort dates of death by" +msgstr "Ölüm tarihlerini şuna göre sırala:" + +msgid "Month and day" +msgstr "Ay ve gün" + +msgid "Proximity to current date" +msgstr "Güncel tarihe yakınlık" + +msgid "Processing..." +msgstr "İşleniyor…" diff --git a/DateOfDeathGramplet/po/uk-local.po b/DateOfDeathGramplet/po/uk-local.po new file mode 100644 index 0000000000..34ede0e3e9 --- /dev/null +++ b/DateOfDeathGramplet/po/uk-local.po @@ -0,0 +1,37 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 00:00-0000\n" +"PO-Revision-Date: 2025-03-06 13:57+0000\n" +"Last-Translator: Yurii Liubymyi \n" +"Language-Team: Ukrainian \n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Generator: Weblate 5.10.3-dev\n" + +msgid "Date of Death" +msgstr "Дата смерті" + +msgid "a gramplet that displays death dates in sorted order" +msgstr "грамплет, який відображає дати смерті у відсортованому порядку" + +msgid "No Family Tree loaded." +msgstr "Не завантажено жодного родинного дерева." + +msgid "Sort dates of death by" +msgstr "Розсортуйте дати смерті за" + +msgid "Month and day" +msgstr "Місяць і день" + +msgid "Proximity to current date" +msgstr "Близькість до поточної дати" + +msgid "Processing..." +msgstr "Обробка…" diff --git a/FilterRules/activepersonrule.gpr.py b/FilterRules/activepersonrule.gpr.py index 6e0e1c65a6..12cb0c3918 100644 --- a/FilterRules/activepersonrule.gpr.py +++ b/FilterRules/activepersonrule.gpr.py @@ -26,7 +26,7 @@ id="ActivePerson", name=_("The active Person"), description=_("The active Person"), - version = '0.0.18', + version = '0.0.19', authors=["Paul Culley"], authors_email=["paulr2787@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/ageatdeath.gpr.py b/FilterRules/ageatdeath.gpr.py index 5409577a7e..6ec50f564e 100644 --- a/FilterRules/ageatdeath.gpr.py +++ b/FilterRules/ageatdeath.gpr.py @@ -24,7 +24,7 @@ id="ageatdeath", name=_("Filter people by their age at death"), description=_("Filter rule that matches people by their age at death"), - version = '1.0.19', + version = '1.0.20', authors=["Matthias Kemmer"], authors_email=["matt.familienforschung@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/associationsofpersonmatch.gpr.py b/FilterRules/associationsofpersonmatch.gpr.py index 2d06c54f03..e0abc867d2 100644 --- a/FilterRules/associationsofpersonmatch.gpr.py +++ b/FilterRules/associationsofpersonmatch.gpr.py @@ -24,7 +24,7 @@ id="associationsofpersonmatch", name=_("Match associations of "), description=_("Match associations of "), - version = '1.0.20', + version = '1.0.21', authors=["Matthias Kemmer"], authors_email=["matt.familienforschung@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/degreesofseparation.gpr.py b/FilterRules/degreesofseparation.gpr.py index 5d2be84489..5b17ea6e85 100644 --- a/FilterRules/degreesofseparation.gpr.py +++ b/FilterRules/degreesofseparation.gpr.py @@ -24,7 +24,7 @@ id="degreesofseparation", name=_("People separated less than degrees of "), description=_("Filter rule that matches relatives by degrees of " "separation"), - version = '1.1.19', + version = '1.1.20', authors=["Matthias Kemmer"], authors_email=["matt.familienforschung@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/familieswitheventfiltermatch.gpr.py b/FilterRules/familieswitheventfiltermatch.gpr.py index 5adc41a1e9..77444832f5 100644 --- a/FilterRules/familieswitheventfiltermatch.gpr.py +++ b/FilterRules/familieswitheventfiltermatch.gpr.py @@ -24,7 +24,7 @@ id="familieswitheventfiltermatch", name=_("Families matching "), description=_("Matches families that are matched by an event filter"), - version = '1.0.26', + version = '1.0.27', authors=["Matthias Kemmer"], authors_email=["matt.familienforschung@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/hasrolerule.gpr.py b/FilterRules/hasrolerule.gpr.py index 8efbd4204c..7bb7b4c106 100644 --- a/FilterRules/hasrolerule.gpr.py +++ b/FilterRules/hasrolerule.gpr.py @@ -26,7 +26,7 @@ id="HasPersonEventRole", name=_("People with events with a selected role"), description=_("Matches people with an event with a selected role"), - version = '0.0.31', + version = '0.0.32', authors=["Paul Culley"], authors_email=["paulr2787@gmail.com"], gramps_target_version="6.1", @@ -42,7 +42,7 @@ id="HasFamilyEventRole", name=_("Families with events with a selected role"), description=_("Matches families with an event with a selected role"), - version = '0.0.31', + version = '0.0.32', authors=["Paul Culley"], authors_email=["paulr2787@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/hassourcefilter.gpr.py b/FilterRules/hassourcefilter.gpr.py index 3ab286c3af..66d6565210 100644 --- a/FilterRules/hassourcefilter.gpr.py +++ b/FilterRules/hassourcefilter.gpr.py @@ -27,7 +27,7 @@ id="HasSourceParameter", name=_("Source matching parameters"), description=_("Matches Sources with values containing the chosen parameters"), - version = '0.0.31', + version = '0.0.32', authors=["Dave Scheipers", "Paul Culley"], authors_email=["paulr2787@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/infamilyrule.gpr.py b/FilterRules/infamilyrule.gpr.py index 18657cb034..424964aeb3 100644 --- a/FilterRules/infamilyrule.gpr.py +++ b/FilterRules/infamilyrule.gpr.py @@ -25,7 +25,7 @@ id="PersonsInFamilyFilterMatch", name=_("People who are part of families matching "), description=_("People who are part of families matching "), - version = '1.0.26', + version = '1.0.27', authors=["Matthias Kemmer", "Paul Culley"], authors_email=["matt.familienforschung@gmail.com", "paulr2787@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/isfamilyfiltermatchevent.gpr.py b/FilterRules/isfamilyfiltermatchevent.gpr.py index 2b55111993..06dca6e2ac 100644 --- a/FilterRules/isfamilyfiltermatchevent.gpr.py +++ b/FilterRules/isfamilyfiltermatchevent.gpr.py @@ -24,7 +24,7 @@ id="isfamilyfiltermatchevent", name=_("Events of families matching a "), description=_("Events of families matching a "), - version = '1.0.23', + version = '1.0.24', authors=["Matthias Kemmer"], authors_email=["matt.familienforschung@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/isfamilyfiltermatchevent.py b/FilterRules/isfamilyfiltermatchevent.py index 25e0997ee6..c533b1e23e 100644 --- a/FilterRules/isfamilyfiltermatchevent.py +++ b/FilterRules/isfamilyfiltermatchevent.py @@ -92,7 +92,9 @@ def prepare(self, db: Database, user): if self.MFF: for family in db.iter_families(): if self.MFF.apply_to_one(db, family): - self.events.update([e.ref for e in family.get_event_ref_list()]) + self.selected_handles.update( + [e.ref for e in family.get_event_ref_list()] + ) def apply_to_one(self, db: Database, event: Event) -> bool: """ diff --git a/FilterRules/isrelatedwithfiltermatch.gpr.py b/FilterRules/isrelatedwithfiltermatch.gpr.py index a3848a56b8..1b3e74c3a8 100644 --- a/FilterRules/isrelatedwithfiltermatch.gpr.py +++ b/FilterRules/isrelatedwithfiltermatch.gpr.py @@ -28,7 +28,7 @@ description=_( "Matches people who are related to anybody matched by " "a person filter" ), - version = '1.0.29', + version = '1.0.30', authors=["Matthias Kemmer"], authors_email=["matt.familienforschung@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/matcheventfilterrole.gpr.py b/FilterRules/matcheventfilterrole.gpr.py index 7b8d145119..848389ea1b 100644 --- a/FilterRules/matcheventfilterrole.gpr.py +++ b/FilterRules/matcheventfilterrole.gpr.py @@ -6,10 +6,10 @@ id="MatchEventFilterRole", name=_("People from event with role"), description=_("Matches people of event filter with role"), - version = '0.0.2', + version = '0.0.3', authors=["jjdup"], authors_email=["jeremi+gramps@dupin.fdn.fr"], - gramps_target_version="6.0", + gramps_target_version="6.1", status=STABLE, fname="matcheventfilterrole.py", ruleclass="MatchesEventFilterRole", # must be rule class name diff --git a/FilterRules/matchparentoffilterfamily.gpr.py b/FilterRules/matchparentoffilterfamily.gpr.py index 9f1dfb68c0..2f72065691 100644 --- a/FilterRules/matchparentoffilterfamily.gpr.py +++ b/FilterRules/matchparentoffilterfamily.gpr.py @@ -6,10 +6,10 @@ id="MatchParentOfFilterFamily", name=_("Parents of family filter"), description=_("Matches parent of family filter"), - version = '0.0.2', + version = '0.0.3', authors=["jjdup"], authors_email=["jeremi+gramps@dupin.fdn.fr"], - gramps_target_version="6.0", + gramps_target_version="6.1", status=STABLE, fname="matchparentoffilterfamily.py", ruleclass="MatchesParentOfFilterFamily", # must be rule class name diff --git a/FilterRules/matchpersonfilterrole.gpr.py b/FilterRules/matchpersonfilterrole.gpr.py index 014aef6f90..4b7e932f38 100644 --- a/FilterRules/matchpersonfilterrole.gpr.py +++ b/FilterRules/matchpersonfilterrole.gpr.py @@ -6,7 +6,7 @@ id="MatchPersonFilterRole", name=_("Events from people with role"), description=_("Matches event of people filter with role"), - version = '0.0.5', + version = '0.0.6', authors=[""], authors_email=[""], gramps_target_version="6.1", diff --git a/FilterRules/multipleparents.gpr.py b/FilterRules/multipleparents.gpr.py index e9cb4639cb..dded1ed929 100644 --- a/FilterRules/multipleparents.gpr.py +++ b/FilterRules/multipleparents.gpr.py @@ -26,7 +26,7 @@ id="multipleparents", name=_("Multiple Parents Filter"), description=_("Multiple Parents Filter"), - version = '0.0.18', + version = '0.0.19', authors=["Dave Scheipers"], authors_email=["dave.scheipers@gmail.com"], gramps_target_version="6.1", diff --git a/FilterRules/peopleeventscount.gpr.py b/FilterRules/peopleeventscount.gpr.py index 112a5f2159..62cfe0e27f 100644 --- a/FilterRules/peopleeventscount.gpr.py +++ b/FilterRules/peopleeventscount.gpr.py @@ -24,7 +24,7 @@ id="peopleeventscount", name=_("People with of "), description=_("Matches persons which have events of given type and number."), - version = '1.0.13', + version = '1.0.14', authors=["Matthias Kemmer"], authors_email=["matt.familienforschung@gmail.com"], gramps_target_version="6.1", diff --git a/WordleGramplet/tests/__init__.py b/FilterRules/tests/__init__.py similarity index 100% rename from WordleGramplet/tests/__init__.py rename to FilterRules/tests/__init__.py diff --git a/FilterRules/tests/test_isfamilyfiltermatchevent.py b/FilterRules/tests/test_isfamilyfiltermatchevent.py new file mode 100644 index 0000000000..3a1f9d9f80 --- /dev/null +++ b/FilterRules/tests/test_isfamilyfiltermatchevent.py @@ -0,0 +1,173 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Regression tests for the ``IsFamilyFilterMatchEvent`` filter rule. + +``prepare()`` used to update ``self.events``, an attribute never +defined anywhere on the class, instead of ``self.selected_handles`` +(the attribute it initializes and that ``apply_to_one()`` actually +checks). That raised ``AttributeError: 'IsFamilyFilterMatchEvent' +object has no attribute 'events'`` whenever the "Events of families +matching a " rule was applied, crashing the filter +entirely. See: +https://gramps.discourse.group/t/crash-of-an-event-filter-using-a-functional-family-filter/9733 +""" + +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import os +import shutil +import sys +import tempfile +import unittest + +# The addon imports Gtk at module load (via +# gramps.gui.editors.filtereditor). GTK/GDK are already pinned to 3.0 +# repo-wide by tests/__init__.py (PR #950); skip cleanly here if +# PyGObject isn't available at all. +try: + import gi +except ImportError as err: + raise unittest.SkipTest("PyGObject not available: %s" % err) + +# Addon root goes on sys.path so ``FilterRules.isfamilyfiltermatchevent`` +# resolves. The ``FilterRules`` directory lacks an __init__.py, so this +# relies on Python 3 namespace packages. +ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ADDON_DIR not in sys.path: + sys.path.insert(0, ADDON_DIR) + +try: + import gramps +except ImportError as err: + raise unittest.SkipTest("gramps package not available: %s" % err) + +if "GRAMPS_RESOURCES" not in os.environ: + os.environ["GRAMPS_RESOURCES"] = os.path.dirname(os.path.dirname(gramps.__file__)) + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gen.db import DbTxn +from gramps.gen.db.utils import make_database +from gramps.gen.lib import Event, EventRef, EventType, Family, FamilyRelType + +# CustomFilters starts out as None; it must be initialized before any +# module imports the name by value, since reload_custom_filters() +# rebinds the module-level global rather than mutating it in place. +from gramps.gen.filters import reload_custom_filters + +reload_custom_filters() +from gramps.gen.filters import CustomFilters, GenericFilterFactory +from gramps.gen.filters.rules.family import HasIdOf as FamilyHasIdOf +from gramps.cli.user import User + +from FilterRules.isfamilyfiltermatchevent import IsFamilyFilterMatchEvent + +FAMILY_FILTER_NAME = "_test_isfamilyfiltermatchevent_family_filter" + + +class IsFamilyFilterMatchEventTest(unittest.TestCase): + """Regression tests for IsFamilyFilterMatchEvent.prepare().""" + + def setUp(self): + """Build a database with two families, each with one event, and + register a custom Family filter matching only the first.""" + self.db_dir = tempfile.mkdtemp(prefix="isfamilyfiltermatchevent_") + self.db = make_database("sqlite") + self.db.load(self.db_dir) + + with DbTxn("build test db", self.db) as txn: + matched_event = Event() + matched_event.set_type(EventType(EventType.MARRIAGE)) + self.db.add_event(matched_event, txn) + self.matched_event_handle = matched_event.handle + + unmatched_event = Event() + unmatched_event.set_type(EventType(EventType.MARRIAGE)) + self.db.add_event(unmatched_event, txn) + self.unmatched_event_handle = unmatched_event.handle + + matched_family = Family() + matched_family.set_relationship(FamilyRelType(FamilyRelType.MARRIED)) + matched_ref = EventRef() + matched_ref.set_reference_handle(self.matched_event_handle) + matched_family.add_event_ref(matched_ref) + self.db.add_family(matched_family, txn) + self.matched_family_gramps_id = matched_family.gramps_id + + unmatched_family = Family() + unmatched_family.set_relationship(FamilyRelType(FamilyRelType.MARRIED)) + unmatched_ref = EventRef() + unmatched_ref.set_reference_handle(self.unmatched_event_handle) + unmatched_family.add_event_ref(unmatched_ref) + self.db.add_family(unmatched_family, txn) + + family_filter = GenericFilterFactory("Family")() + family_filter.set_name(FAMILY_FILTER_NAME) + family_filter.add_rule(FamilyHasIdOf([self.matched_family_gramps_id])) + CustomFilters.get_filters_dict("Family")[FAMILY_FILTER_NAME] = family_filter + + def tearDown(self): + del CustomFilters.get_filters_dict("Family")[FAMILY_FILTER_NAME] + self.db.close() + shutil.rmtree(self.db_dir, ignore_errors=True) + + def test_prepare_does_not_raise_attributeerror(self): + """prepare() must populate selected_handles, not crash on the + undefined self.events.""" + rule = IsFamilyFilterMatchEvent([FAMILY_FILTER_NAME]) + rule.requestprepare(self.db, User()) + self.assertEqual(rule.selected_handles, {self.matched_event_handle}) + + def test_apply_to_one_matches_only_expected_event(self): + """apply_to_one() must accept the matched family's event and + reject the unmatched family's event.""" + rule = IsFamilyFilterMatchEvent([FAMILY_FILTER_NAME]) + rule.requestprepare(self.db, User()) + matched_event = self.db.get_event_from_handle(self.matched_event_handle) + unmatched_event = self.db.get_event_from_handle(self.unmatched_event_handle) + self.assertTrue(rule.apply_to_one(self.db, matched_event)) + self.assertFalse(rule.apply_to_one(self.db, unmatched_event)) + + def test_full_filter_apply(self): + """Running the rule through a GenericFilter must return exactly + the matched family's event.""" + event_filter = GenericFilterFactory("Event")() + event_filter.add_rule(IsFamilyFilterMatchEvent([FAMILY_FILTER_NAME])) + results = set(event_filter.apply(self.db)) + self.assertEqual(results, {self.matched_event_handle}) + + def test_missing_family_filter(self): + """A rule referencing a nonexistent family filter must not + crash and must match nothing.""" + rule = IsFamilyFilterMatchEvent(["_no_such_filter_"]) + rule.requestprepare(self.db, User()) + self.assertEqual(rule.selected_handles, set()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Form/CensusCheckQuickview.gpr.py b/Form/CensusCheckQuickview.gpr.py index be1735549f..199445353f 100644 --- a/Form/CensusCheckQuickview.gpr.py +++ b/Form/CensusCheckQuickview.gpr.py @@ -8,7 +8,7 @@ id = 'censuscheckquickview', name = _("CensusCheck"), description= _("Check whether any Census events are missing for a person and some of their descendents"), - version = '1.0.7', + version = '1.0.8', gramps_target_version = '6.1', status = STABLE, fname = 'CensusCheckQuickview.py', @@ -22,7 +22,7 @@ id = 'censuscheckupquickview', name = _("CensusCheckUp"), description= _("Check whether any Census events are missing for a person and some of their ancestors"), - version = '1.0.7', + version = '1.0.8', gramps_target_version = '6.1', status = STABLE, fname = 'CensusCheckUpQuickview.py', diff --git a/Form/form.py b/Form/form.py index 940c92be1e..eba50fd667 100644 --- a/Form/form.py +++ b/Form/form.py @@ -48,7 +48,6 @@ # # --------------------------------------------------------------- from form_validator import ( - get_form_warnings, validate_form_dom, validate_form_element, ) @@ -200,9 +199,6 @@ def __load_file(self, full_path): "\n".join(errors), ) - for warning in get_form_warnings(dom): - LOG.warning("In %s: %s", full_path, warning) - try: self.__load_definitions(dom) finally: diff --git a/Form/form_validator.py b/Form/form_validator.py index 5b82cd98c2..4b82451de1 100644 --- a/Form/form_validator.py +++ b/Form/form_validator.py @@ -153,71 +153,6 @@ def validate_form_dom(dom: xml.dom.minidom.Document) -> list[str]: return errors -def get_form_warnings(dom: xml.dom.minidom.Document) -> list[str]: - """ - Collect non-fatal warnings about a parsed form definitions DOM. - - Warnings describe likely authoring mistakes that do not prevent the - form from loading. Currently covers Gramps bug 11010's observation - that a section's ```` ```` values are expected to sum - to 100 — sections that declare explicit sizes on every column but do - not sum to 100 are reported as warnings so callers can log them - without blocking the form from loading. - - Sections without any sized columns, or with only some columns - sized, are skipped because the intent is ambiguous. - - :param dom: a parsed ``xml.dom.minidom.Document`` - :returns: a list of human-readable warning messages; empty when - nothing questionable is detected - """ - warnings: list[str] = [] - top = dom.getElementsByTagName("forms") - if not top: - return warnings - - for form in top[0].getElementsByTagName("form"): - form_id = ( - form.attributes["id"].value - if "id" in form.attributes - else "" - ) - for section in form.getElementsByTagName("section"): - role = ( - section.attributes["role"].value - if "role" in section.attributes - else "" - ) - columns = section.getElementsByTagName("column") - if not columns: - continue - - sizes: list[int] = [] - all_sized = True - for column in columns: - size_nodes = column.getElementsByTagName("size") - if not size_nodes or not size_nodes[0].childNodes: - all_sized = False - break - try: - sizes.append(int(size_nodes[0].childNodes[0].data)) - except ValueError: - all_sized = False - break - if not all_sized: - continue - - total = sum(sizes) - if total != 100: - warnings.append( - "Form '%s': section '%s' column sizes sum to %d " - "(expected 100); form will still load but column " - "widths may not render as intended" - % (form_id, role, total) - ) - return warnings - - def parse_and_validate(path: str) -> tuple[xml.dom.minidom.Document | None, list[str]]: """ Parse ``path`` as XML and validate it against the form schema. diff --git a/Form/formgramplet.gpr.py b/Form/formgramplet.gpr.py index 2acbaf2a4f..f607bb3e95 100644 --- a/Form/formgramplet.gpr.py +++ b/Form/formgramplet.gpr.py @@ -31,7 +31,7 @@ name=_("Form Gramplet"), description=_("Gramplet interface for Forms"), status=STABLE, - version = '2.0.57', + version = '2.0.58', gramps_target_version="6.1", navtypes=["Person"], fname="formgramplet.py", diff --git a/Form/tests/test_form_validator.py b/Form/tests/test_form_validator.py index 0efc350ebe..4c9109af04 100644 --- a/Form/tests/test_form_validator.py +++ b/Form/tests/test_form_validator.py @@ -44,7 +44,6 @@ # ------------------------ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from form_validator import ( - get_form_warnings, parse_and_validate, split_family_title, validate_form_dom, @@ -394,125 +393,5 @@ def test_all_builtin_form_files_validate(self): ) -# --------------------------------------------------------------------------- -# get_form_warnings — non-fatal authoring warnings (Gramps bug 11010) -# --------------------------------------------------------------------------- -class TestGetFormWarnings(unittest.TestCase): - """ - Column sizes are expected to sum to 100. Rather than reject the - form, ``get_form_warnings`` flags suspect sections so the caller can - log them — 78 shipped definition files currently violate this rule - without breaking rendering, so escalating to an error would be - user-hostile. - """ - - def test_section_without_columns_has_no_warning(self): - dom = _dom_from_string(textwrap.dedent("""\ - -
-
- - - """)) - self.assertEqual(get_form_warnings(dom), []) - - def test_columns_without_any_size_have_no_warning(self): - dom = _dom_from_string(textwrap.dedent("""\ - -
-
- <_attribute>Name - <_attribute>Age -
-
-
- """)) - self.assertEqual(get_form_warnings(dom), []) - - def test_columns_summing_to_100_have_no_warning(self): - dom = _dom_from_string(textwrap.dedent("""\ - -
-
- <_attribute>A60 - <_attribute>B40 -
-
-
- """)) - self.assertEqual(get_form_warnings(dom), []) - - def test_columns_not_summing_to_100_emit_warning(self): - dom = _dom_from_string(textwrap.dedent("""\ - -
-
- <_attribute>A25 -
-
-
- """)) - warnings = get_form_warnings(dom) - self.assertEqual(len(warnings), 1) - self.assertIn("sum to 25", warnings[0]) - self.assertIn("F1", warnings[0]) - self.assertIn("Primary", warnings[0]) - - def test_partially_sized_columns_have_no_warning(self): - """ - When only some columns declare a ````, the author's intent - is ambiguous (mixed relative/absolute sizing) so the check is - skipped to avoid false positives. - """ - dom = _dom_from_string(textwrap.dedent("""\ - -
-
- <_attribute>A25 - <_attribute>B -
-
-
- """)) - self.assertEqual(get_form_warnings(dom), []) - - def test_warnings_are_independent_of_errors(self): - """ - Warnings are structurally orthogonal to errors: a malformed form - still produces warnings for its well-formed sibling. - """ - dom = _dom_from_string(textwrap.dedent("""\ - -
-
- -
-
- <_attribute>A30 -
-
- - """)) - warnings = get_form_warnings(dom) - self.assertEqual(len(warnings), 1) - self.assertIn("GOOD", warnings[0]) - - def test_multiple_misaligned_sections_all_reported(self): - dom = _dom_from_string(textwrap.dedent("""\ - -
-
- <_attribute>X40 -
-
- <_attribute>Y70 -
-
-
- """)) - warnings = get_form_warnings(dom) - self.assertEqual(len(warnings), 2) - - if __name__ == "__main__": unittest.main() diff --git a/Form/tests/test_integration_form.py b/Form/tests/test_integration_form.py index 82839c0cf1..ad00f394ae 100644 --- a/Form/tests/test_integration_form.py +++ b/Form/tests/test_integration_form.py @@ -43,7 +43,6 @@ # ------------------------ # Python modules # ------------------------ -import logging import os import shutil import sys @@ -259,51 +258,6 @@ def test_empty_forms_element_shows_error_dialog(self) -> None: self.assertEqual(list(instance.get_form_ids()), []) -# --------------------------------------------------------------------------- -# Column-size warnings (Gramps bug 11010 item b) — WARNING only, not errors -# --------------------------------------------------------------------------- -class TestColumnSizeWarnings(FormLoaderTestCase): - """ - Sections whose ```` sizes do not sum to 100 must be logged - as warnings only — 78 shipped forms trip this check, so escalating - to an ErrorDialog would harass users on every launch. - """ - - def test_column_size_sum_warning_is_logged_not_dialog(self) -> None: - """Column-size mismatch → WARNING log entry, no ErrorDialog.""" - self._write( - "custom.xml", - textwrap.dedent("""\ - -
-
- <_attribute>A25 -
-
-
- """), - ) - self._patch_definition_files(["custom.xml"]) - - with self.assertLogs(".FormGramplet", level=logging.WARNING) as log_ctx: - instance = self.form.Form(definition_dir=self.tmp_dir) - - self.assertFalse( - self.shown, - "column-size mismatch must not produce an ErrorDialog:\n" - + "\n".join("%s: %s" % (t, b) for t, b in self.shown), - ) - self.assertIn( - "F1", - list(instance.get_form_ids()), - "the form must still load despite the size mismatch", - ) - self.assertTrue( - any("sum to 25" in message for message in log_ctx.output), - "expected a column-size warning in the log", - ) - - # --------------------------------------------------------------------------- # Shipped files load cleanly # --------------------------------------------------------------------------- diff --git a/GrampsAssistant/grampsassistant.gpr.py b/GrampsAssistant/grampsassistant.gpr.py index 017817ce65..f7d70eacf9 100644 --- a/GrampsAssistant/grampsassistant.gpr.py +++ b/GrampsAssistant/grampsassistant.gpr.py @@ -25,7 +25,7 @@ id="grampsassistant", name=_("Gramps Assistant"), description=_("AI assistant for querying your Gramps family tree"), - version = '1.0.1', + version = '1.0.2', gramps_target_version="6.1", status=STABLE, fname="grampsassistant.py", @@ -36,4 +36,5 @@ optionclass="GrampsAssistantOptions", tool_modes=[TOOL_MODE_GUI], depends_on=["Grampy Script"], + help_url="Addon:GrampsAssistant", ) diff --git a/GrampsAssistant/grampsassistant.py b/GrampsAssistant/grampsassistant.py index 0d5589c05a..c7b05c9826 100644 --- a/GrampsAssistant/grampsassistant.py +++ b/GrampsAssistant/grampsassistant.py @@ -29,6 +29,7 @@ from gi.repository import GLib, Gdk, Gtk, Pango from gramps.gen.config import config as global_config +from gramps.gui.display import display_url try: from gramps.gui.sidepanel import BaseSidePanel @@ -44,6 +45,8 @@ _ = glocale.translation.gettext _LOG = logging.getLogger("gramps-assistant") +WIKI_PAGE = "https://gramps-project.org/wiki/index.php?title=Addon:GrampsAssistant" + # --------------------------------------------------------------------------- # Plugin-local configuration # --------------------------------------------------------------------------- @@ -213,6 +216,10 @@ def _build_ui(self): clear_btn.set_tooltip_text(_("Clear conversation and context")) clear_btn.connect("clicked", self._on_clear_clicked) + help_btn = Gtk.Button(label=_("Help")) + help_btn.set_tooltip_text(_("Open the Gramps Assistant wiki page")) + help_btn.connect("clicked", self._on_help_clicked) + self._send_btn = Gtk.Button(label=_("Send")) self._send_btn.connect("clicked", self._on_send_clicked) @@ -221,6 +228,7 @@ def _build_ui(self): btn_row.pack_start(settings_btn, False, False, 0) btn_row.pack_start(clear_btn, False, False, 0) + btn_row.pack_start(help_btn, False, False, 0) btn_row.pack_start(self._context_label, True, True, 4) btn_row.pack_end(self._send_btn, False, False, 0) @@ -707,6 +715,9 @@ def _on_clear_clicked(self, button): self._show_welcome() self._update_context_label() + def _on_help_clicked(self, button): + display_url(WIKI_PAGE) + # ------------------------------------------------------------------ # Message submission # ------------------------------------------------------------------ diff --git a/GrampsAssistant/tools.py b/GrampsAssistant/tools.py index 3f48a9c169..025937bb5e 100644 --- a/GrampsAssistant/tools.py +++ b/GrampsAssistant/tools.py @@ -1268,7 +1268,7 @@ def evaluate_expression(code: str) -> str: The same scope as execute_script is available: database, people(), families(), events(), places(), sources(), citations(), media(), notes(), repositories(), selected(), filtered(), - active_person, active_family, ..., today, counter() + custom_filter(), active_person, active_family, ..., today, counter() In addition, any Gramps lib class can be imported normally: from gramps.gen.lib import Person, Event, Date @@ -1313,6 +1313,9 @@ def execute_script(code: str) -> str: media(), notes(), repositories() -- all records of that type selected("Person") -- currently selected rows in the active view filtered("Person") -- currently filtered rows in the active view + custom_filter(name, namespace="Person") -- rows matching an existing + Gramps sidebar custom filter; prints a Warning and yields + nothing if no filter with that name exists for the namespace ("Person","Family","Event","Place","Source","Citation", "Media","Note","Repository" are valid table names) @@ -1332,6 +1335,8 @@ def execute_script(code: str) -> str: counter() -- defaultdict(int) for tallying begin_changes() -- open a DB transaction for edits end_changes() -- commit the transaction + delete(obj) -- delete a record (person, family, event, ...); + must be called between begin_changes() and end_changes() ## Person properties person.gramps_id -- "I0001" @@ -1389,6 +1394,12 @@ def execute_script(code: str) -> str: person.private = True # triggers auto-commit via callback end_changes() + begin_changes() + for repo in repositories(): + if not repo.back_references: + delete(repo) # deletes the record itself + end_changes() + Example -- people born before 1800: for person in people(): year = person.birth.get_date_object().get_year() diff --git a/GrampyScript/GrampyScript.gpr.py b/GrampyScript/GrampyScript.gpr.py index 20258e0dc0..34f77a285e 100644 --- a/GrampyScript/GrampyScript.gpr.py +++ b/GrampyScript/GrampyScript.gpr.py @@ -23,7 +23,7 @@ name=_("Gram.py Script"), description=_("Run a special Gramps Python script"), status=STABLE, - version = '0.0.7', + version = '0.0.9', fname="GrampyScript.py", authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], @@ -32,4 +32,5 @@ gramplet_title=_("Gram.py Script"), help_url="Addon:GrampyScript", height=800, + requires_mod=["jedi"], ) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index ace7a5645e..9f365634ea 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -19,7 +19,6 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import csv -import ast import keyword import datetime from collections import defaultdict @@ -34,6 +33,7 @@ from gi.repository import Gtk, Gdk, cairo, Pango from gramps.gen.db import DbTxn +from gramps.gen import filters as gramps_filters from gramps.gen.plug import Gramplet from gramps.gen.display.name import displayer as name_displayer from gramps.gen.display.place import displayer as place_displayer @@ -44,7 +44,8 @@ from gramps.gui.widgets.undoablebuffer import UndoableBuffer from gramps.gui.utils import match_primary_mask from gramps.gen.config import config as configman -from gramps.gui.dialog import OkDialog, ErrorDialog +from gramps.gui.dialog import OkDialog, ErrorDialog, SaveDialog +from gramps.gui.display import display_help, display_url from gramps.gui.editors import ( EditCitation, EditEvent, @@ -57,6 +58,10 @@ EditSource, ) from datadict2 import DataDict2, NoneData, set_sa +from script_descriptions import SCRIPT_DESCRIPTIONS +from script_utils import get_columns, extract_header_comment, SCRIPTS_DIR +from namespace_builder import build_namespace +from completion_popup import CompletionController _ = glocale.translation.gettext @@ -75,18 +80,6 @@ def contains_any_none_data(args): return not isinstance(args, NoneData) -def get_columns(source, func_name): - try: - tree = ast.parse(source) - for node in ast.walk(tree): - if isinstance(node, ast.Call): - if hasattr(node.func, "id") and node.func.id == func_name: - return [ast.unparse(arg) for arg in node.args] - except Exception: - pass - return [] - - class ScriptOpenFileChooserDialog(Gtk.FileChooserDialog): def __init__(self, uistate): # type: (DisplayState) -> None @@ -116,6 +109,37 @@ def __init__(self, uistate): filter_all.add_pattern("*.*") self.add_filter(filter_all) + self.preview_label = Gtk.Label() + self.preview_label.set_line_wrap(True) + self.preview_label.set_xalign(0) + self.preview_label.set_yalign(0) + preview_scrolled = Gtk.ScrolledWindow() + preview_scrolled.set_size_request(220, -1) + preview_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + preview_scrolled.add(self.preview_label) + preview_scrolled.show_all() + self.set_preview_widget(preview_scrolled) + self.connect("update-preview", self.on_update_preview) + + def on_update_preview(self, dialog): + filename = dialog.get_preview_filename() + text = "" + if filename and filename.endswith(".gram.py") and os.path.isfile(filename): + basename = os.path.basename(filename) + if basename in SCRIPT_DESCRIPTIONS: + title, description = SCRIPT_DESCRIPTIONS[basename] + text = "%s\n\n%s" % (title, description) + else: + try: + text = extract_header_comment(open(filename).read()) + except Exception: + text = "" + if text: + self.preview_label.set_text(text) + dialog.set_preview_widget_active(True) + else: + dialog.set_preview_widget_active(False) + class ScriptSaveFileChooserDialog(Gtk.FileChooserDialog): def __init__(self, uistate): @@ -255,6 +279,8 @@ def init(self): "events", "selected", "filtered", + "custom_filter", + "delete", ] self.constants = [ "True", @@ -279,14 +305,14 @@ def init(self): self.liststore = None self.text_length = 0 self.chart_data = None + self.converting_tabs = False self.gui.WIDGET = self.build_gui() self.gui.get_container_widget().remove(self.gui.textview) self.gui.get_container_widget().add(self.gui.WIDGET) + self.update_filename_label() if os.path.exists(self.last_filename): self.ebuf.set_text(open(self.last_filename).read()) - self.statusmsg.set_text("Loaded %r" % self.last_filename) else: - self.statusmsg.set_text("Current filename: %r" % self.last_filename) self.ebuf.set_text( """# This is a sample script @@ -294,6 +320,7 @@ def init(self): row(person) """ ) + self.ebuf.set_modified(False) def build_gui(self): """ @@ -309,15 +336,19 @@ def build_gui(self): openitem = Gtk.MenuItem(label=_("Open...")) save_item = Gtk.MenuItem(label=_("Save")) save_as_item = Gtk.MenuItem(label=_("Save as...")) + help_item = Gtk.MenuItem(label=_("Help")) filemenu.append(newitem) filemenu.append(openitem) filemenu.append(save_item) filemenu.append(save_as_item) + filemenu.append(Gtk.SeparatorMenuItem()) + filemenu.append(help_item) menubar.append(fileitem) newitem.connect("activate", self.new_script) openitem.connect("activate", self.open_script) save_as_item.connect("activate", self.save_as_script) save_item.connect("activate", self.save_script) + help_item.connect("activate", self.show_help) datamenu = Gtk.Menu() dataitem = Gtk.MenuItem(label=_("Data")) @@ -352,6 +383,7 @@ def build_gui(self): self.editor_textview.connect("key-press-event", self.on_key_press) self.editor_textview.connect("button-press-event", self.on_textview_click) + self.editor_textview.connect("focus-out-event", self.on_editor_focus_out) key, mods = Gtk.accelerator_parse("c") self.editor_textview.add_accelerator( "copy-clipboard", self.accel_group, key, mods, Gtk.AccelFlags.VISIBLE @@ -366,6 +398,8 @@ def build_gui(self): ) self.ebuf = UndoableBuffer() self.editor_textview.set_buffer(self.ebuf) + self.ebuf.connect_after("insert-text", self.on_insert_text_after) + self.ebuf.connect("modified-changed", self.on_modified_changed) self.keyword_tag = self.ebuf.create_tag( "keyword", foreground="blue", weight=700 ) @@ -378,10 +412,31 @@ def build_gui(self): self.comment_tag = self.ebuf.create_tag( "comment", foreground="gray", style=Pango.Style.ITALIC ) + self.string_tag = self.ebuf.create_tag("string", foreground="brown") self.ebuf.connect("changed", self.on_buffer_changed) + self.completion = CompletionController( + self.editor_textview, get_namespace=lambda: build_namespace(self.dbstate.db) + ) widget.pack_start(self.editor, True, True, 0) + bbox = Gtk.ButtonBox() + self.apply_button = Gtk.Button(label=_("Execute ")) + self.apply_button.connect("clicked", self.apply_clicked) + self.apply_button.set_tooltip_text(_("Execute the script")) + css = b"* {background: #00aa00; color: white}" + provider = Gtk.CssProvider() + try: + provider.load_from_data(css) + self.apply_button.get_style_context().add_provider( + provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + except: + pass + + bbox.pack_start(self.apply_button, False, False, 6) + widget.pack_start(bbox, False, False, 6) + self.notebook = Gtk.Notebook() self.page1 = Gtk.ScrolledWindow() @@ -402,52 +457,109 @@ def build_gui(self): widget.pack_start(self.notebook, True, True, 0) - bbox = Gtk.ButtonBox() - self.apply_button = Gtk.Button(label=_("Execute ")) - self.apply_button.connect("clicked", self.apply_clicked) - self.apply_button.set_tooltip_text(_("Execute the script")) - css = b"* {background: #00aa00; color: white}" - provider = Gtk.CssProvider() - try: - provider.load_from_data(css) - self.apply_button.get_style_context().add_provider( - provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - except: - pass - - bbox.pack_start(self.apply_button, False, False, 6) - widget.pack_start(bbox, False, False, 6) - - self.statusmsg = Gtk.Label(_("Ready...")) - self.statusmsg.set_xalign(0) # 0.0 for left, 0.5 for center, 1.0 for right - self.statusmsg.get_style_context().add_class('bordered-label') #add a css class css = b""" .bordered-label { border: 1px solid gray; padding: 1px; } + .bold-label { + font-weight: bold; + padding: 1px; + } """ provider = Gtk.CssProvider() provider.load_from_data(css) + + self.filename_label = Gtk.Label() + self.filename_label.set_xalign(0) + self.filename_label.get_style_context().add_class('bold-label') + self.filename_label.get_style_context().add_provider( + provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + self.statusmsg = Gtk.Label(_("Ready... (Tab for completions)")) + self.statusmsg.set_xalign(0) # 0.0 for left, 0.5 for center, 1.0 for right + # Some status messages embed the full path of the current file + # (e.g. "Loaded '/home/.../scripts/some_script.gram.py'"), which + # would otherwise force the whole gramplet wider than its + # container. Ellipsize and cap the natural width so it truncates + # instead -- max_width_chars is what actually bounds the natural + # size request; ellipsize alone only takes effect once allocated + # space is already smaller than that. + self.statusmsg.set_ellipsize(Pango.EllipsizeMode.MIDDLE) + self.statusmsg.set_max_width_chars(40) + self.statusmsg.get_style_context().add_class('bordered-label') #add a css class self.statusmsg.get_style_context().add_provider( provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) - widget.pack_start(self.statusmsg, False, False, 1) + + status_box = Gtk.HBox() + status_box.pack_start(self.filename_label, False, False, 1) + status_box.pack_start(self.statusmsg, True, True, 10) + widget.pack_start(status_box, False, False, 1) widget.show_all() return widget + def update_filename_label(self): + name = os.path.basename(self.last_filename) if self.last_filename else _("Untitled") + if self.ebuf.get_modified(): + name = "*" + name + self.filename_label.set_text(name) + + def on_modified_changed(self, buffer): + self.update_filename_label() + + def check_unsaved_changes(self, proceed): + """ + If the script has unsaved changes, ask the user whether to save, + discard, or cancel before calling `proceed`. Otherwise call + `proceed` immediately. + """ + if not self.ebuf.get_modified(): + proceed() + return + + def discard(): + proceed() + + def save_then_proceed(): + self.save_script(None) + if not self.ebuf.get_modified(): + proceed() + + SaveDialog( + _("Save Changes?"), + _( + "If you continue without saving, the changes you have " + "made to this script will be lost." + ), + discard, + save_then_proceed, + parent=self.uistate.window, + ) + def new_script(self, widget): # type: (Any) -> None + self.check_unsaved_changes(self._do_new_script) + + def _do_new_script(self): self.ebuf.set_text("") - self.statusmsg.set_text("Ready...") + self.ebuf.set_modified(False) + self.last_filename = "" + self.update_filename_label() + self.statusmsg.set_text(_("Ready... (Tab for completions)")) def open_script(self, widget): # type: (Gtk.Widget) -> None + self.check_unsaved_changes(self._do_open_script) + + def _do_open_script(self): choose_file_dialog = ScriptOpenFileChooserDialog(self.uistate) if self.last_filename: choose_file_dialog.set_filename(self.last_filename) + elif os.path.isdir(SCRIPTS_DIR): + choose_file_dialog.set_current_folder(SCRIPTS_DIR) while True: response = choose_file_dialog.run() @@ -458,11 +570,12 @@ def open_script(self, widget): elif response == Gtk.ResponseType.OK: filename = choose_file_dialog.get_filename() self.ebuf.set_text(open(filename).read()) + self.ebuf.set_modified(False) self.statusmsg.set_text("Script loaded") self.last_filename = filename config.set("defaults.last_filename", filename) config.save() - self.statusmsg.set_text("Loaded %r" % self.last_filename) + self.update_filename_label() break choose_file_dialog.destroy() @@ -473,7 +586,8 @@ def save_script(self, widget): return with open(self.last_filename, "w") as fp: fp.write(self.get_text()) - self.statusmsg.set_text("Saved %r" % self.last_filename) + self.ebuf.set_modified(False) + self.statusmsg.set_text("Saved") def save_as_script(self, widget): choose_file_dialog = ScriptSaveFileChooserDialog(self.uistate) @@ -483,6 +597,8 @@ def save_as_script(self, widget): choose_file_dialog.set_do_overwrite_confirmation(True) if self.last_filename: choose_file_dialog.set_filename(self.last_filename) + elif os.path.isdir(SCRIPTS_DIR): + choose_file_dialog.set_current_folder(SCRIPTS_DIR) while True: response = choose_file_dialog.run() @@ -494,14 +610,23 @@ def save_as_script(self, widget): filename = choose_file_dialog.get_filename() with open(filename, "w") as fp: fp.write(self.get_text()) + self.ebuf.set_modified(False) self.last_filename = filename config.set("defaults.last_filename", filename) config.save() - self.statusmsg.set_text("Saved as %r (now current)" % self.last_filename) + self.update_filename_label() + self.statusmsg.set_text("Saved as (now current)") break choose_file_dialog.destroy() + def show_help(self, widget): + help_url = self.gui.help_url + if help_url and help_url.startswith(("http://", "https://")): + display_url(help_url) + else: + display_help(help_url) + def save_csv(self, widget): if self.liststore is None: self.statusmsg.set_text("No data to save") @@ -573,6 +698,22 @@ def copy_to_clipboard(self, widget): def on_buffer_changed(self, buffer): self.highlight_syntax() + self.completion.on_buffer_changed() + + def on_insert_text_after(self, buffer, text_iter, text, length): + if "\t" not in text or self.converting_tabs: + return + self.converting_tabs = True + try: + end_offset = text_iter.get_offset() + start_offset = end_offset - len(text) + start = buffer.get_iter_at_offset(start_offset) + end = buffer.get_iter_at_offset(end_offset) + new_text = text.replace("\t", " ") + buffer.delete(start, end) + buffer.insert(buffer.get_iter_at_offset(start_offset), new_text) + finally: + self.converting_tabs = False def highlight_syntax(self): start_iter = self.ebuf.get_start_iter() @@ -581,37 +722,56 @@ def highlight_syntax(self): text = self.ebuf.get_text(start_iter, end_iter, True) + def inside_span(match, spans): + start_offset = match.start() + end_offset = match.end() + for span_start, span_end in spans: + if start_offset >= span_start and end_offset <= span_end: + return True + return False + + # Strings are found first so that keywords/comments inside them (eg. + # "with" in a docstring, or a "#" in a quoted string) aren't + # mistaken for code. + string_pattern = ( + r'"""[\s\S]*?"""' + r"|'''[\s\S]*?'''" + r'|"(?:[^"\\\n]|\\.)*"' + r"|'(?:[^'\\\n]|\\.)*'" + ) + string_matches = [] + for match in re.finditer(string_pattern, text): + start = self.ebuf.get_iter_at_offset(match.start()) + end = self.ebuf.get_iter_at_offset(match.end()) + self.ebuf.apply_tag(self.string_tag, start, end) + string_matches.append((match.start(), match.end())) + comment_matches = [] for match in re.finditer(r"#.*", text): + if inside_span(match, string_matches): + continue start = self.ebuf.get_iter_at_offset(match.start()) end = self.ebuf.get_iter_at_offset(match.end()) self.ebuf.apply_tag(self.comment_tag, start, end) comment_matches.append((match.start(), match.end())) - def inside_comment(match): - start_offset = match.start() - end_offset = match.end() - # Check if the keyword overlaps with a comment - for comment_start, comment_end in comment_matches: - if start_offset >= comment_start and end_offset <= comment_end: - return True - return False + skip_spans = string_matches + comment_matches for keyword in self.keywords: for match in re.finditer(r"\b" + keyword + r"\b", text): - if not inside_comment(match): + if not inside_span(match, skip_spans): start = self.ebuf.get_iter_at_offset(match.start()) end = self.ebuf.get_iter_at_offset(match.end()) self.ebuf.apply_tag(self.keyword_tag, start, end) for constant in self.constants: for match in re.finditer(r"\b" + constant + r"\b", text): - if not inside_comment(match): + if not inside_span(match, skip_spans): start = self.ebuf.get_iter_at_offset(match.start()) end = self.ebuf.get_iter_at_offset(match.end()) self.ebuf.apply_tag(self.constant_tag, start, end) for function in self.functions: for match in re.finditer(r"\b" + function + r"\b", text): - if not inside_comment(match): + if not inside_span(match, skip_spans): start = self.ebuf.get_iter_at_offset(match.start()) end = self.ebuf.get_iter_at_offset(match.end()) self.ebuf.apply_tag(self.function_tag, start, end) @@ -786,39 +946,156 @@ def pp(self, item): return str(item) def on_textview_click(self, widget, event): + self.completion.close() if event.button == 1: # Left mouse button widget.grab_focus() + def on_editor_focus_out(self, widget, event): + self.completion.close() + return False + def on_key_press(self, textview, event): - if event.keyval == Gdk.KEY_Tab: + keyval = event.keyval + shift_tab = keyval == Gdk.KEY_ISO_Left_Tab or ( + keyval == Gdk.KEY_Tab and (event.state & Gdk.ModifierType.SHIFT_MASK) + ) + + if shift_tab: + self.dedent_selection() + return True + + if keyval == Gdk.KEY_Tab and self.ebuf.get_has_selection(): + self.indent_selection() + return True + + if self.completion.on_key_press(event): + return True + + if keyval == Gdk.KEY_Tab: # buffer = textview.get_buffer() iter_ = self.ebuf.get_iter_at_mark(self.ebuf.get_insert()) self.ebuf.insert(iter_, " ") # Insert 4 spaces return True - elif event.keyval == Gdk.KEY_Return and ( - event.state & Gdk.ModifierType.MOD1_MASK - ): + elif keyval == Gdk.KEY_Return and (event.state & Gdk.ModifierType.MOD1_MASK): self.apply_button.emit("clicked") return True - elif event.keyval == Gdk.KEY_c and (event.state & Gdk.ModifierType.MOD1_MASK): + elif keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + self.insert_auto_indent_newline() + return True + + elif keyval == Gdk.KEY_c and (event.state & Gdk.ModifierType.MOD1_MASK): self.copy_selected_text() return True - elif (Gdk.keyval_name(event.keyval) == "Z") and match_primary_mask( + elif (Gdk.keyval_name(keyval) == "Z") and match_primary_mask( event.get_state(), Gdk.ModifierType.SHIFT_MASK ): self.redo() return True - elif (Gdk.keyval_name(event.keyval) == "z") and match_primary_mask( + elif (Gdk.keyval_name(keyval) == "z") and match_primary_mask( event.get_state() ): self.undo() return True + elif keyval == Gdk.KEY_slash and match_primary_mask(event.get_state()): + self.toggle_comment_selection() + return True + return False + def compute_indent_for_new_line(self, text_before_cursor): + stripped = text_before_cursor.rstrip() + indent = re.match(r"[ \t]*", text_before_cursor).group(0).replace("\t", " ") + if stripped.endswith(":"): + indent += " " + elif re.match(r"^[ \t]*(return|pass|break|continue|raise)\b", stripped): + if indent.endswith(" "): + indent = indent[:-4] + return indent + + def insert_auto_indent_newline(self): + buf = self.ebuf + it = buf.get_iter_at_mark(buf.get_insert()) + line_start = it.copy() + line_start.set_line_offset(0) + text_before_cursor = buf.get_text(line_start, it, True) + indent = self.compute_indent_for_new_line(text_before_cursor) + buf.insert_at_cursor("\n" + indent) + + def selection_line_bounds(self): + buf = self.ebuf + if buf.get_has_selection(): + sel_start, sel_end = buf.get_selection_bounds() + else: + it = buf.get_iter_at_mark(buf.get_insert()) + sel_start = sel_end = it + start = buf.get_iter_at_line(sel_start.get_line()) + end_line = sel_end.get_line() + if end_line > sel_start.get_line() and sel_end.get_line_offset() == 0: + # A drag-selection ending at column 0 of a line usually means + # the user didn't mean to touch that line. + end_line -= 1 + end = buf.get_iter_at_line(end_line) + end.forward_to_line_end() + return start, end + + def reindent_selection(self, transform): + buf = self.ebuf + start, end = self.selection_line_bounds() + start_offset = start.get_offset() + text = buf.get_text(start, end, True) + new_text = "\n".join(transform(line) for line in text.split("\n")) + if new_text == text: + return + buf.delete(start, end) + buf.insert(buf.get_iter_at_offset(start_offset), new_text) + new_start = buf.get_iter_at_offset(start_offset) + new_end = buf.get_iter_at_offset(start_offset + len(new_text)) + buf.select_range(new_start, new_end) + + def indent_selection(self): + self.reindent_selection(lambda line: " " + line) + + def dedent_selection(self): + def dedent(line): + if line.startswith(" "): + return line[4:] + if line.startswith("\t"): + return line[1:] + return line.lstrip(" ") + + self.reindent_selection(dedent) + + def toggle_comment_selection(self): + buf = self.ebuf + start, end = self.selection_line_bounds() + text = buf.get_text(start, end, True) + code_lines = [line for line in text.split("\n") if line.strip()] + all_commented = bool(code_lines) and all( + line.lstrip().startswith("#") for line in code_lines + ) + + def comment(line): + if not line.strip(): + return line + stripped = line.lstrip(" ") + indent = line[: len(line) - len(stripped)] + return indent + "# " + stripped + + def uncomment(line): + stripped = line.lstrip(" ") + indent = line[: len(line) - len(stripped)] + if stripped.startswith("# "): + return indent + stripped[2:] + if stripped.startswith("#"): + return indent + stripped[1:] + return line + + self.reindent_selection(uncomment if all_commented else comment) + def undo(self): self.ebuf.undo() self.text_length = len(self.get_text()) @@ -975,25 +1252,30 @@ def on_draw(self, widget, cr): min_val = min(data) if max_val == min_val: return - interval = (max_val - min_val) / self.chart_data[2] - buckets = [0] * (int(max_val / interval) + 1) + num_buckets = max(1, int(self.chart_data[2])) + interval = (max_val - min_val) / num_buckets + buckets = [0] * num_buckets for value in data: - if value > max_val: - buckets[int(max_val / interval)] += 1 - else: - buckets[int(value / interval)] += 1 + # Bucket index is the value's offset from min_val, not + # the raw value -- otherwise negative or non-zero-based + # data lands outside the buckets list. Clamp the top + # edge (value == max_val) into the last bucket rather + # than one past it. + idx = int((value - min_val) / interval) + if idx >= num_buckets: + idx = num_buckets - 1 + buckets[idx] += 1 labels = [] decimal_places = self.chart_data[3].get("decimal_places", 0) format = "%0." + str(decimal_places) + "f" - for i in range(int(max_val / interval)): - begin = format % (i * interval) - end = format % ((i + 1) * interval) + for i in range(num_buckets): + begin = format % (min_val + i * interval) + end = format % (min_val + (i + 1) * interval) if begin != end: labels.append(begin + "-" + end) else: labels.append(begin) - labels.append(format % ((i + 1) * interval,)) # Draw a bar chart with values bar_width = width / (len(buckets) * 1.5) @@ -1024,6 +1306,15 @@ def evaluate_expression(self, code): """Run code in the full GrampyScript scope and return stdout.""" return self.execute_code(code) + def ensure_import_paths(self): + """Make helper .py files next to scripts importable via `import`.""" + paths = [SCRIPTS_DIR] + if self.last_filename: + paths.append(os.path.dirname(os.path.abspath(self.last_filename))) + for path in paths: + if path and path not in sys.path: + sys.path.insert(0, path) + def execute_filename(self, filename): if os.path.exists(filename): with open(filename) as file: @@ -1044,11 +1335,12 @@ def begin_changes(message=_("Gram.py Script Edited Data")): self.CHANGING = True self.TRANSACTION = DbTxn(message, self.db) - self.db._txn_begin() + self.db.transaction_begin(self.TRANSACTION) def end_changes(): if self.CHANGING: - self.db._txn_commit() + self.db.transaction_commit(self.TRANSACTION) + self.CHANGING = False def _iter_raw_person_data(): for handle, data in self.db._iter_raw_person_data(): @@ -1109,7 +1401,7 @@ def columns(*column_names): active_source = self.get_active_data("Source") active_citation = self.get_active_data("Citation") active_place = self.get_active_data("Place") - active_event = self.get_active("Event") + active_event = self.get_active_data("Event") chart = self.chart @@ -1139,6 +1431,24 @@ def filtered(table_name): data = get_data(handle) yield DataDict2(dict(data), callback=self.callback) + def custom_filter(name, namespace="Person"): + if gramps_filters.CustomFilters is None: + gramps_filters.reload_custom_filters() + filt = gramps_filters.CustomFilters.get_filters_dict(namespace).get(name) + if filt is None: + print( + "Warning: no custom filter named %r for namespace %r" + % (name, namespace) + ) + return + get_data = self.db._get_table_func(namespace, "raw_func") + for handle in filt.apply(self.db): + yield DataDict2(dict(get_data(handle)), callback=self.callback) + + def delete(obj): + del_func = self.db._get_table_func(obj["_class"], "del_func") + del_func(obj["handle"], self.TRANSACTION) + database = self.db today = Date( @@ -1156,6 +1466,7 @@ def filtered(table_name): self.TRANSACTION = None self.output_buffer.set_text("") + self.ensure_import_paths() # ----------------- # User code # FIXME: don't use stdout? diff --git a/GrampyScript/MANIFEST b/GrampyScript/MANIFEST new file mode 100644 index 0000000000..09e11f74cb --- /dev/null +++ b/GrampyScript/MANIFEST @@ -0,0 +1,2 @@ +GrampyScript/scripts/*.gram.py +GrampyScript/scripts/README.md diff --git a/GrampyScript/completion.py b/GrampyScript/completion.py new file mode 100644 index 0000000000..d1b4abe401 --- /dev/null +++ b/GrampyScript/completion.py @@ -0,0 +1,136 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2025 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Command completion for the GrampyScript editor, built on jedi. + +Kept free of GTK imports so it can be developed and tested without a +running Gramps/GTK environment; GrampyScript.py is responsible for +turning a Gtk.TextBuffer cursor position into (line, column) and for +building the namespace of live/template DSL objects. +""" + +import jedi + +from stub_generator import build_stub_source + +_stub_source = None + + +def _get_stub_preamble(): + """ + Lazily build and cache the stub-class source (stub_generator.py): + static type annotations, derived from Gramps' own get_schema(), that + let jedi infer the row type of DSL generators like `people()` for a + user's own loop variable -- something no live namespace object can + provide, since there is no instance until the script actually runs. + """ + global _stub_source + if _stub_source is None: + _stub_source = build_stub_source() + return _stub_source + + +def _complete(source, line, column, namespace): + """Shared jedi call underlying both get_completions() and + get_completion_items(); returns raw jedi Completion objects, excluding + classes (jedi type "class"). The DSL has no use for instantiating + classes directly, and the stub preamble's own scaffold classes + (Person, Family, ...) would otherwise leak into the list -- they exist + only for jedi's static analysis and aren't bound to anything in the + namespace a script actually runs in, so offering them as completions + would suggest names that raise NameError if accepted.""" + preamble = _get_stub_preamble() + full_source = preamble + source + interpreter = jedi.Interpreter(full_source, [namespace]) + try: + completions = interpreter.complete(line + preamble.count("\n"), column) + except Exception: + return [] + return [completion for completion in completions if completion.type != "class"] + + +def _display_name(completion): + """Completion name for display: function/method completions (jedi type + "function", e.g. `people`, `print`) get "()" appended, so both + get_completions() and get_completion_items() consistently show a + callable as callable rather than as a bare, field-like name.""" + name = completion.name + if completion.type == "function": + name += "()" + return name + + +def get_completions(source, line, column, namespace): + """ + Return candidate completion names for `source` at the given cursor + position. `line`/`column` refer to `source` itself (1-indexed / + 0-indexed, jedi's convention, matching Gtk.TextIter's + get_line()+1 / get_line_offset()); the stub preamble prepended below + is accounted for internally. + + `namespace` is a plain dict of name -> live or template object, + e.g. {"active_person": DataDict2(...), "database": self.db}. + Attribute completion on dynamic objects (like DataDict2) relies on + those objects implementing __dir__ correctly, since jedi falls back + to runtime introspection (dir()/getattr()) for anything it can't + statically analyze. + """ + return [_display_name(completion) for completion in _complete(source, line, column, namespace)] + + +def _takes_arguments(completion): + """True if a function/method completion has at least one parameter + to fill in (jedi's Signature.params already excludes a bound + method's `self`), used to decide whether the inserted "()" should + land the cursor between the parens instead of after them.""" + try: + signatures = completion.get_signatures() + except Exception: + return False + return any(signature.params for signature in signatures) + + +def get_completion_items(source, line, column, namespace): + """ + Same as get_completions(), but for UI use: returns a list of + {"name": full completion name, "complete": text to insert at the + cursor, "cursor_offset": how many characters back from the end of + the inserted text the cursor should land} dicts. `name` is for + display; `complete` is only the remaining characters jedi says are + missing (e.g. typing "impo" and accepting "import" gives complete == + "rt"), so callers can insert it directly without + recomputing/re-typing the already-typed prefix. + + Function/method completions also get "()" appended to `complete`; + `cursor_offset` is then 1 for functions that take arguments, landing + the cursor between the parens ready to type them, or 0 for + no-argument functions, landing it after the closing paren. + """ + items = [] + for completion in _complete(source, line, column, namespace): + name = _display_name(completion) + complete = completion.complete + cursor_offset = 0 + if completion.type == "function": + complete += "()" + if _takes_arguments(completion): + cursor_offset = 1 + items.append({"name": name, "complete": complete, "cursor_offset": cursor_offset}) + return items diff --git a/GrampyScript/completion_popup.py b/GrampyScript/completion_popup.py new file mode 100644 index 0000000000..8ce9c66d10 --- /dev/null +++ b/GrampyScript/completion_popup.py @@ -0,0 +1,307 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2025 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +A Tab-triggered, live-filtering completion popover for a Gtk.TextView, +built on completion.get_completion_items(). + +Kept as a standalone controller (not part of GrampyScript.py's Gramplet +class) so it can be driven directly against a plain Gtk.TextView in +tests, independent of the full Gramps Gramplet machinery. + +Wiring it into a host widget requires forwarding four things: + textview "key-press-event" -> controller.on_key_press(event) + (if it returns True, treat the event + as handled and stop further processing) + buffer "changed" -> controller.on_buffer_changed() + textview "button-press-event"/"focus-out-event" -> controller.close() +""" + +import logging + +from gi.repository import Gdk, Gtk + +from completion import get_completion_items + +_LOG = logging.getLogger(".GrampyScript.completion") + +_NAVIGATION_KEYS = ( + Gdk.KEY_Left, + Gdk.KEY_Right, + Gdk.KEY_Home, + Gdk.KEY_End, + Gdk.KEY_Page_Up, + Gdk.KEY_Page_Down, +) + + +class CompletionController: + def __init__(self, textview, get_namespace): + """ + `textview`: the Gtk.TextView to attach completion to. + `get_namespace`: zero-arg callable returning the current + namespace dict for get_completion_items() (e.g. + `lambda: build_namespace(self.dbstate.db)`); called fresh on + every request so it always reflects the live database. + """ + self.textview = textview + self.buffer = textview.get_buffer() + self.get_namespace = get_namespace + self.popover = None + self.listbox = None + self.scrolled = None + self.items = [] + self.selected_index = 0 + + # ---- public event entry points ----------------------------------- + + def on_key_press(self, event): + """Return True if the event was consumed and should not be + processed any further by the caller.""" + try: + return self._on_key_press(event) + except Exception: + # Never let a bug here swallow the keypress entirely -- that + # would leave GTK's own default handler to run instead (e.g. + # inserting a literal tab character for Gdk.KEY_Tab), which + # looks like "completion silently does nothing." Log and + # fall back to "not handled" instead. + _LOG.exception("completion on_key_press failed") + self.close() + return False + + def _on_key_press(self, event): + keyval = event.keyval + _LOG.debug("on_key_press keyval=%s open=%s", Gdk.keyval_name(keyval), self.is_open()) + if keyval == Gdk.KEY_Tab: + if self.is_open(): + self.accept() + return True + return self.trigger() + if self.is_open(): + if keyval == Gdk.KEY_Up: + self.move_selection(-1) + return True + if keyval == Gdk.KEY_Down: + self.move_selection(1) + return True + if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + self.accept() + return True + if keyval == Gdk.KEY_Escape: + self.close() + return True + if keyval in _NAVIGATION_KEYS: + # The cursor is about to move out from under the popover; + # let it move normally, just stop completing at this spot. + self.close() + return False + return False + + def on_buffer_changed(self): + if not self.is_open(): + return + try: + self.refresh() + except Exception: + _LOG.exception("completion refresh failed") + self.close() + + def is_open(self): + return self.popover is not None + + # ---- core ----------------------------------------------------------- + + def _cursor_iter(self): + return self.buffer.get_iter_at_mark(self.buffer.get_insert()) + + def _cursor_line_column(self): + it = self._cursor_iter() + return it.get_line() + 1, it.get_line_offset() + + def _word_prefix(self): + it = self._cursor_iter() + start = it.copy() + while start.backward_char(): + ch = start.get_char() + if ch.isalnum() or ch == "_": + continue + start.forward_char() + break + return self.buffer.get_text(start, it, True) + + def _is_completable_context(self): + it = self._cursor_iter() + start = it.copy() + if not start.backward_char(): + _LOG.debug("not completable: at start of buffer") + return False + ch = start.get_char() + completable = ch.isalnum() or ch in "_.]" + _LOG.debug("preceding char=%r completable=%s", ch, completable) + return completable + + def _compute_items(self): + source = self.buffer.get_text( + self.buffer.get_start_iter(), self.buffer.get_end_iter(), True + ) + line, column = self._cursor_line_column() + prefix = self._word_prefix() + _LOG.debug("computing completions at line=%s column=%s prefix=%r", line, column, prefix) + try: + namespace = self.get_namespace() + items = get_completion_items(source, line, column, namespace) + except Exception: + _LOG.exception("building completion namespace/items failed") + return [] + if not prefix.startswith("_"): + items = [item for item in items if not item["name"].startswith("_")] + _LOG.debug("found %d completion(s): %s", len(items), [i["name"] for i in items[:10]]) + if not items: + _LOG.debug("zero completions, full source was:\n%s", source) + return items + + def trigger(self): + """Try to complete at the cursor. Returns True if there was + something completable to show. A single match is inserted + directly instead of opening a popover with one row in it; + multiple matches open the popover as usual.""" + if not self._is_completable_context(): + return False + items = self._compute_items() + if not items: + _LOG.debug("trigger: no completions, falling back to default Tab behavior") + return False + self.items = items + self.selected_index = 0 + if len(items) == 1: + _LOG.debug("trigger: single match, inserting directly: %s", items[0]["name"]) + self.accept() + return True + self._open_popover() + return True + + def refresh(self): + """Recompute matches for an already-open popover, following the + cursor as the user keeps typing. Closes if nothing matches + anymore.""" + if not self._is_completable_context(): + self.close() + return + items = self._compute_items() + if not items: + self.close() + return + self.items = items + self.selected_index = min(self.selected_index, len(items) - 1) + self._rebuild_listbox() + self._reposition() + + def move_selection(self, delta): + if not self.items: + return + self.selected_index = max(0, min(len(self.items) - 1, self.selected_index + delta)) + self._update_row_selection() + + def accept(self): + if self.items: + item = self.items[self.selected_index] + self.buffer.insert(self._cursor_iter(), item["complete"]) + offset = item.get("cursor_offset", 0) + if offset: + it = self._cursor_iter() + it.backward_chars(offset) + self.buffer.place_cursor(it) + self.close() + + def close(self): + if self.popover is not None: + self.popover.destroy() + self.popover = None + self.listbox = None + self.scrolled = None + self.items = [] + self.selected_index = 0 + + # ---- widget building -------------------------------------------------- + + def _open_popover(self): + self.popover = Gtk.Popover() + self.popover.set_relative_to(self.textview) + self.popover.set_modal(False) + self.popover.set_position(Gtk.PositionType.BOTTOM) + + self.scrolled = Gtk.ScrolledWindow() + self.scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.scrolled.set_max_content_height(200) + self.scrolled.set_propagate_natural_height(True) + + self.listbox = Gtk.ListBox() + self.listbox.set_activate_on_single_click(True) + self.listbox.connect("row-activated", self._on_row_activated) + self.scrolled.add(self.listbox) + self.popover.add(self.scrolled) + + self._rebuild_listbox() + self.popover.show_all() + self._reposition() + self.popover.popup() + + def _rebuild_listbox(self): + for child in self.listbox.get_children(): + self.listbox.remove(child) + for item in self.items: + label = Gtk.Label(label=item["name"], xalign=0) + label.set_margin_start(6) + label.set_margin_end(6) + row = Gtk.ListBoxRow() + row.add(label) + self.listbox.add(row) + self.listbox.show_all() + self._update_row_selection() + + def _update_row_selection(self): + row = self.listbox.get_row_at_index(self.selected_index) + if row is not None: + self.listbox.select_row(row) + self._scroll_to_row(row) + + def _scroll_to_row(self, row): + alloc = row.get_allocation() + adj = self.scrolled.get_vadjustment() + if alloc.y < adj.get_value(): + adj.set_value(alloc.y) + elif alloc.y + alloc.height > adj.get_value() + adj.get_page_size(): + adj.set_value(alloc.y + alloc.height - adj.get_page_size()) + + def _reposition(self): + rect = self.textview.get_iter_location(self._cursor_iter()) + x, y = self.textview.buffer_to_window_coords( + Gtk.TextWindowType.WIDGET, rect.x, rect.y + ) + pointing = Gdk.Rectangle() + pointing.x = x + pointing.y = y + pointing.width = 1 + pointing.height = rect.height + self.popover.set_pointing_to(pointing) + + def _on_row_activated(self, listbox, row): + self.selected_index = row.get_index() + self.accept() diff --git a/GrampyScript/datadict2.py b/GrampyScript/datadict2.py index 37df19c3a3..2970365cfd 100644 --- a/GrampyScript/datadict2.py +++ b/GrampyScript/datadict2.py @@ -27,10 +27,21 @@ from __future__ import annotations from gramps.gen.lib.json_utils import data_to_object, object_to_dict -from gramps.gen.lib import PrimaryObject +from gramps.gen.lib import PrimaryObject, GrampsType from gramps.gen.config import config NoneType = type(None) + +# *Ref._class -> the table its `ref` handle points into, for the `reference` +# property below. +REFERENCE_TABLES = { + "ChildRef": "Person", + "EventRef": "Event", + "MediaRef": "Media", + "PersonRef": "Person", + "PlaceRef": "Place", + "RepoRef": "Repository", +} invalid_date_format = config.get("preferences.invalid-date-format") age_precision = config.get("preferences.age-display-precision") age_after_death = config.get("preferences.age-after-death") @@ -260,7 +271,17 @@ def events(self): @property def reference(self): - return DataDict2(sa.dbase.get_raw_person_data(self.ref), callback=self.callback) + # self is one of the *Ref wrapper types (PersonRef, EventRef, ...); + # `ref` is a handle into whichever table its own _class points at, + # not always Person. + table = REFERENCE_TABLES.get(self["_class"]) + if table is None: + return NoneData() + getter = sa.dbase.method("get_raw_%s_data", table) + data = getter(self.ref) if getter else None + if data is None: + return NoneData() + return DataDict2(data, callback=self.callback) @property def attributes(self): @@ -298,33 +319,50 @@ def back_references_recursively(self): def name(self): if self["_class"] == "Person": return self.primary_name - else: - return self["name"] + return self["name"] if "name" in self else NoneData() @property def surname(self): if self["_class"] == "Person": return self.primary_name.surname_list[0] - else: - return self["surname"] + return self["surname"] if "surname" in self else NoneData() @property def names(self): - return DataList2([self.primary_name] + [self.alternate_names]) + return DataList2([self.primary_name] + self.alternate_names) + + @property + def string(self): + # The raw "string" field only holds the *custom*-type override text + # for GrampsType-based values (NameOriginType, NameType, EventType, + # ...); for predefined values (the common case) it is always "". + # Route to the real object's `.string` property instead, which + # computes the actual (translated) label. Raising AttributeError + # for anything without a "string" field falls back to the normal + # __getattr__ lookup, so this doesn't change behavior elsewhere. + if "string" not in self: + raise AttributeError("string") + if isinstance(self._object, GrampsType): + return str(self._object) + return self["string"] + + def _real_object(self): + """Walk from the root's real object down self.path to find the + actual (not reconstructed) object that this wrapper represents.""" + obj = self.root._object + for part in self.path: + if isinstance(part, int): + obj = obj[part] + else: + obj = getattr(obj, part) + return obj def __setattr__(self, attr, value): if attr in ["root", "path", "callback"]: return super().__setattr__(attr, value) else: - # Follow the path: - obj = self.root._object - for part in self.path: - if isinstance(part, int): - obj = obj[part] - else: - obj = getattr(obj, part) # Set it in the real _object: - setattr(obj, attr, value) + setattr(self._real_object(), attr, value) # Update the top-level dict: self.root.update(object_to_dict(self.root._object)) # Call the callback @@ -334,10 +372,24 @@ def __setattr__(self, attr, value): # def __str__(self): # return str(self._object) + def __dir__(self): + # Merge real class attributes with the dynamic dict keys, so that + # introspection tools (e.g. jedi-based completion) can see fields + # like `primary_name` that only exist via __getattr__. + return sorted(set(super().__dir__()) | set(self.keys())) + def __getattr__(self, key): if key == "_object": if "_object" not in self: - self["_object"] = data_to_object(self) + # A nested wrapper (non-empty path) must resolve to the + # actual sub-object inside the root's real object tree, + # not a standalone copy reconstructed from its own dict + # slice -- otherwise set_*() calls below mutate a clone + # that is discarded instead of the real, committed object. + if self.path: + self["_object"] = self._real_object() + else: + self["_object"] = data_to_object(self) return self["_object"] elif key.startswith("_"): raise AttributeError( @@ -391,7 +443,14 @@ def __setitem__(self, position, value): raise Exception("Setting a DataList2 item is not allowed") def __getattr__(self, attr): - # return DataList2(flatten([getattr(x, attr) for x in self])) + if attr.startswith("set_"): + # Fan the call (same args) out to every item, rather than + # collecting each item's set_*() wrapper closure unevaluated + # (which isn't callable itself and silently did nothing). + def wrapper(*args, **kwargs): + return DataList2([getattr(x, attr)(*args, **kwargs) for x in self]) + + return wrapper return DataList2(flatten([getattr(x, attr) for x in self])) def __getitem__(self, position, root=None, path=""): @@ -399,7 +458,14 @@ def __getitem__(self, position, root=None, path=""): value = super().__getitem__(position) except Exception: return NoneData() - if isinstance(value, dict): + # Items can already be fully-wrapped (e.g. after `+`/`__radd__` + # concatenates lists whose elements are DataDict2/DataList2). Since + # both subclass dict/list, re-wrapping them here would discard their + # real root/path and replace it with this list's (often unrelated) + # root, corrupting later attribute assignment. + if isinstance(value, (DataDict2, DataList2)): + return value + elif isinstance(value, dict): return DataDict2(value, root=self.root, path=self.path + [position]) elif isinstance(value, list): return DataList2(value, root=self.root, path=self.path + [position]) @@ -422,7 +488,10 @@ def __add__(self, value): return DataList2([x for x in self] + [x for x in value]) def __radd__(self, value): - return DataList2([x for x in self] + [x for x in value]) + # self is the right-hand operand here (Python calls b.__radd__(a) + # for `a + b`), so the result must be `value + self`, not `self + + # value` -- otherwise `plain_list + data_list2` comes out reversed. + return DataList2([x for x in value] + [x for x in self]) sa = None diff --git a/GrampyScript/namespace_builder.py b/GrampyScript/namespace_builder.py new file mode 100644 index 0000000000..9bf9dec2b6 --- /dev/null +++ b/GrampyScript/namespace_builder.py @@ -0,0 +1,59 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2025 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Builds the runtime completion namespace: real live objects for the DSL +names whose fields are safe and useful to introspect directly. + +active_person/active_family/etc. are deliberately NOT included here -- +see stub_generator.ACTIVE_VARIABLES. They're handled as static +annotations instead, because completing through them would otherwise +require jedi to actually call DataDict2's computed @property methods +(father, birth, ...) to see what they return, executing real +SimpleAccess lookups for no benefit (a blank template has nothing to +find anyway). + +Kept free of GTK imports so it can be developed and tested without a +running Gramps/GTK environment. +""" + +import datetime +from collections import defaultdict + +from gramps.gen.lib import Date + + +def build_namespace(database=None): + """ + Return a namespace dict for get_completions(), covering the + directly-bound DSL names in execute_code() that are safe to + introspect as live objects: today, counter, and database. + + `database` is the real Gramps database (self.dbstate.db). Passing it + enables completion of its real methods (database.get_person_from_handle, + ...); it's optional since dir() on it never executes anything. + """ + today = datetime.date.today() + namespace = { + "today": Date(today.year, today.month, today.day), + "counter": lambda: defaultdict(int), + } + if database is not None: + namespace["database"] = database + return namespace diff --git a/GrampyScript/script_descriptions.py b/GrampyScript/script_descriptions.py new file mode 100644 index 0000000000..1e35346d3f --- /dev/null +++ b/GrampyScript/script_descriptions.py @@ -0,0 +1,149 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2025 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Translatable titles and descriptions for the bundled example scripts in +scripts/. This file is generated -- do not hand-edit the SCRIPT_DESCRIPTIONS +dict below. Each entry's title comes from the corresponding script's leading +'# Title' comment, and its description comes from that script's module +docstring; both are kept in the .gram.py files themselves so the source of +truth lives next to the code it documents. + +Wrapping these plain-text strings in _() here (rather than in the .gram.py +files) is what makes them picked up by the addon's normal xgettext-based +translation pipeline (see ../make.py) while keeping the example scripts +themselves free of gettext markup. + +To pick up a new script, or a script's changed title/docstring, run: + + python3 update_script_descriptions.py +""" + +from gramps.gen.const import GRAMPS_LOCALE as glocale + +_ = glocale.translation.gettext + +SCRIPT_DESCRIPTIONS = { + "01_list_people.gram.py": ( + _("List All People"), + _( + "Iterate over every person in the database and show their Gramps " + "ID, given name, surname, and gender in the results table." + ), + ), + "02_filter_by_surname.gram.py": ( + _("Filter By Surname"), + _( + "List only the people whose surname matches a given value — a " + "starting point for narrowing any report down by a condition." + ), + ), + "03_family_overview.gram.py": ( + _("Family Overview"), + _( + "List every family together with the father, the mother, and how " + "many children they have — a quick way to spot families that look " + "incomplete." + ), + ), + "04_gender_pie_chart.gram.py": ( + _("Gender Breakdown (Pie Chart)"), + _( + "Count how many people are male, female, or of unknown gender, " + "then draw a pie chart of the totals. Check the Chart tab after " + "running." + ), + ), + "05_age_histogram.gram.py": ( + _("Age At Death Histogram"), + _( + "For everyone with both a birth and a death event recorded, " + "compute their age in whole years and draw a histogram of the " + "distribution. Check the Chart tab after running." + ), + ), + "06_mark_unsourced_people_private.gram.py": ( + _("Mark Unsourced People As Private"), + _( + "Batch-edit example: find every person who has no citations " + "attached and flag them as private, wrapped in " + "begin_changes()/end_changes() so the edits happen inside a " + "single, undoable transaction." + ), + ), + "07_csv_ready_report.gram.py": ( + _("CSV-Ready People Report"), + _( + "Build a simple tabular report — ID, name, gender, birth year — " + "for every person. Once it runs, use Data > Save as CSV or Copy to " + "clipboard to export the Table tab's contents." + ), + ), + "08_active_person_summary.gram.py": ( + _("Active Person Summary"), + _( + "Show a compact family summary for the currently active person: " + "their record, parents, spouse, and children." + ), + ), + "09_selected_people_report.gram.py": ( + _("Report On Selected People"), + _( + "List just the people currently selected (highlighted) in the " + "People view. Select some rows in the People view before running " + "this script." + ), + ), + "10_find_missing_birth_dates.gram.py": ( + _("Find People Missing A Birth Date"), + _( + "Data-quality check: list every person who has no recorded birth " + "event, so you can prioritize research on those records." + ), + ), + "11_import_example.gram.py": ( + _("Births Per Decade (Import Example)"), + _( + "Counts births by decade, using a decade() function imported from " + "script_helpers.py in this same folder — a template for sharing " + "helper code between your own scripts with a plain 'import' " + "statement." + ), + ), + "12_custom_filter_example.gram.py": ( + _("Custom Filter Example"), + _( + "Runs one of your own custom filters (from the Filters " + "gramplet/editor) by name using custom_filter(). Change 'example " + "filter' to the name of a filter you've already created; if the " + "name doesn't match one, a warning shows up in the Output tab " + "instead." + ), + ), + "13_delete_unused_repositories.gram.py": ( + _("Delete Unused Repositories (Delete Example)"), + _( + "Demonstrates delete(): removes any Repository record that nothing " + "else in the tree refers to. Most trees have no unused " + "repositories, so this is unlikely to actually delete anything — " + "it's meant to show the pattern, wrapped in " + "begin_changes()/end_changes() as a single undoable transaction." + ), + ), +} diff --git a/GrampyScript/script_utils.py b/GrampyScript/script_utils.py new file mode 100644 index 0000000000..9ae2032dee --- /dev/null +++ b/GrampyScript/script_utils.py @@ -0,0 +1,59 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2025 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Pure-Python helpers shared by GrampyScript.py, update_script_descriptions.py, +and the test suite. Kept free of GTK/Gramps imports so they can be used +from a plain script or test without needing a GUI environment. +""" + +import ast +import os + +SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts") + + +def get_columns(source, func_name): + try: + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + if hasattr(node.func, "id") and node.func.id == func_name: + return [ast.unparse(arg) for arg in node.args] + except Exception: + pass + return [] + + +def extract_header_comment(source): + """ + Extract the leading '#'-comment block of a script as plain text, + for use as a fallback preview when a file has no catalogued + description in SCRIPT_DESCRIPTIONS. + """ + lines = [] + for line in source.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + lines.append(stripped.lstrip("#").strip()) + elif stripped == "" and not lines: + continue + else: + break + return "\n".join(lines).strip() diff --git a/GrampyScript/scripts/01_list_people.gram.py b/GrampyScript/scripts/01_list_people.gram.py new file mode 100644 index 0000000000..4bcd8a2a6a --- /dev/null +++ b/GrampyScript/scripts/01_list_people.gram.py @@ -0,0 +1,12 @@ +# List All People +""" +Iterate over every person in the database and show their Gramps ID, given name, +surname, and gender in the results table. +""" + +for person in people(): + row( + person, + person.gender, + person.age, + ) diff --git a/GrampyScript/scripts/02_filter_by_surname.gram.py b/GrampyScript/scripts/02_filter_by_surname.gram.py new file mode 100644 index 0000000000..4107b61477 --- /dev/null +++ b/GrampyScript/scripts/02_filter_by_surname.gram.py @@ -0,0 +1,11 @@ +# Filter By Surname +""" +List only the people whose surname matches a given value — a starting point for +narrowing any report down by a condition. +""" + +TARGET_SURNAME = "Smith" + +for person in people(): + if person.surname.surname == TARGET_SURNAME: + row(person.gramps_id, person.name.first_name, person.surname.surname) diff --git a/GrampyScript/scripts/03_family_overview.gram.py b/GrampyScript/scripts/03_family_overview.gram.py new file mode 100644 index 0000000000..ea8394bbb6 --- /dev/null +++ b/GrampyScript/scripts/03_family_overview.gram.py @@ -0,0 +1,8 @@ +# Family Overview +""" +List every family together with the father, the mother, and how many children +they have — a quick way to spot families that look incomplete. +""" + +for family in families(): + row(family.gramps_id, family.father, family.mother, len(family.children)) diff --git a/GrampyScript/scripts/04_gender_pie_chart.gram.py b/GrampyScript/scripts/04_gender_pie_chart.gram.py new file mode 100644 index 0000000000..4fe1a51201 --- /dev/null +++ b/GrampyScript/scripts/04_gender_pie_chart.gram.py @@ -0,0 +1,11 @@ +# Gender Breakdown (Pie Chart) +""" +Count how many people are male, female, or of unknown gender, then draw a pie +chart of the totals. Check the Chart tab after running. +""" + +counts = counter() +for person in people(): + counts[person.gender] += 1 + +chart("pie", counts) diff --git a/GrampyScript/scripts/05_age_histogram.gram.py b/GrampyScript/scripts/05_age_histogram.gram.py new file mode 100644 index 0000000000..b959a75877 --- /dev/null +++ b/GrampyScript/scripts/05_age_histogram.gram.py @@ -0,0 +1,14 @@ +# Age At Death Histogram +""" +For everyone with both a birth and a death event recorded, compute their age in +whole years and draw a histogram of the distribution. Check the Chart tab after +running. +""" + +ages = [] +for person in people(): + age = person.age + if age: + ages.append(age.tuple()[0]) + +chart("histogram", ages, count=15) diff --git a/GrampyScript/scripts/06_mark_unsourced_people_private.gram.py b/GrampyScript/scripts/06_mark_unsourced_people_private.gram.py new file mode 100644 index 0000000000..1783a6d215 --- /dev/null +++ b/GrampyScript/scripts/06_mark_unsourced_people_private.gram.py @@ -0,0 +1,17 @@ +# Mark Unsourced People As Private +""" +Batch-edit example: find every person who has no citations attached and flag +them as private, wrapped in begin_changes()/end_changes() so the edits happen +inside a single, undoable transaction. +""" + +begin_changes("Mark unsourced people as private") + +count = 0 +for person in people(): + if len(person.citations) == 0 and not person.private: + person.private = True + count += 1 + +end_changes() +print("Marked %d people as private" % count) diff --git a/GrampyScript/scripts/07_csv_ready_report.gram.py b/GrampyScript/scripts/07_csv_ready_report.gram.py new file mode 100644 index 0000000000..3f51049393 --- /dev/null +++ b/GrampyScript/scripts/07_csv_ready_report.gram.py @@ -0,0 +1,17 @@ +# CSV-Ready People Report +""" +Build a simple tabular report — ID, name, gender, birth year — for every +person. Once it runs, use Data > Save as CSV or Copy to clipboard to export the +Table tab's contents. +""" + +columns("Person", "Gender", "Birth Year") + +for person in people(): + birth = person.birth + birth_year = birth.get_date_object().get_year() if birth else "" + row( + person, + person.gender, + birth_year, + ) diff --git a/GrampyScript/scripts/08_active_person_summary.gram.py b/GrampyScript/scripts/08_active_person_summary.gram.py new file mode 100644 index 0000000000..371315b160 --- /dev/null +++ b/GrampyScript/scripts/08_active_person_summary.gram.py @@ -0,0 +1,17 @@ +# Active Person Summary +""" +Show a compact family summary for the currently active person: their record, +parents, spouse, and children. +""" + +person = active_person +if person: + row(person) + for parent in person.parents: + row(parent) + if person.spouse: + row(person.spouse) + for child in person.children: + row(child) +else: + print("No active person is set.") diff --git a/GrampyScript/scripts/09_selected_people_report.gram.py b/GrampyScript/scripts/09_selected_people_report.gram.py new file mode 100644 index 0000000000..74f840d9d4 --- /dev/null +++ b/GrampyScript/scripts/09_selected_people_report.gram.py @@ -0,0 +1,8 @@ +# Report On Selected People +""" +List just the people currently selected (highlighted) in the People view. +Select some rows in the People view before running this script. +""" + +for person in selected("Person"): + row(person) diff --git a/GrampyScript/scripts/10_find_missing_birth_dates.gram.py b/GrampyScript/scripts/10_find_missing_birth_dates.gram.py new file mode 100644 index 0000000000..ac7656305b --- /dev/null +++ b/GrampyScript/scripts/10_find_missing_birth_dates.gram.py @@ -0,0 +1,9 @@ +# Find People Missing A Birth Date +""" +Data-quality check: list every person who has no recorded birth event, so you +can prioritize research on those records. +""" + +for person in people(): + if not person.birth: + row(person) diff --git a/GrampyScript/scripts/11_import_example.gram.py b/GrampyScript/scripts/11_import_example.gram.py new file mode 100644 index 0000000000..a01dd90898 --- /dev/null +++ b/GrampyScript/scripts/11_import_example.gram.py @@ -0,0 +1,21 @@ +# Births Per Decade (Import Example) +""" +Counts births by decade, using a decade() function imported from +script_helpers.py in this same folder — a template for sharing helper code +between your own scripts with a plain 'import' statement. +""" + +from script_helpers import decade + +columns("Decade", "Births") + +counts = counter() +for person in people(): + birth = person.birth + if birth: + year = birth.get_date_object().get_year() + if year: + counts[decade(year)] += 1 + +for decade_start, count in sorted(counts.items()): + row("%ds" % decade_start, count) diff --git a/GrampyScript/scripts/12_custom_filter_example.gram.py b/GrampyScript/scripts/12_custom_filter_example.gram.py new file mode 100644 index 0000000000..c84eeffe55 --- /dev/null +++ b/GrampyScript/scripts/12_custom_filter_example.gram.py @@ -0,0 +1,10 @@ +# Custom Filter Example +""" +Runs one of your own custom filters (from the Filters gramplet/editor) by name +using custom_filter(). Change 'example filter' to the name of a filter you've +already created; if the name doesn't match one, a warning shows up in the +Output tab instead. +""" + +for person in custom_filter("example filter"): + row(person) diff --git a/GrampyScript/scripts/13_delete_unused_repositories.gram.py b/GrampyScript/scripts/13_delete_unused_repositories.gram.py new file mode 100644 index 0000000000..b1f2e169e8 --- /dev/null +++ b/GrampyScript/scripts/13_delete_unused_repositories.gram.py @@ -0,0 +1,18 @@ +# Delete Unused Repositories (Delete Example) +""" +Demonstrates delete(): removes any Repository record that nothing else in the +tree refers to. Most trees have no unused repositories, so this is unlikely to +actually delete anything — it's meant to show the pattern, wrapped in +begin_changes()/end_changes() as a single undoable transaction. +""" + +begin_changes("Delete unused repositories") + +count = 0 +for repository in repositories(): + if not repository.back_references: + delete(repository) + count += 1 + +end_changes() +print("Deleted %d unused repositories" % count) diff --git a/GrampyScript/scripts/README.md b/GrampyScript/scripts/README.md new file mode 100644 index 0000000000..47aab2acbb --- /dev/null +++ b/GrampyScript/scripts/README.md @@ -0,0 +1,35 @@ +# Gram.py Script examples + +This folder ships with the GrampyScript addon and doubles as the default +folder for Script > Open... and Script > Save as... in the gramplet. The +numbered files below are examples; anything else you save here is yours. + +| File | Description | +| --- | --- | +| `01_list_people.gram.py` | List every person with ID, given name, surname, and gender. | +| `02_filter_by_surname.gram.py` | List only people whose surname matches a given value. | +| `03_family_overview.gram.py` | List every family with father, mother, and child count. | +| `04_gender_pie_chart.gram.py` | Pie chart of the gender breakdown of everyone in the tree. | +| `05_age_histogram.gram.py` | Histogram of age at death, for people with both birth and death recorded. | +| `06_mark_unsourced_people_private.gram.py` | Batch-edit example: mark people with no citations as private. | +| `07_csv_ready_report.gram.py` | Tabular report meant to be exported via Data > Save as CSV. | +| `08_active_person_summary.gram.py` | Summary of the active person plus their parents, spouse, and children. | +| `09_selected_people_report.gram.py` | Report on just the rows currently selected in the People view. | +| `10_find_missing_birth_dates.gram.py` | Data-quality check: people with no recorded birth event. | +| `11_import_example.gram.py` | Counts births per decade using `decade()`, imported from `script_helpers.py`. | +| `12_custom_filter_example.gram.py` | Runs one of your own custom filters by name via `custom_filter()`. | +| `13_delete_unused_repositories.gram.py` | Delete example: removes Repository records nothing refers to (rarely any). | + +Each script's title and description are also shown as a preview when you +highlight it in the Open dialog. + +`script_helpers.py` is a plain Python module, not a `.gram.py` script — it +won't show up in the Open dialog. It exists to be imported (see +`11_import_example.gram.py`): any `.py` file you place in this folder, or +alongside a script saved elsewhere, can be imported the same way. + +**Note:** addon updates only overwrite files that share a name with +something in the released package. It's safe to save your own scripts in +this folder under any other name — just avoid editing the numbered +examples above in place, since a future addon update could overwrite those +edits. diff --git a/GrampyScript/scripts/script_helpers.py b/GrampyScript/scripts/script_helpers.py new file mode 100644 index 0000000000..482fd34540 --- /dev/null +++ b/GrampyScript/scripts/script_helpers.py @@ -0,0 +1,9 @@ +# Plain Python helper module, importable from any .gram.py script in this +# folder via `from script_helpers import decade` (see 11_import_example.gram.py). +# Unlike .gram.py files this is not a runnable script itself -- it's a +# regular module that GrampyScript's import-path setup makes importable. + + +def decade(year): + """Round a year down to the start of its decade, e.g. 1873 -> 1870.""" + return (year // 10) * 10 if year else None diff --git a/GrampyScript/stub_generator.py b/GrampyScript/stub_generator.py new file mode 100644 index 0000000000..5b7230744e --- /dev/null +++ b/GrampyScript/stub_generator.py @@ -0,0 +1,297 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2025 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Generates a block of plain-annotation Python source (a "stub preamble") that +jedi can use to statically infer the row type of a DSL generator function, +e.g. `for person in people(): person.primary_name.first_name`. + +jedi's static engine reads class bodies, it never runs our DataDict2.__dir__ +override, so a live DataDict2 instance is not enough for this case (there is +no instance until the script actually runs). Instead we derive the field +names straight from Gramps' own JSON schema (cls.get_schema(), present on +every gramps.gen.lib class) and render lightweight classes carrying only +type annotations -- no bodies, nothing is ever executed. + +This module has no GTK dependency, only gramps.gen.lib, so it can be +developed and tested without a running Gramps/GTK environment. +""" + +import re + +from gramps.gen.lib import ( + Citation, + Event, + Family, + Media, + Note, + Person, + Place, + Repository, + Source, +) + +# The DSL row types generators/active_* names are bound to in +# GrampyScript.execute_code(). +ROOT_CLASSES = [Person, Family, Event, Place, Repository, Source, Citation, Note, Media] + +# generator function name -> row type, mirrors the DSL bindings in +# GrampyScript.execute_code() (people/families/notes/...). +GENERATOR_ROW_TYPES = { + "people": "Person", + "families": "Family", + "notes": "Note", + "events": "Event", + "repositories": "Repository", + "citations": "Citation", + "sources": "Source", + "media": "Media", + "places": "Place", +} + +# active_* name -> row type, mirrors the active_* bindings in +# GrampyScript.execute_code(). These are declared as plain module-level +# annotations (no value), which is enough for jedi to resolve attribute +# chains statically. That matters here: a *live* template DataDict2 +# instance would require jedi to actually call DataDict2's computed +# @property methods (father, birth, ...) to see what they return, which +# executes real code (SimpleAccess lookups) and, for a blank template +# with nothing to find, yields no completions at all past that point. +# The stub sidesteps both problems -- nothing is ever executed, and the +# field list comes from the schema regardless of what data exists. +ACTIVE_VARIABLES = { + "active_person": "Person", + "active_family": "Family", + "active_event": "Event", + "active_place": "Place", + "active_repository": "Repository", + "active_source": "Source", + "active_citation": "Citation", + "active_note": "Note", + "active_media": "Media", +} + +# selected()/filtered()/custom_filter() in execute_code() all take a +# table-name string argument that picks the row type at runtime (e.g. +# selected("Family")). jedi 0.19 does not discriminate typing.overload by +# a Literal argument value -- verified empirically it merges every +# overload's return fields regardless of which literal was passed, and +# regardless of overload declaration order. So rather than rely on that, +# these are typed as returning the union of every row type: less precise +# than the argument deserves, but strictly more useful than no annotation +# at all (which is what these had before). +TABLE_FUNCTIONS = { + "selected": ["table_name: str"], + "filtered": ["table_name: str"], + "custom_filter": ["name: str", 'namespace: str = "Person"'], +} + +# Other top-level DSL callables bound in execute_code() (GrampyScript.py): +# real functions/bound methods with side effects (printing a row, opening/ +# closing a transaction, drawing a chart, deleting a record) rather than +# something whose return value ever gets chained. None of these need row-type +# inference, just enough of a signature for jedi to offer them as completions +# and to know their parameters -- hence "-> None" rather than being folded +# into TABLE_FUNCTIONS. +VOID_FUNCTIONS = { + "row": ["*args"], + "columns": ["*column_names"], + "begin_changes": ['message: str = ""'], + "end_changes": [], + "delete": ["obj"], + "chart": ["type", "data", "count: int = 20", "**kwargs"], +} + +# back_references(_recursively) can resolve to any primary object type at +# runtime (datadict2.py looks up the handle's own table), so -- same trick as +# TABLE_FUNCTIONS above -- type them as the union of every row type rather +# than "object": jedi merges every union member's attributes, which is more +# useful than no completions at all past a plain "object". +_ALL_ROOT = set(GENERATOR_ROW_TYPES.values()) +_BACK_REFERENCE_TYPE = 'list[Union[%s]]' % ", ".join('"%s"' % name for name in sorted(_ALL_ROOT)) + +_PERSON = {"Person"} +_PERSON_FAMILY = {"Person", "Family"} + +# DataDict2's computed @property names (datadict2.py): type_annotation plus +# which root row types the property is actually valid on, derived from what +# each property's own code requires -- a SimpleAccess call that asserts its +# argument type (e.g. sa.gender, sa.spouse: Person only), or a raw dict field +# that only some schemas have (e.g. `place` needs Event.place, `source` needs +# Citation.source_handle). Layering a property onto a type it doesn't apply +# to would offer a completion that's either silently useless or -- like the +# old `gender`-on-non-Person -- raises at runtime. +COMPUTED_PROPERTIES = { + "gender": ("str", _PERSON), + "age": ("Span", _PERSON), + "birth": ("Event", _PERSON), + "death": ("Event", _PERSON), + "place": ("Place", {"Event"}), + "parents": ('list["Person"]', _PERSON_FAMILY), + "father": ("Person", _PERSON_FAMILY), + "mother": ("Person", _PERSON_FAMILY), + "spouse": ("Person", _PERSON), + "source": ("Source", {"Citation"}), + "families": ('list["Family"]', _PERSON), + "parent_families": ('list["Family"]', _PERSON), + "children": ('list["Person"]', _PERSON_FAMILY), + "notes": ('list["Note"]', _ALL_ROOT - {"Note"}), + "tags": ('list["Tag"]', _ALL_ROOT), + "citations": ('list["Citation"]', {"Person", "Family", "Event", "Place", "Media"}), + "media": ('list["MediaRef"]', {"Person", "Family", "Event", "Place", "Source", "Citation"}), + "events": ('list["Event"]', _PERSON_FAMILY), + "attributes": ('list["Attribute"]', {"Person", "Family", "Event", "Source", "Citation", "Media"}), + "addresses": ('list["Address"]', {"Person", "Repository"}), + "lds_ords": ('list["LdsOrdinance"]', _PERSON_FAMILY), + "references": ('list["PersonRef"]', _PERSON), + "back_references": (_BACK_REFERENCE_TYPE, _ALL_ROOT), + "back_references_recursively": (_BACK_REFERENCE_TYPE, _ALL_ROOT), + "name": ("Name", _PERSON), + "surname": ("Surname", _PERSON), + "names": ('list["Name"]', _PERSON), +} + +# `reference` (datadict2.py) isn't valid on any root row type -- it reads +# `self.ref`, a handle that exists only on the nested *Ref wrapper types +# (mirrors datadict2.REFERENCE_TABLES). Sanitized schema class name -> the +# row type its `ref` handle points into. +REFERENCE_TARGET_TYPES = { + "ChildReference": "Person", + "EventReference": "Event", + "MediaRef": "Media", + "PersonRef": "Person", + "PlaceRef": "Place", + "RepositoryRef": "Repository", +} + +_SCALAR_TYPES = {"string": "str", "integer": "int", "boolean": "bool", "number": "float"} + + +def _sanitize(title): + """Turn a Gramps schema title like "Event reference" into a valid + Python identifier like "EventReference".""" + return "".join(word.capitalize() for word in re.findall(r"[A-Za-z0-9]+", title)) + + +def _pytype(schema, registry): + type_ = schema.get("type") + if isinstance(type_, list): + return "object" + if type_ == "object" and "properties" in schema: + _walk(schema, registry) + return _sanitize(schema["title"]) + if type_ == "array": + items = schema.get("items") + if isinstance(items, dict) and items.get("type") == "object" and "properties" in items: + _walk(items, registry) + return 'list["%s"]' % _sanitize(items["title"]) + return "list" + return _SCALAR_TYPES.get(type_, "object") + + +def _walk(schema, registry): + title = schema.get("title") + if not title: + return + name = _sanitize(title) + if name in registry: + return + registry[name] = {} # reserve first, to break reference cycles + fields = {} + for field_name, sub in schema.get("properties", {}).items(): + if field_name == "_class": + continue + fields[field_name] = _pytype(sub, registry) + registry[name] = fields + + +def build_registry(root_classes=ROOT_CLASSES): + """ + Return {sanitized_class_name: {field_name: type_annotation}} for every + type reachable from `root_classes` via Gramps' own get_schema(). Each + entry in COMPUTED_PROPERTIES is layered only onto the root row types it + lists as valid (matching real attribute lookup order: properties shadow + raw dict keys) -- nested structural types (Name, Attribute, ...) get + schema fields only. `reference` is layered separately, onto the nested + *Ref types listed in REFERENCE_TARGET_TYPES, since that's what it's + actually valid on. + """ + registry = {} + for cls in root_classes: + _walk(cls.get_schema(), registry) + for class_name, fields in registry.items(): + if class_name in _ALL_ROOT: + for prop_name, (type_, valid_for) in COMPUTED_PROPERTIES.items(): + if class_name in valid_for: + fields[prop_name] = type_ + target = REFERENCE_TARGET_TYPES.get(class_name) + if target is not None: + fields["reference"] = target + return registry + + +def render_stub_source( + registry, + generator_row_types=GENERATOR_ROW_TYPES, + table_functions=TABLE_FUNCTIONS, + active_variables=ACTIVE_VARIABLES, + void_functions=VOID_FUNCTIONS, +): + """ + Render `registry` plus DSL generator function signatures and active_* + variable annotations as a block of Python source usable as a jedi + completion preamble. No class or function body has real logic, only + annotations -- this text is only ever fed to jedi for static + analysis, never executed. + """ + lines = [ + "from __future__ import annotations", + "from typing import Iterator, Union", + "from gramps.gen.lib.date import Span", + "", + ] + for name in sorted(registry): + lines.append("class %s:" % name) + fields = registry[name] + if not fields: + lines.append(" pass") + else: + for field_name, type_ in fields.items(): + lines.append(" %s: %s" % (field_name, type_)) + lines.append("") + for func_name, row_type in generator_row_types.items(): + lines.append("def %s() -> Iterator[%s]: ..." % (func_name, row_type)) + if table_functions: + row_union = "Union[%s]" % ", ".join(sorted(set(generator_row_types.values()))) + for func_name, params in table_functions.items(): + lines.append( + "def %s(%s) -> Iterator[%s]: ..." % (func_name, ", ".join(params), row_union) + ) + for func_name, params in void_functions.items(): + lines.append("def %s(%s) -> None: ..." % (func_name, ", ".join(params))) + lines.append("") + for var_name, row_type in active_variables.items(): + lines.append("%s: %s" % (var_name, row_type)) + lines.append("") + return "\n".join(lines) + + +def build_stub_source(): + """Convenience: build the registry and render it in one call.""" + return render_stub_source(build_registry()) diff --git a/GrampyScript/tests/test_completion.py b/GrampyScript/tests/test_completion.py new file mode 100644 index 0000000000..d76cfd64f5 --- /dev/null +++ b/GrampyScript/tests/test_completion.py @@ -0,0 +1,214 @@ +""" +Tests for completion.py — jedi-based command completion. + +Uses real Gramps gen-lib objects (no GTK required). +""" + +import os +import sys +import unittest +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from gramps.gen.lib import Person, Name, Surname +from gramps.gen.simple import SimpleAccess + +from datadict2 import DataDict2, set_sa +from completion import get_completions, get_completion_items + + +def _make_person(gramps_id="I0001", first="John", surname="Smith"): + p = Person() + p.set_gramps_id(gramps_id) + n = Name() + sn = Surname() + sn.set_surname(surname) + n.add_surname(sn) + n.set_first_name(first) + p.set_primary_name(n) + return p + + +class _MockSaBase(unittest.TestCase): + """Base class that sets up a minimal SimpleAccess mock before each test.""" + + def setUp(self): + db = MagicMock() + sa = SimpleAccess(db) + set_sa(sa) + + def _complete(self, source, namespace): + line = source.count("\n") + 1 + column = len(source) - (source.rfind("\n") + 1) + return get_completions(source, line, column, namespace) + + +class TestBareWordCompletion(_MockSaBase): + def test_completes_python_builtins(self): + # print is a function, so it gets "()" appended like any other + # callable completion -- see TestCompletionItems below. + names = self._complete("pri", {}) + self.assertIn("print()", names) + + def test_completes_namespace_variable(self): + names = self._complete("active_per", {"active_person": DataDict2(_make_person())}) + self.assertIn("active_person", names) + + +class TestAttributeCompletion(_MockSaBase): + def setUp(self): + super().setUp() + self.namespace = {"active_person": DataDict2(_make_person())} + + def test_completes_dynamic_dict_keys(self): + names = self._complete("active_person.", self.namespace) + self.assertIn("primary_name", names) + self.assertIn("gramps_id", names) + + def test_completes_class_properties(self): + names = self._complete("active_person.", self.namespace) + self.assertIn("father", names) + self.assertIn("age", names) + + def test_completes_nested_attribute_chain(self): + names = self._complete("active_person.primary_name.", self.namespace) + self.assertIn("first_name", names) + self.assertIn("surname_list", names) + + def test_prefix_narrows_nested_match(self): + names = self._complete("active_person.primary_name.first_", self.namespace) + self.assertEqual(names, ["first_name"]) + + def test_no_false_match_for_unrelated_prefix(self): + names = self._complete("active_person.primary_name.zzz", self.namespace) + self.assertEqual(names, []) + + +class TestGeneratorRowTypeInference(_MockSaBase): + """ + Completion on a user's own loop variable, e.g. + `for person in people(): person.primary_name.first_name` -- `person` is + a name the user chose, not something we bind into the namespace, so it + can only be resolved via the stub_generator preamble's static + annotation on `people()`, not runtime introspection. + """ + + def test_completes_loop_variable_over_people(self): + names = self._complete("for person in people():\n person.", {}) + self.assertIn("primary_name", names) + self.assertIn("gramps_id", names) + + def test_completes_nested_attribute_on_loop_variable(self): + names = self._complete( + "for person in people():\n person.primary_name.first_", {} + ) + self.assertEqual(names, ["first_name"]) + + def test_distinguishes_row_type_by_generator(self): + # families() yields Family, not Person -- fields must not bleed + # across generators. + names = self._complete("for fam in families():\n fam.", {}) + self.assertIn("father_handle", names) + self.assertNotIn("primary_name", names) + + +class TestGetCompletionsFunctionParens(_MockSaBase): + """ + Regression: get_completions() used to return bare function names + (e.g. "people", "format") while get_completion_items() appended "()" + to the same completions -- inconsistent and misleading, since a bare + name reads as a field rather than a callable. Both now agree. + """ + + def test_bare_function_completion_gets_parens(self): + names = self._complete("peop", {}) + self.assertIn("people()", names) + self.assertNotIn("people", names) + + def test_non_function_completion_has_no_parens(self): + namespace = {"active_person": DataDict2(_make_person())} + names = self._complete("active_person.gramps_", namespace) + self.assertIn("gramps_id", names) + + +class TestClassCompletionsExcluded(_MockSaBase): + """ + Classes (jedi type "class") are excluded entirely, not just left + without "()". The stub preamble injects scaffold classes (Person, + Family, ...) purely for jedi's static analysis -- they aren't bound to + anything in the namespace a script actually executes in, so offering + them as completions would suggest names that raise NameError if + accepted. Builtin classes (list, dict, ...) are excluded too, since + the DSL has no use for instantiating classes directly. + """ + + def test_stub_scaffold_class_not_offered(self): + names = self._complete("Perso", {}) + self.assertNotIn("Person", names) + self.assertNotIn("PersonRef", names) + + def test_builtin_class_not_offered(self): + names = self._complete("li", {}) + self.assertNotIn("list", names) + + +class TestCompletionItems(_MockSaBase): + """get_completion_items() is get_completions() plus the jedi + `.complete` suffix, used by the editor to insert just the missing + characters rather than re-typing the whole name.""" + + def test_complete_is_only_the_missing_suffix(self): + namespace = {"active_person": DataDict2(_make_person())} + items = get_completion_items("active_person.primary_", 1, len("active_person.primary_"), namespace) + self.assertEqual( + items, [{"name": "primary_name", "complete": "name", "cursor_offset": 0}] + ) + + def test_complete_is_full_name_when_nothing_typed_yet(self): + namespace = {"active_person": DataDict2(_make_person())} + items = get_completion_items("active_person.", 1, len("active_person."), namespace) + matching = [i for i in items if i["name"] == "primary_name"] + self.assertEqual( + matching, [{"name": "primary_name", "complete": "primary_name", "cursor_offset": 0}] + ) + + def test_no_arg_function_gets_parens_appended(self): + # people() takes no arguments -- cursor lands after "()". + items = get_completion_items("peop", 1, len("peop"), {}) + matching = [i for i in items if i["name"] == "people()"] + self.assertEqual( + matching, [{"name": "people()", "complete": "le()", "cursor_offset": 0}] + ) + + def test_function_with_args_lands_cursor_between_parens(self): + items = get_completion_items("custom_fil", 1, len("custom_fil"), {}) + matching = [i for i in items if i["name"] == "custom_filter()"] + self.assertEqual( + matching, [{"name": "custom_filter()", "complete": "ter()", "cursor_offset": 1}] + ) + + def test_non_function_completion_has_no_parens(self): + namespace = {"active_person": DataDict2(_make_person())} + items = get_completion_items("active_person.gramps_", 1, len("active_person.gramps_"), namespace) + matching = [i for i in items if i["name"] == "gramps_id"] + self.assertEqual( + matching, [{"name": "gramps_id", "complete": "id", "cursor_offset": 0}] + ) + + +class TestRobustness(_MockSaBase): + def test_empty_source_does_not_raise(self): + # Completing on an empty buffer legitimately lists every builtin + # in scope; the point of this test is only that it doesn't raise. + names = self._complete("", {}) + self.assertIn("print()", names) + + def test_incomplete_code_does_not_raise(self): + # Mid-typing code is often syntactically invalid; must not crash. + names = self._complete("for person in people(", {}) + self.assertIsInstance(names, list) + + +if __name__ == "__main__": + unittest.main() diff --git a/GrampyScript/tests/test_completion_popup.py b/GrampyScript/tests/test_completion_popup.py new file mode 100644 index 0000000000..047ee62d22 --- /dev/null +++ b/GrampyScript/tests/test_completion_popup.py @@ -0,0 +1,247 @@ +""" +Tests for completion_popup.py — the Tab-triggered completion popover. + +Needs a real (possibly virtual, e.g. Xvfb) display since it builds real +Gtk widgets (Gtk.Popover, Gtk.ListBox) and asks for their allocation. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk, Gtk + +from completion_popup import CompletionController + + +class _FakeEvent: + def __init__(self, keyval): + self.keyval = keyval + + +def _make_controller(text, cursor_offset=None, namespace=None): + textview = Gtk.TextView() + buffer = textview.get_buffer() + buffer.set_text(text) + if cursor_offset is None: + cursor_offset = len(text) + buffer.place_cursor(buffer.get_iter_at_offset(cursor_offset)) + + # A real (offscreen) top-level window so widgets can be allocated -- + # Gtk.Popover needs a realized relative_to widget to compute a + # position against. + window = Gtk.Window() + window.add(textview) + window.set_default_size(400, 300) + window.show_all() + while Gtk.events_pending(): + Gtk.main_iteration() + + controller = CompletionController(textview, get_namespace=lambda: namespace or {}) + return controller, buffer, window + + +class TestTriggerAndClose(unittest.TestCase): + def test_trigger_opens_for_completable_context(self): + controller, buffer, window = _make_controller("active_person.") + opened = controller.trigger() + self.assertTrue(opened) + self.assertTrue(controller.is_open()) + names = [item["name"] for item in controller.items] + self.assertIn("primary_name", names) + window.destroy() + + def test_trigger_does_not_open_after_whitespace(self): + controller, buffer, window = _make_controller("x = 1 ") + opened = controller.trigger() + self.assertFalse(opened) + self.assertFalse(controller.is_open()) + window.destroy() + + def test_trigger_does_not_open_on_empty_buffer(self): + controller, buffer, window = _make_controller("") + opened = controller.trigger() + self.assertFalse(opened) + window.destroy() + + def test_close_resets_state(self): + controller, buffer, window = _make_controller("active_person.") + controller.trigger() + controller.close() + self.assertFalse(controller.is_open()) + self.assertEqual(controller.items, []) + self.assertEqual(controller.selected_index, 0) + window.destroy() + + def test_trigger_inserts_directly_when_only_one_match(self): + # "primary_" only matches primary_name among active_person's + # dynamic keys -- with a single candidate there is nothing to + # choose between, so it should be inserted immediately rather + # than opening a one-row popover. + controller, buffer, window = _make_controller("active_person.primary_") + opened = controller.trigger() + self.assertTrue(opened) + self.assertFalse(controller.is_open()) + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + self.assertEqual(text, "active_person.primary_name") + window.destroy() + + +class TestAccept(unittest.TestCase): + def test_accept_inserts_missing_suffix_only(self): + controller, buffer, window = _make_controller("active_person.") + controller.trigger() + names = [item["name"] for item in controller.items] + controller.selected_index = names.index("primary_name") + controller.accept() + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + self.assertEqual(text, "active_person.primary_name") + self.assertFalse(controller.is_open()) + window.destroy() + + def test_accept_with_no_items_just_closes(self): + controller, buffer, window = _make_controller("active_person.") + controller.trigger() + controller.items = [] + controller.accept() + self.assertFalse(controller.is_open()) + window.destroy() + + def test_accept_no_arg_function_places_cursor_after_parens(self): + # "peop" has only one match (people()), so trigger() inserts it + # directly -- exercises the same accept() cursor-placement code + # path as a manual popover selection would. + controller, buffer, window = _make_controller("peop") + controller.trigger() + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + self.assertEqual(text, "people()") + cursor = buffer.get_iter_at_mark(buffer.get_insert()).get_offset() + self.assertEqual(cursor, len("people()")) + window.destroy() + + def test_accept_function_with_args_places_cursor_between_parens(self): + controller, buffer, window = _make_controller("custom_fil") + controller.trigger() + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + self.assertEqual(text, "custom_filter()") + cursor = buffer.get_iter_at_mark(buffer.get_insert()).get_offset() + self.assertEqual(cursor, len("custom_filter(")) + window.destroy() + + +class TestNavigation(unittest.TestCase): + def test_move_selection_clamped(self): + controller, buffer, window = _make_controller("active_person.") + controller.trigger() + n = len(controller.items) + self.assertGreater(n, 1) + controller.move_selection(-1) + self.assertEqual(controller.selected_index, 0) + controller.move_selection(10**6) + self.assertEqual(controller.selected_index, n - 1) + controller.move_selection(-(10**6)) + self.assertEqual(controller.selected_index, 0) + window.destroy() + + +class TestOnKeyPress(unittest.TestCase): + def test_tab_completes_directly_for_single_match(self): + controller, buffer, window = _make_controller("active_person.primary_") + consumed = controller.on_key_press(_FakeEvent(Gdk.KEY_Tab)) + self.assertTrue(consumed) + self.assertFalse(controller.is_open()) + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + self.assertEqual(text, "active_person.primary_name") + window.destroy() + + def test_tab_opens_then_accepts_for_multiple_matches(self): + controller, buffer, window = _make_controller("active_person.") + consumed = controller.on_key_press(_FakeEvent(Gdk.KEY_Tab)) + self.assertTrue(consumed) + self.assertTrue(controller.is_open()) + + consumed = controller.on_key_press(_FakeEvent(Gdk.KEY_Tab)) + self.assertTrue(consumed) + self.assertFalse(controller.is_open()) + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + self.assertTrue(text.startswith("active_person.")) + self.assertGreater(len(text), len("active_person.")) + window.destroy() + + def test_tab_falls_through_when_nothing_completable(self): + controller, buffer, window = _make_controller("x = 1 ") + consumed = controller.on_key_press(_FakeEvent(Gdk.KEY_Tab)) + self.assertFalse(consumed) # caller should fall back to inserting spaces + window.destroy() + + def test_arrow_keys_only_consumed_while_open(self): + controller, buffer, window = _make_controller("active_person.") + self.assertFalse(controller.on_key_press(_FakeEvent(Gdk.KEY_Down))) + controller.trigger() + self.assertTrue(controller.on_key_press(_FakeEvent(Gdk.KEY_Down))) + self.assertEqual(controller.selected_index, 1) + window.destroy() + + def test_escape_closes_and_is_consumed(self): + controller, buffer, window = _make_controller("active_person.") + controller.trigger() + consumed = controller.on_key_press(_FakeEvent(Gdk.KEY_Escape)) + self.assertTrue(consumed) + self.assertFalse(controller.is_open()) + window.destroy() + + def test_left_right_close_but_are_not_consumed(self): + controller, buffer, window = _make_controller("active_person.") + controller.trigger() + consumed = controller.on_key_press(_FakeEvent(Gdk.KEY_Left)) + self.assertFalse(consumed) # cursor movement must still happen + self.assertFalse(controller.is_open()) + window.destroy() + + def test_return_accepts_only_while_open(self): + controller, buffer, window = _make_controller("active_person.") + # popover not open: Return must not be swallowed (newline/apply-script bindings) + self.assertFalse(controller.on_key_press(_FakeEvent(Gdk.KEY_Return))) + controller.trigger() + self.assertTrue(controller.is_open()) + self.assertTrue(controller.on_key_press(_FakeEvent(Gdk.KEY_Return))) + self.assertFalse(controller.is_open()) + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + self.assertTrue(text.startswith("active_person.")) + self.assertGreater(len(text), len("active_person.")) + window.destroy() + + +class TestLiveRefresh(unittest.TestCase): + def test_refresh_narrows_as_more_is_typed(self): + controller, buffer, window = _make_controller("active_person.") + controller.trigger() + self.assertGreater(len(controller.items), 1) + + it = buffer.get_iter_at_mark(buffer.get_insert()) + buffer.insert(it, "primary_") + controller.on_buffer_changed() + names = [item["name"] for item in controller.items] + self.assertEqual(names, ["primary_name"]) + window.destroy() + + def test_refresh_closes_when_context_no_longer_completable(self): + controller, buffer, window = _make_controller("active_person.") + controller.trigger() + self.assertTrue(controller.is_open()) + + it = buffer.get_iter_at_mark(buffer.get_insert()) + buffer.insert(it, " ") + controller.on_buffer_changed() + self.assertFalse(controller.is_open()) + window.destroy() + + +if __name__ == "__main__": + unittest.main() diff --git a/GrampyScript/tests/test_datadict2.py b/GrampyScript/tests/test_datadict2.py index d7ab9e5237..8460b106e7 100644 --- a/GrampyScript/tests/test_datadict2.py +++ b/GrampyScript/tests/test_datadict2.py @@ -11,7 +11,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from gramps.gen.lib import Person, Name, Surname, Family +from gramps.gen.lib import Person, Name, Surname, Family, NameOriginType from gramps.gen.simple import SimpleAccess from datadict2 import DataDict2, DataList2, NoneData, set_sa @@ -47,6 +47,12 @@ def setUp(self): db.get_event_from_handle.return_value = None db.get_place_from_handle.return_value = None db.get_source_from_handle.return_value = None + # Mirror DbGeneric.method()'s real dispatch (getattr on a formatted, + # lowercased method name), since a bare MagicMock doesn't do this. + db.method.side_effect = lambda fmt, *args: getattr( + db, fmt % tuple(a.lower() for a in args), None + ) + self.db = db sa = SimpleAccess(db) set_sa(sa) @@ -162,6 +168,60 @@ def test_family_gramps_id(self): self.assertEqual(dd.gramps_id, "F0007") self.assertEqual(dd["_class"], "Family") + def test_names_includes_alternate_names_in_order(self): + # Regression: `names` was `[self.primary_name] + [self.alternate_names]` + # -- the extra brackets nested the whole alternate_names list as one + # element instead of spreading it in, and __radd__ used to reverse + # the order on top of that. + p = _make_person(first="John") + alt = Name() + alt_sn = Surname() + alt_sn.set_surname("Doe") + alt.add_surname(alt_sn) + alt.set_first_name("Jack") + p.add_alternate_name(alt) + dd = DataDict2(p) + self.assertEqual(len(dd.names), 2) + self.assertEqual([n.first_name for n in dd.names], ["John", "Jack"]) + + +# --------------------------------------------------------------------------- +# DataDict2 — surname/name/reference on non-Person and *Ref wrappers +# --------------------------------------------------------------------------- + + +class TestDataDict2NonPersonProperties(_MockSaBase): + def test_surname_is_none_data_for_non_person(self): + # Regression: used to raise KeyError via self["surname"], since no + # schema has a top-level "surname" field. + dd = DataDict2(_make_family()) + self.assertIsInstance(dd.surname, NoneData) + + def test_name_is_none_data_when_field_absent(self): + # Regression: used to raise KeyError via self["name"] for classes + # (like Family) with no "name" schema field. + dd = DataDict2(_make_family()) + self.assertIsInstance(dd.name, NoneData) + + def test_reference_dispatches_by_class(self): + # Regression: used to always call get_raw_person_data regardless of + # which *Ref type was wrapped. + from gramps.gen.lib import PersonRef + from gramps.gen.lib.json_utils import object_to_dict + + ref = PersonRef() + ref.set_reference_handle("HANDLE1") + self.db.get_raw_person_data.return_value = object_to_dict( + _make_person(gramps_id="I9999") + ) + dd = DataDict2(ref) + self.assertEqual(dd.reference.gramps_id, "I9999") + self.db.get_raw_person_data.assert_called_with("HANDLE1") + + def test_reference_none_data_for_unmapped_class(self): + dd = DataDict2(_make_family()) + self.assertIsInstance(dd.reference, NoneData) + # --------------------------------------------------------------------------- # DataDict2 — null-safe chaining @@ -211,16 +271,121 @@ def test_getattr_fans_out_across_items(self): self.assertIn("I0001", ids) self.assertIn("I0002", ids) + def test_set_method_fans_out_across_items(self): + # Regression: `dl.set_privacy(True)` used to fan out attribute + # access first (collecting each item's unevaluated set_*() wrapper + # closure into a DataList2), then fail to call because a DataList2 + # of closures isn't itself callable. It must call set_privacy(True) + # on every item instead. + dl = self._make_list() + dl.set_privacy(True) + self.assertTrue(dl[0].private) + self.assertTrue(dl[1].private) + def test_add_concatenates(self): dl1 = DataList2([DataDict2(_make_person(gramps_id="I0001"))]) dl2 = DataList2([DataDict2(_make_person(gramps_id="I0002"))]) self.assertEqual(len(dl1 + dl2), 2) + def test_radd_preserves_order(self): + # Regression: __radd__ used to return `self + value` instead of + # `value + self`, so `plain_list + data_list2` (the pattern used to + # loop over primary + alternate names) came out reversed. + dl = DataList2([DataDict2(_make_person(gramps_id="I0002"))]) + combined = [DataDict2(_make_person(gramps_id="I0001"))] + dl + self.assertEqual([p.gramps_id for p in combined], ["I0001", "I0002"]) + def test_empty_list(self): dl = DataList2([]) self.assertEqual(len(dl), 0) self.assertEqual(list(dl), []) +# --------------------------------------------------------------------------- +# DataDict2 — mutation (attribute assignment and set_*() methods) +# --------------------------------------------------------------------------- + +class TestDataDict2Mutation(_MockSaBase): + def test_top_level_attribute_assignment(self): + dd = DataDict2(_make_person(gramps_id="I0001")) + dd.gramps_id = "I9999" + self.assertEqual(dd._object.get_gramps_id(), "I9999") + self.assertEqual(dd.gramps_id, "I9999") + + def test_nested_attribute_assignment_updates_real_object(self): + # Regression: assigning through a nested wrapper (primary_name is + # not the root) must mutate the real Person's Name object, not a + # disconnected copy. + dd = DataDict2(_make_person(first="John")) + dd.primary_name.first_name = "Zoe" + self.assertEqual(dd._object.get_primary_name().get_first_name(), "Zoe") + self.assertEqual(dd.primary_name.first_name, "Zoe") + + def test_nested_set_method_updates_real_object(self): + # Regression: calling a set_*() method on a nested wrapper (e.g. a + # Surname inside primary_name.surname_list) used to run against a + # standalone object rebuilt from that wrapper's own dict slice, so + # the mutation never reached the real Person and was lost on commit. + dd = DataDict2(_make_person(surname="Smith")) + surname = dd.primary_name.surname_list[0] + surname.set_surname("Jones") + real_surname = dd._object.get_primary_name().get_surname_list()[0] + self.assertEqual(real_surname.get_surname(), "Jones") + self.assertEqual(dd.primary_name.surname_list[0].surname, "Jones") + + def test_nested_set_method_calls_callback_with_root(self): + callback = MagicMock() + dd = DataDict2(_make_person(surname="Smith"), callback=callback) + surname = dd.primary_name.surname_list[0] + surname.set_surname("Jones") + callback.assert_called_once_with("set", dd) + + def test_concatenated_name_list_assignment_updates_real_object(self): + # Regression: `[dd.primary_name] + dd.alternate_names` (the pattern + # used to loop over all of a person's names) goes through + # DataList2.__radd__, which builds a plain list of already-wrapped + # DataDict2 items and re-wraps it in a new DataList2. Iterating that + # outer DataList2 used to re-wrap each *already-wrapped* item via + # __getitem__, discarding its real root/path and replacing it with + # root=self (since the outer list has root=None), producing a + # DataDict2 whose root is itself but whose path is non-empty -- + # an inconsistent state that made attribute assignment recurse + # into itself trying to resolve `self.root._object`. + dd = DataDict2(_make_person(surname="Smith")) + for name in [dd.primary_name] + dd.alternate_names: + self.assertIs(name.root, dd) + for surname in name.surname_list: + surname.set_surname("Jones") + real = dd._object.get_primary_name().get_surname_list()[0] + self.assertEqual(real.get_surname(), "Jones") + + +# --------------------------------------------------------------------------- +# DataDict2 — .string for GrampsType-based fields (NameOriginType, ...) +# --------------------------------------------------------------------------- + +class TestDataDict2TypeString(_MockSaBase): + def test_origintype_string_reflects_predefined_value(self): + # Regression: the raw "string" field only holds the *custom*-type + # override text, which is always "" for predefined values like + # PATRILINEAL. `.string` must return the real, computed label + # instead of that raw (and misleadingly empty) field. + dd = DataDict2(_make_person(surname="Smith")) + surname = dd.primary_name.surname_list[0] + surname.set_origintype(NameOriginType.PATRILINEAL) + self.assertEqual(dd.primary_name.surname_list[0].origintype.string, "Patrilineal") + + def test_origintype_string_empty_for_none(self): + dd = DataDict2(_make_person()) + self.assertEqual(dd.primary_name.surname_list[0].origintype.string, "") + + def test_string_missing_field_falls_back_normally(self): + # A DataDict2 with no "string" key at all (e.g. a Name) must not be + # affected by the .string property -- it should fall through to + # ordinary attribute lookup rather than raising or returning "". + dd = DataDict2(_make_person()) + self.assertIsInstance(dd.primary_name.string, NoneData) + + if __name__ == "__main__": unittest.main() diff --git a/GrampyScript/tests/test_extract_header_comment.py b/GrampyScript/tests/test_extract_header_comment.py new file mode 100644 index 0000000000..bbcf169984 --- /dev/null +++ b/GrampyScript/tests/test_extract_header_comment.py @@ -0,0 +1,53 @@ +""" +Tests for the extract_header_comment() utility in script_utils. + +extract_header_comment() pulls the leading '#'-comment block out of a +script's source, for use as a fallback Open-dialog preview when a file +has no catalogued entry in SCRIPT_DESCRIPTIONS. It lives in script_utils.py +(no GTK or Gramps imports required) precisely so it can be imported and +tested directly, without pulling in the full (GTK-dependent) GrampyScript +module. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from script_utils import extract_header_comment + + +class TestExtractHeaderComment(unittest.TestCase): + def test_single_line_header(self): + source = "# Title\n\nfor p in people():\n row(p)\n" + self.assertEqual(extract_header_comment(source), "Title") + + def test_multi_line_header(self): + source = "# Title\n#\n# A longer description.\n\nrow(1)\n" + self.assertEqual( + extract_header_comment(source), "Title\n\nA longer description." + ) + + def test_no_header_returns_empty(self): + source = "for p in people():\n row(p)\n" + self.assertEqual(extract_header_comment(source), "") + + def test_leading_blank_lines_before_header_are_skipped(self): + source = "\n\n# Title\n\nrow(1)\n" + self.assertEqual(extract_header_comment(source), "Title") + + def test_stops_at_first_code_line(self): + source = "# Title\nrow(1) # not part of the header\n" + self.assertEqual(extract_header_comment(source), "Title") + + def test_empty_source_returns_empty(self): + self.assertEqual(extract_header_comment(""), "") + + def test_hash_only_lines_become_blank_lines(self): + source = "# Title\n#\n# More.\n" + self.assertEqual(extract_header_comment(source), "Title\n\nMore.") + + +if __name__ == "__main__": + unittest.main() diff --git a/GrampyScript/tests/test_get_columns.py b/GrampyScript/tests/test_get_columns.py index 6270d7b760..b3cdb045bf 100644 --- a/GrampyScript/tests/test_get_columns.py +++ b/GrampyScript/tests/test_get_columns.py @@ -1,43 +1,20 @@ """ -Tests for the get_columns() utility from GrampyScript. +Tests for the get_columns() utility in script_utils. get_columns parses Python source and extracts argument expressions from all -calls to a given function name (typically "row"). It is pure ast — no GTK -or Gramps imports required — so the function is tested here by reimporting -it directly from the module source via ast/importlib. +calls to a given function name (typically "row"). It lives in script_utils.py +(no GTK or Gramps imports required) precisely so it can be imported and +tested directly, without pulling in the full (GTK-dependent) GrampyScript +module. """ -import ast import os import sys import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -# --------------------------------------------------------------------------- -# Load get_columns without importing the full GrampyScript module -# (which needs GTK). We parse the source and exec just the function. -# --------------------------------------------------------------------------- - -_SOURCE = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "GrampyScript.py" -) - - -def _load_get_columns(): - src = open(_SOURCE).read() - tree = ast.parse(src) - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name == "get_columns": - snippet = ast.unparse(node) - ns = {"ast": ast} - exec(snippet, ns) - return ns["get_columns"] - raise RuntimeError("get_columns not found in GrampyScript.py") - - -get_columns = _load_get_columns() +from script_utils import get_columns # --------------------------------------------------------------------------- diff --git a/GrampyScript/tests/test_namespace_builder.py b/GrampyScript/tests/test_namespace_builder.py new file mode 100644 index 0000000000..5efc008e3f --- /dev/null +++ b/GrampyScript/tests/test_namespace_builder.py @@ -0,0 +1,46 @@ +""" +Tests for namespace_builder.py — the runtime completion namespace. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from gramps.gen.lib import Date + +from namespace_builder import build_namespace + + +class TestBuildNamespace(unittest.TestCase): + def test_without_database(self): + namespace = build_namespace() + self.assertIn("today", namespace) + self.assertIn("counter", namespace) + self.assertNotIn("database", namespace) + + def test_today_is_a_real_date(self): + namespace = build_namespace() + self.assertIsInstance(namespace["today"], Date) + + def test_counter_returns_defaultdict(self): + namespace = build_namespace() + counter = namespace["counter"]() + self.assertEqual(counter["anything"], 0) + + def test_database_included_when_given(self): + sentinel = object() + namespace = build_namespace(sentinel) + self.assertIs(namespace["database"], sentinel) + + def test_active_names_are_not_included(self): + # active_person etc. are handled as static stub annotations + # (stub_generator.ACTIVE_VARIABLES), not live namespace objects. + namespace = build_namespace() + self.assertNotIn("active_person", namespace) + self.assertNotIn("active_family", namespace) + + +if __name__ == "__main__": + unittest.main() diff --git a/GrampyScript/tests/test_script_descriptions.py b/GrampyScript/tests/test_script_descriptions.py new file mode 100644 index 0000000000..4785cebd16 --- /dev/null +++ b/GrampyScript/tests/test_script_descriptions.py @@ -0,0 +1,78 @@ +""" +Consistency checks between scripts/*.gram.py and SCRIPT_DESCRIPTIONS. + +script_descriptions.py has no GTK dependency (only gramps.gen.const), so +it can be imported directly here, unlike GrampyScript.py itself. +""" + +import ast +import glob +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from script_descriptions import SCRIPT_DESCRIPTIONS +from update_script_descriptions import ( + DESCRIPTIONS_PATH, + _load_header, + build_source, + collect_entries, +) + +SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) + + +def _script_basenames(): + return { + os.path.basename(path) + for path in glob.glob(os.path.join(SCRIPTS_DIR, "*.gram.py")) + } + + +class TestScriptDescriptionsCoverage(unittest.TestCase): + def test_every_script_has_a_description(self): + missing = _script_basenames() - set(SCRIPT_DESCRIPTIONS) + self.assertEqual(missing, set(), "scripts missing from SCRIPT_DESCRIPTIONS") + + def test_no_stale_entries(self): + stale = set(SCRIPT_DESCRIPTIONS) - _script_basenames() + self.assertEqual(stale, set(), "SCRIPT_DESCRIPTIONS keys with no matching file") + + def test_entries_have_title_and_description(self): + for name, entry in SCRIPT_DESCRIPTIONS.items(): + self.assertEqual(len(entry), 2, name) + title, description = entry + self.assertTrue(title.strip(), "%s has an empty title" % name) + self.assertTrue(description.strip(), "%s has an empty description" % name) + + +class TestScriptDescriptionsIsGenerated(unittest.TestCase): + def test_regenerating_produces_no_changes(self): + entries, errors = collect_entries() + self.assertEqual(errors, []) + header = _load_header(DESCRIPTIONS_PATH) + regenerated = build_source(entries, header) + with open(DESCRIPTIONS_PATH, encoding="utf-8") as f: + on_disk = f.read() + self.assertEqual( + regenerated, + on_disk, + "script_descriptions.py is out of sync with scripts/*.gram.py -- " + "run `python3 update_script_descriptions.py` to regenerate it.", + ) + + +class TestScriptsAreValidPython(unittest.TestCase): + def test_all_scripts_parse(self): + for path in glob.glob(os.path.join(SCRIPTS_DIR, "*.gram.py")): + with self.subTest(path=path): + with open(path) as f: + ast.parse(f.read()) + + +if __name__ == "__main__": + unittest.main() diff --git a/GrampyScript/tests/test_stub_generator.py b/GrampyScript/tests/test_stub_generator.py new file mode 100644 index 0000000000..55121c48a4 --- /dev/null +++ b/GrampyScript/tests/test_stub_generator.py @@ -0,0 +1,221 @@ +""" +Tests for stub_generator.py — deriving jedi completion stubs from Gramps' +own get_schema(). +""" + +import ast +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from stub_generator import ( + ACTIVE_VARIABLES, + GENERATOR_ROW_TYPES, + VOID_FUNCTIONS, + build_registry, + render_stub_source, +) +from completion import get_completions + + +class TestBuildRegistry(unittest.TestCase): + def setUp(self): + self.registry = build_registry() + + def test_discovers_root_types(self): + for name in ["Person", "Family", "Event", "Place", "Source", "Citation", "Note", "Media"]: + self.assertIn(name, self.registry) + + def test_discovers_nested_types(self): + # Reached only by walking into Person's primary_name / Name's date. + for name in ["Name", "Surname", "Date"]: + self.assertIn(name, self.registry) + + def test_sanitizes_titles_with_spaces(self): + # Raw schema titles are "Event reference", "Child Reference", etc. + self.assertIn("EventReference", self.registry) + self.assertNotIn("Event reference", self.registry) + + def test_person_has_raw_schema_fields(self): + fields = self.registry["Person"] + self.assertEqual(fields["gramps_id"], "str") + self.assertEqual(fields["primary_name"], "Name") + + def test_nested_list_field_is_typed(self): + fields = self.registry["Person"] + self.assertEqual(fields["address_list"], 'list["Address"]') + + def test_computed_properties_layered_on_matching_root_types(self): + # `father` is valid on Person and Family (sa.father accepts both). + for name in ["Person", "Family"]: + self.assertEqual(self.registry[name]["father"], "Person") + + def test_computed_properties_not_layered_on_mismatched_types(self): + # `father` shouldn't leak onto nested structural types (Name is + # reached only by walking Person.primary_name, not a root row type), + # nor onto root types the underlying SimpleAccess call rejects. + self.assertNotIn("father", self.registry["Name"]) + self.assertNotIn("spouse", self.registry["Event"]) + self.assertNotIn("gender", self.registry["Family"]) + + def test_reference_layered_only_on_ref_types(self): + # `reference` reads a `ref` handle that only *Ref wrapper types + # have -- it shouldn't appear on the root row types themselves. + self.assertEqual(self.registry["PersonRef"]["reference"], "Person") + self.assertEqual(self.registry["EventReference"]["reference"], "Event") + self.assertNotIn("reference", self.registry["Person"]) + + def test_computed_property_overrides_raw_field(self): + # `gender` is both a raw int field and a DataDict2 @property; + # the property wins at real attribute-lookup time. + self.assertEqual(self.registry["Person"]["gender"], "str") + + def test_class_key_excluded(self): + self.assertNotIn("_class", self.registry["Person"]) + + +class TestRenderStubSource(unittest.TestCase): + def test_output_is_valid_python(self): + source = render_stub_source(build_registry()) + ast.parse(source) # raises SyntaxError on failure + + def test_generator_functions_present(self): + source = render_stub_source(build_registry()) + for func_name, row_type in GENERATOR_ROW_TYPES.items(): + self.assertIn("def %s() -> Iterator[%s]: ..." % (func_name, row_type), source) + + def test_empty_registry_still_valid(self): + source = render_stub_source({}, generator_row_types={}, table_functions={}) + ast.parse(source) + + def test_table_functions_present(self): + source = render_stub_source(build_registry()) + self.assertIn("def selected(table_name: str) -> Iterator[Union[", source) + self.assertIn("def filtered(table_name: str) -> Iterator[Union[", source) + self.assertIn( + 'def custom_filter(name: str, namespace: str = "Person") -> Iterator[Union[', + source, + ) + + def test_table_function_union_covers_every_row_type(self): + source = render_stub_source(build_registry()) + line = next(l for l in source.splitlines() if l.startswith("def selected")) + for row_type in GENERATOR_ROW_TYPES.values(): + self.assertIn(row_type, line) + + def test_no_table_functions_when_omitted(self): + source = render_stub_source(build_registry(), table_functions={}) + self.assertNotIn("def selected", source) + + def test_active_variables_present(self): + source = render_stub_source(build_registry()) + for var_name, row_type in ACTIVE_VARIABLES.items(): + self.assertIn("%s: %s" % (var_name, row_type), source) + + def test_no_active_variables_when_omitted(self): + source = render_stub_source(build_registry(), active_variables={}) + self.assertNotIn("active_person:", source) + + def test_void_functions_present(self): + source = render_stub_source(build_registry()) + self.assertIn("def columns(*column_names) -> None: ...", source) + self.assertIn('def begin_changes(message: str = "") -> None: ...', source) + self.assertIn("def end_changes() -> None: ...", source) + self.assertIn("def delete(obj) -> None: ...", source) + self.assertIn("def row(*args) -> None: ...", source) + self.assertIn( + "def chart(type, data, count: int = 20, **kwargs) -> None: ...", source + ) + + def test_no_void_functions_when_omitted(self): + source = render_stub_source(build_registry(), void_functions={}) + self.assertNotIn("def columns", source) + + +class TestActiveVariableCompletion(unittest.TestCase): + """ + active_person/active_family/etc. are declared as bare static + annotations (no value) rather than bound to a live template + DataDict2 instance. A live template would need jedi to actually call + DataDict2's computed @property methods (father, birth, ...) to see + what they return -- real SimpleAccess execution that, for a blank + template with nothing to find, only yields empty completions anyway. + """ + + def _complete(self, source): + lines = source.splitlines() + return get_completions(source, len(lines), len(lines[-1]), {}) + + def test_completes_active_person_directly(self): + names = self._complete("active_person.") + self.assertIn("primary_name", names) + self.assertIn("gramps_id", names) + + def test_completes_through_computed_property_chain(self): + # father is a DataDict2 @property, not a raw schema field -- + # this only works because it's typed in the stub, not executed. + names = self._complete("active_person.father.primary_name.first_") + self.assertEqual(names, ["first_name"]) + + def test_distinguishes_active_family_from_active_person(self): + names = self._complete("active_family.") + self.assertIn("father_handle", names) + self.assertNotIn("primary_name", names) + + +class TestTableFunctionCompletion(unittest.TestCase): + """ + selected()/filtered()/custom_filter() pick their row type from a + runtime string argument, which jedi cannot discriminate via + typing.overload + Literal (verified empirically -- it merges every + overload regardless of the literal passed). These are typed as + returning the union of every row type instead, so completion still + offers real fields rather than nothing. + """ + + def _complete(self, source): + lines = source.splitlines() + return get_completions(source, len(lines), len(lines[-1]), {}) + + def test_selected_offers_real_fields(self): + names = self._complete('for person in selected("Person"):\n person.') + self.assertIn("primary_name", names) + + def test_filtered_offers_real_fields(self): + names = self._complete('for fam in filtered("Family"):\n fam.') + self.assertIn("father_handle", names) + + def test_custom_filter_offers_real_fields_with_default_namespace(self): + names = self._complete('for person in custom_filter("example filter"):\n person.') + self.assertIn("primary_name", names) + + def test_custom_filter_offers_real_fields_with_explicit_namespace(self): + names = self._complete('for fam in custom_filter("f", "Family"):\n fam.') + self.assertIn("father_handle", names) + + +class TestVoidFunctionCompletion(unittest.TestCase): + """ + columns()/begin_changes()/end_changes()/delete()/row()/chart() are void + DSL functions (VOID_FUNCTIONS) -- they don't need row-type inference, + just a signature so jedi offers them as completions at all. Before + these were added to the stub, jedi had no way to know these names exist + since they're bound as local closures inside execute_code(), never + passed through the completion namespace. + """ + + def _complete(self, source): + lines = source.splitlines() + return get_completions(source, len(lines), len(lines[-1]), {}) + + def test_completes_void_function_names(self): + for name in VOID_FUNCTIONS: + with self.subTest(name=name): + names = self._complete(name[:-1]) + self.assertIn(name + "()", names) + + +if __name__ == "__main__": + unittest.main() diff --git a/GrampyScript/update_script_descriptions.py b/GrampyScript/update_script_descriptions.py new file mode 100644 index 0000000000..8ccd9e3519 --- /dev/null +++ b/GrampyScript/update_script_descriptions.py @@ -0,0 +1,149 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2025 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Dev tool: rebuild script_descriptions.py's SCRIPT_DESCRIPTIONS dict from the +files in scripts/. Each scripts/*.gram.py file is the source of truth for +its own entry: the title comes from the file's leading '# Title' comment, +and the description comes from its module docstring (the triple-quoted +string right below that comment). + +Run it any time you add a new example script, edit an existing script's +title or docstring, or delete a script: + + python3 update_script_descriptions.py + +The whole SCRIPT_DESCRIPTIONS body is always fully rebuilt from scripts/ -- +there is no hand-maintained text left to preserve. A script missing a +title comment or a docstring is an error, since both are now required. +The static header (license block, module docstring, imports) is kept as +whatever's already at the top of script_descriptions.py. +""" + +import ast +import glob +import os +import sys +import textwrap + +from script_utils import SCRIPTS_DIR, extract_header_comment + +DESCRIPTIONS_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "script_descriptions.py" +) + +ENTRY_INDENT = " " * 8 +TEXT_INDENT = " " * 12 +DESCRIPTION_WIDTH = 65 + + +def _load_header(path): + """Return the file text up through the "SCRIPT_DESCRIPTIONS = {" line.""" + with open(path, encoding="utf-8") as f: + source = f.read() + tree = ast.parse(source) + lines = source.splitlines(keepends=True) + + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "SCRIPT_DESCRIPTIONS" + for t in node.targets + ): + return "".join(lines[: node.lineno - 1]) + "SCRIPT_DESCRIPTIONS = {\n" + raise RuntimeError("SCRIPT_DESCRIPTIONS not found in %s" % path) + + +def _dquote(text): + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _render_call(text, width=None): + lines = textwrap.wrap(text, width=width) if width else [text] + if len(lines) <= 1: + return "_(%s)" % _dquote(text) + body = "".join( + "%s%s\n" % (TEXT_INDENT, _dquote(line + (" " if i < len(lines) - 1 else ""))) + for i, line in enumerate(lines) + ) + return "_(\n%s%s)" % (body, ENTRY_INDENT) + + +def render_entry(filename, title, description): + return " %s: (\n%s%s,\n%s%s,\n ),\n" % ( + _dquote(filename), + ENTRY_INDENT, + _render_call(title), + ENTRY_INDENT, + _render_call(description, width=DESCRIPTION_WIDTH), + ) + + +def collect_entries(): + """ + Returns (entries, errors), where entries maps filename -> (title, + description) read from scripts/*.gram.py, and errors lists filenames + missing a title comment or a docstring. + """ + entries = {} + errors = [] + for path in sorted(glob.glob(os.path.join(SCRIPTS_DIR, "*.gram.py"))): + filename = os.path.basename(path) + with open(path, encoding="utf-8") as f: + source = f.read() + title = extract_header_comment(source) + description = ast.get_docstring(ast.parse(source), clean=True) + if description: + description = " ".join(description.split()) + if not title: + errors.append("%s: missing a leading '# Title' comment" % filename) + if not description: + errors.append("%s: missing a description docstring" % filename) + if title and description: + entries[filename] = (title, description) + return entries, errors + + +def build_source(entries, header): + body = "".join( + render_entry(filename, title, description) + for filename, (title, description) in entries.items() + ) + return header + body + "}\n" + + +def main(): + entries, errors = collect_entries() + if errors: + print("Cannot regenerate script_descriptions.py:") + for error in errors: + print(" ! %s" % error) + return 1 + + header = _load_header(DESCRIPTIONS_PATH) + new_source = build_source(entries, header) + + with open(DESCRIPTIONS_PATH, "w", encoding="utf-8") as fp: + fp.write(new_source) + + print("Regenerated script_descriptions.py from %d scripts." % len(entries)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/PDFForms/PDFForms.gpr.py b/PDFForms/PDFForms.gpr.py index 87416acedf..ef5f82ec0b 100644 --- a/PDFForms/PDFForms.gpr.py +++ b/PDFForms/PDFForms.gpr.py @@ -26,7 +26,7 @@ "Generate blank fillable PDF forms: census/event forms or " "Ahnentafel pedigree charts." ), - version = '1.0.4', + version = '1.0.5', gramps_target_version="6.1", status=STABLE, fname="generatepdfform.py", @@ -38,6 +38,7 @@ tool_modes=[TOOL_MODE_GUI], requires_mod=["reportlab"], depends_on=["Form Gramplet"], + help_url="Addon:PDFForms", ) register( @@ -48,7 +49,7 @@ "Import genealogy data from a PDF form. " "Send the PDF template to others to fill out and return." ), - version = '1.0.4', + version = '1.0.5', gramps_target_version="6.1", status=STABLE, fname="importpdf.py", @@ -56,4 +57,5 @@ extension="pdf", requires_mod=["pypdf"], depends_on=["Form Gramplet"], + help_url="Addon:PDFForms", ) diff --git a/Themes/tests/__init__.py b/Themes/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Themes/tests/test_initial_panel.py b/Themes/tests/test_initial_panel.py new file mode 100644 index 0000000000..597bbcc3aa --- /dev/null +++ b/Themes/tests/test_initial_panel.py @@ -0,0 +1,125 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Gramps Development Team +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Regression test for the "initial_panel" preferences crash. + +Gramps core's ``GrampsPreferences.__init__`` (gramps/gui/configure.py) +gained an ``initial_panel`` keyword argument so callers like +``ViewManager.preferences_activate`` can open the dialog directly on a +specific panel. The Themes addon replaces ``GrampsPreferences.__init__`` +with ``MyPrefs.__init__`` (see ``themes_load.py``), which did not accept +the new keyword, raising: + + TypeError: MyPrefs.__init__() got an unexpected keyword argument + 'initial_panel' + +every time preferences were opened from a context that passes +``initial_panel`` (e.g. a "Configure" button tied to a specific panel). + +Construct ``MyPrefs`` via ``__new__`` and stub out +``ConfigureDialog.__init__``/``setup_configs`` (they build a real GTK +dialog and are irrelevant to this bug) so the test stays a fast, headless +unit test. +""" + +import os +import sys +import unittest +from unittest import mock + +# Pin Gtk to 3.0 before importing -- themes.py imports +# gi.repository.Gdk.Screen directly, which GTK 4's Gdk does not provide. +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +# Make sure the addon module is importable from the parent directory. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import themes # pylint: disable=wrong-import-position + + +def _make_prefs(): + """Return a bare MyPrefs instance (no real __init__ run yet).""" + return themes.MyPrefs.__new__(themes.MyPrefs) + + +class TestMyPrefsInitialPanel(unittest.TestCase): + """Regression guard for the initial_panel TypeError.""" + + def test_init_accepts_initial_panel_keyword(self): + """MyPrefs.__init__ must accept the initial_panel keyword that + GrampsPreferences.__init__ now takes; otherwise + ViewManager.preferences_activate's call raises TypeError.""" + import inspect + + sig = inspect.signature(themes.MyPrefs.__init__) + self.assertIn("initial_panel", sig.parameters) + self.assertIsNone(sig.parameters["initial_panel"].default) + + def test_init_selects_requested_panel(self): + """Passing initial_panel='colors' must select that panel, the + same behaviour ViewManager relies on for GrampsPreferences.""" + prefs = _make_prefs() + + def fake_configure_init(self, *_args, **_kwargs): + self.window = mock.MagicMock() + + with mock.patch.object( + themes.ConfigureDialog, "__init__", fake_configure_init + ), mock.patch.object( + themes.MyPrefs, "setup_configs", mock.MagicMock(), create=True + ), mock.patch.object( + themes.MyPrefs, "select_panel", mock.MagicMock(), create=True + ) as select_panel: + themes.MyPrefs.__init__( + prefs, mock.MagicMock(), mock.MagicMock(), initial_panel="colors" + ) + + select_panel.assert_called_once_with("colors") + + def test_init_without_initial_panel_does_not_select(self): + """The default (no initial_panel) must behave exactly as + before: no panel selection call, dialog opens on its default + page.""" + prefs = _make_prefs() + + def fake_configure_init(self, *_args, **_kwargs): + self.window = mock.MagicMock() + + with mock.patch.object( + themes.ConfigureDialog, "__init__", fake_configure_init + ), mock.patch.object( + themes.MyPrefs, "setup_configs", mock.MagicMock(), create=True + ), mock.patch.object( + themes.MyPrefs, "select_panel", mock.MagicMock(), create=True + ) as select_panel: + themes.MyPrefs.__init__(prefs, mock.MagicMock(), mock.MagicMock()) + + select_panel.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/Themes/themes.gpr.py b/Themes/themes.gpr.py index 24818b0fd8..4d67cc76f1 100644 --- a/Themes/themes.gpr.py +++ b/Themes/themes.gpr.py @@ -31,7 +31,7 @@ "An addition to Preferences for simple Theme and Font" " adjustment. Especially useful for Windows users." ), - version = '0.0.20', + version = '0.0.21', gramps_target_version="6.1", fname="themes_load.py", authors=["Paul Culley"], diff --git a/Themes/themes.py b/Themes/themes.py index 5438fec68d..2ea7ce0e17 100644 --- a/Themes/themes.py +++ b/Themes/themes.py @@ -65,7 +65,7 @@ class MyPrefs(GrampsPreferences): ''' Adds a new line of controls to the 'Colors' preferences panel. Theme, dark-theme and Font choices are added. ''' - def __init__(self, uistate, dbstate): + def __init__(self, uistate, dbstate, initial_panel=None): ''' this replaces the GrampsPreferences __init__ It includes the patching fixes and calls my version of the Theme panel ''' @@ -131,6 +131,8 @@ def __init__(self, uistate, dbstate): help_btn.connect( 'clicked', lambda x: display_help(WIKI_HELP_PAGE, WIKI_HELP_SEC)) self.setup_configs('interface.grampspreferences', 700, 450) + if initial_panel and hasattr(self, 'select_panel'): + self.select_panel(initial_panel) def add_themes_panel(self, configdialog): ''' This adds a Theme panel ''' diff --git a/WordleGramplet/WordleGramplet.gpr.py b/WordleGramplet/WordleGramplet.gpr.py deleted file mode 100644 index 26f0b06bb4..0000000000 --- a/WordleGramplet/WordleGramplet.gpr.py +++ /dev/null @@ -1,16 +0,0 @@ -register( - GRAMPLET, - id="Wordle Gramplet", - name=_("Wordle"), - status=UNSTABLE, - include_in_listing=False, - fname="WordleGramplet.py", - height=230, - gramplet="WordleGramplet", - gramplet_title=_("Wordle"), - gramps_target_version="6.1", - version = '1.0.30', - description=_("Gramplet used to make word clouds with wordle.net"), - authors=["Douglas Blank"], - authors_email=["doug.blank@gmail.com"], -) diff --git a/WordleGramplet/WordleGramplet.py b/WordleGramplet/WordleGramplet.py deleted file mode 100644 index 86f40db04c..0000000000 --- a/WordleGramplet/WordleGramplet.py +++ /dev/null @@ -1,172 +0,0 @@ -# Gramps - a GTK+/GNOME based genealogy program -# -# Copyright (C) 2007-2009 Douglas S. Blank -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -# $Id: WordleGramplet.py 13416 2009-10-25 20:29:45Z dsblank $ - - -#------------------------------------------------------------------------ -# -# GRAMPS modules -# -#------------------------------------------------------------------------ -from gramps.gen.plug import Gramplet -from gramps.gen.plug.report import utils as ReportUtils -from gramps.gen.const import GRAMPS_LOCALE as glocale -try: - _trans = glocale.get_addon_translator(__file__) -except ValueError: - _trans = glocale.translation -_ = _trans.gettext - -#------------------------------------------------------------------------ -# -# Constants -# -#------------------------------------------------------------------------ - -_YIELD_INTERVAL = 350 - -#------------------------------------------------------------------------ -# -# Constants -# -#------------------------------------------------------------------------ -def get_bin(n, counts, mins=8, maxs=20): - diff = maxs - mins - # based on counts (biggest to smallest) - if len(counts) > 1: - position = diff - (diff * (float(counts.index(n)) / (len(counts) - 1))) - else: - position = 0 - return int(position) + mins - -#------------------------------------------------------------------------ -# -# Gramplet class -# -#------------------------------------------------------------------------ -class WordleGramplet(Gramplet): - def init(self): - self.set_tooltip(_("Double-click surname for details")) - self.top_size = 329 # 10 # will be overwritten in load - self.set_text(_("No Family Tree loaded.")) - - def db_changed(self): - self.connect(self.dbstate.db, 'person-add', self.update) - self.connect(self.dbstate.db, 'person-delete', self.update) - self.connect(self.dbstate.db, 'person-update', self.update) - self.connect(self.dbstate.db, 'person-rebuild', self.update) - self.connect(self.dbstate.db, 'family-rebuild', self.update) - - def on_load(self): - if len(self.gui.data) > 0: - self.top_size = int(self.gui.data[0]) - - def on_save(self): - self.gui.data = [self.top_size] - - def main(self): - self.set_text(_("Processing...") + "\n") - surnames = {} - iter_people = self.dbstate.db.iter_person_handles() - self.filter = self.filter_list.get_filter() - people = self.filter.apply(self.dbstate.db, iter_people) - cnt = 0 - for person in map(self.dbstate.db.get_person_from_handle, people): - allnames = [person.get_primary_name()] + person.get_alternate_names() - allnames = set([name.get_group_name().strip() for name in allnames]) - for surname in allnames: - surnames[surname] = surnames.get(surname, 0) + 1 - cnt += 1 - if not cnt % _YIELD_INTERVAL: - yield True - - total_people = cnt - surname_sort = [] - total = 0 - - cnt = 0 - for surname in surnames: - surname_sort.append( (surnames[surname], surname) ) - total += surnames[surname] - cnt += 1 - if not cnt % _YIELD_INTERVAL: - yield True - - total_surnames = cnt - surname_sort.sort(reverse=True) - - counts = list(set([pair[0] for pair in surname_sort])) - counts.sort(reverse=True) - line = 0 - ### All done! - self.set_text("For Wordle: \n\n") - nosurname = _("[Missing]") - for (count, surname) in surname_sort: - bin = get_bin(count, counts, mins=1, maxs=self.bins.get_value()) - text = "%s: %d\n" % ((surname if surname else nosurname), bin) - self.append_text(text) - line += 1 - if line >= self.top_size: - break - self.append_text(("\n" + _("Total unique surnames") + ": %d\n") % - total_surnames) - self.append_text((_("Total people") + ": %d") % total_people, "begin") - - def build_options(self): - from gramps.gen.plug.menu import FilterOption, PersonOption, NumberOption - self.bins = NumberOption(_("Number of font sizes"), 5, 1, 10) - self.add_option(self.bins) - - self.filter_list = FilterOption(_("Filter"), 0) - self.filter_list.set_help(_("Select filter to restrict list")) - self.filter_list.connect('value-changed', self.filter_changed) - self.add_option(self.filter_list) - - self.pid_list = PersonOption(_("Filter Person")) - self.pid_list.set_help(_("The center person for the filter")) - self.pid_list.connect('value-changed', self.update_filters) - self.add_option(self.pid_list) - - self.update_filters() - - def update_filters(self): - """ - Update the filter list based on the selected person - """ - gid = self.pid_list.get_value() - try: - person = self.dbstate.db.get_person_from_gramps_id(gid) - except: - return - filters = ReportUtils.get_person_filters(person, False) - self.filter_list.set_filters(filters) - - def filter_changed(self): - """ - Handle filter change. If the filter is not specific to a person, - disable the person option - """ - filter_value = self.filter_list.get_value() - if 1 <= filter_value <= 4: - # Filters 1, 2, 3 and 4 rely on the center person - self.pid_list.set_available(True) - else: - # The rest don't - self.pid_list.set_available(False) - diff --git a/WordleGramplet/po/da-local.po b/WordleGramplet/po/da-local.po deleted file mode 100644 index 2e788ca381..0000000000 --- a/WordleGramplet/po/da-local.po +++ /dev/null @@ -1,30 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-02-25 16:12+0000\n" -"Last-Translator: Kaj Arne Mikkelsen \n" -"Language-Team: Danish \n" -"Language: da\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.10.2-dev\n" - -msgid "[Missing]" -msgstr "[Mangler]" - -msgid "Number of font sizes" -msgstr "Antal af fontstørrelser" - -msgid "Select filter to restrict list" -msgstr "Vælg filter til afgrænse listen" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet der benyttes til at danne ordskyer fra wordle.net" diff --git a/WordleGramplet/po/de-local.po b/WordleGramplet/po/de-local.po deleted file mode 100644 index da8e305f1b..0000000000 --- a/WordleGramplet/po/de-local.po +++ /dev/null @@ -1,30 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: de\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-05-17 08:47+0000\n" -"Last-Translator: Mirko Leonhäuser \n" -"Language-Team: German \n" -"Language: de\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.12-dev\n" - -msgid "[Missing]" -msgstr "[Fehlt]" - -msgid "Number of font sizes" -msgstr "Anzahl der Schriftgrößen" - -msgid "Select filter to restrict list" -msgstr "Filter auswählen, um Liste einzuschränken" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet verwendet, um Wortwolken mit wordle.net zu erstellen" diff --git a/WordleGramplet/po/es-local.po b/WordleGramplet/po/es-local.po deleted file mode 100644 index 7e50e7b2c8..0000000000 --- a/WordleGramplet/po/es-local.po +++ /dev/null @@ -1,18 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: GRAMPS 3.1\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-06-14 20:06+0000\n" -"Last-Translator: Adolfo Jayme Barrientos \n" -"Language-Team: Spanish \n" -"Language: es\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.12-dev\n" - -msgid "Select filter to restrict list" -msgstr "Seleccione un filtro para restringir la lista" diff --git a/WordleGramplet/po/fi-local.po b/WordleGramplet/po/fi-local.po deleted file mode 100644 index ba64926152..0000000000 --- a/WordleGramplet/po/fi-local.po +++ /dev/null @@ -1,32 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: fi\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-09-03 21:58+0000\n" -"Last-Translator: Matti Niemelä \n" -"Language-Team: Finnish \n" -"Language: fi\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.13.1-rc\n" -"Generated-By: pygettext.py 1.4\n" - -msgid "[Missing]" -msgstr "[Puuttuu]" - -msgid "Number of font sizes" -msgstr "Kirjainkokojen määrä" - -msgid "Select filter to restrict list" -msgstr "Valitse suodatin luettelon rajaamiseksi" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "" -"Grampletti, jota on käytetty sanapilvien tekemiseen wordle.net-sivustolla" diff --git a/WordleGramplet/po/fr-local.po b/WordleGramplet/po/fr-local.po deleted file mode 100644 index 361263ead8..0000000000 --- a/WordleGramplet/po/fr-local.po +++ /dev/null @@ -1,30 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: trunk\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-04-11 12:50+0000\n" -"Last-Translator: jmichault \n" -"Language-Team: French \n" -"Language: fr\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n!=1);\n" -"X-Generator: Weblate 5.11-dev\n" - -msgid "[Missing]" -msgstr "[Absent]" - -msgid "Number of font sizes" -msgstr "Nombre de tailles de police" - -msgid "Select filter to restrict list" -msgstr "Sélectionnez un filtre pour restreindre la liste" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet utilisé pour faire des nuages de mots avec wordle.net" diff --git a/WordleGramplet/po/he-local.po b/WordleGramplet/po/he-local.po deleted file mode 100644 index 8839d8b55e..0000000000 --- a/WordleGramplet/po/he-local.po +++ /dev/null @@ -1,31 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: Gramps 5.2.0 – mediamerge\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2026-06-10 19:07+0000\n" -"Last-Translator: Avi Markovitz \n" -"Language-Team: Hebrew \n" -"Language: he\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && " -"n % 10 == 0) ? 2 : 3));\n" -"X-Generator: Weblate 2026.6\n" - -msgid "[Missing]" -msgstr "[חסר]" - -msgid "Number of font sizes" -msgstr "מספר גדלי גופנים" - -msgid "Select filter to restrict list" -msgstr "בחירת מסנן להגבלת רשימה" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "גרמפלט ליצירת ענני מילים באמצעות wordle.net" diff --git a/WordleGramplet/po/it-local.po b/WordleGramplet/po/it-local.po deleted file mode 100644 index d1b26ed321..0000000000 --- a/WordleGramplet/po/it-local.po +++ /dev/null @@ -1,27 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: gramps 3\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2026-06-17 20:01+0000\n" -"Last-Translator: Paolo Zamponi \n" -"Language-Team: Italian \n" -"Language: it\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 2026.7.dev0\n" - -msgid "[Missing]" -msgstr "[Mancante]" - -msgid "Number of font sizes" -msgstr "Totale dimensioni dei caratteri" - -msgid "Select filter to restrict list" -msgstr "Seleziona filtro per restringere gli elenchi" - -msgid "Wordle" -msgstr "Wordle" diff --git a/WordleGramplet/po/lt-local.po b/WordleGramplet/po/lt-local.po deleted file mode 100644 index 54f4c266b4..0000000000 --- a/WordleGramplet/po/lt-local.po +++ /dev/null @@ -1,25 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: lt\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-09-14 11:02+0000\n" -"Last-Translator: Tadas Masiulionis \n" -"Language-Team: Lithuanian \n" -"Language: lt\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"(n%100<10 || n%100>=20) ? 1 : 2);\n" -"X-Generator: Weblate 5.14-dev\n" -"Generated-By: pygettext.py 1.4\n" -"X-Poedit-Language: Lithuanian\n" -"X-Poedit-Country: LITHUANIA\n" - -msgid "Number of font sizes" -msgstr "Šrifto dydžių skaičius" - -msgid "Select filter to restrict list" -msgstr "Pasirinkite filtrą, kad apribotumėte sąrašą" diff --git a/WordleGramplet/po/nl-local.po b/WordleGramplet/po/nl-local.po deleted file mode 100755 index bc038627e3..0000000000 --- a/WordleGramplet/po/nl-local.po +++ /dev/null @@ -1,30 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: MediaMerge 5.x\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-11-08 17:51+0000\n" -"Last-Translator: Stephan Paternotte \n" -"Language-Team: Dutch \n" -"Language: nl\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.15-dev\n" - -msgid "[Missing]" -msgstr "[Ontbreekt]" - -msgid "Number of font sizes" -msgstr "Aantal lettergroottes" - -msgid "Select filter to restrict list" -msgstr "Selecteer een filter om de lijst te beperken" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet gebruikt om woordwolken te maken met wordle.net" diff --git a/WordleGramplet/po/pt_PT-local.po b/WordleGramplet/po/pt_PT-local.po deleted file mode 100644 index 33d43706b7..0000000000 --- a/WordleGramplet/po/pt_PT-local.po +++ /dev/null @@ -1,30 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: gramps51\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-03-08 07:05+0000\n" -"Last-Translator: Pedro Albuquerque \n" -"Language-Team: Portuguese (Portugal) \n" -"Language: pt_PT\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.10.3-dev\n" - -msgid "[Missing]" -msgstr "(em falta)" - -msgid "Number of font sizes" -msgstr "Número de tamanhos de letra" - -msgid "Select filter to restrict list" -msgstr "Seleccione um filtro para limitar a lista" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet para construir nuvens de palavras com wordle.net" diff --git a/WordleGramplet/po/ru-local.po b/WordleGramplet/po/ru-local.po deleted file mode 100644 index 5c7c9afe0a..0000000000 --- a/WordleGramplet/po/ru-local.po +++ /dev/null @@ -1,32 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: gramps50\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2018-12-04 16:36+0300\n" -"Last-Translator: Ivan Komaritsyn \n" -"Language-Team: Russian\n" -"Language: ru\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: Gtranslator 2.91.7\n" -"X-Poedit-Language: Russian\n" -"X-Poedit-Country: RUSSIAN FEDERATION\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" - -msgid "[Missing]" -msgstr "[Отсутствует]" - -msgid "Number of font sizes" -msgstr "Размер шрифта" - -msgid "Select filter to restrict list" -msgstr "Выберите фильтр для сокращения списка" - -msgid "Wordle" -msgstr "Облако слов" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Грамплет создающий облако слов с помощью wordle.net" diff --git a/WordleGramplet/po/sk-local.po b/WordleGramplet/po/sk-local.po deleted file mode 100644 index 770e096119..0000000000 --- a/WordleGramplet/po/sk-local.po +++ /dev/null @@ -1,30 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: GRAMPS 3.1.3\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-03-08 15:16+0000\n" -"Last-Translator: Milan \n" -"Language-Team: Slovak \n" -"Language: sk\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);\n" -"X-Generator: Weblate 5.10.3-dev\n" - -msgid "[Missing]" -msgstr "Chýbajúci]" - -msgid "Number of font sizes" -msgstr "Počet veľkostí písma" - -msgid "Select filter to restrict list" -msgstr "Vyberte filter na vymedzenie zoznamu" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet používaný na vytváranie oblakov slov s wordle.net" diff --git a/WordleGramplet/po/sv-local.po b/WordleGramplet/po/sv-local.po deleted file mode 100644 index 70112672ff..0000000000 --- a/WordleGramplet/po/sv-local.po +++ /dev/null @@ -1,30 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-03-06 17:38+0000\n" -"Last-Translator: Pär Ekholm \n" -"Language-Team: Swedish \n" -"Language: sv\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.10.3-dev\n" - -msgid "[Missing]" -msgstr "[Saknas]" - -msgid "Number of font sizes" -msgstr "Antal typsnittsstorlekar" - -msgid "Select filter to restrict list" -msgstr "Välj ett filter för att begränsa lista" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet för att skapa ordmoln med wordle.net" diff --git a/WordleGramplet/po/template.pot b/WordleGramplet/po/template.pot deleted file mode 100644 index 01d3d81c98..0000000000 --- a/WordleGramplet/po/template.pot +++ /dev/null @@ -1,71 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" - -#: WordleGramplet/WordleGramplet.py:72 -msgid "Double-click surname for details" -msgstr "" - -#: WordleGramplet/WordleGramplet.py:74 -msgid "No Family Tree loaded." -msgstr "" - -#: WordleGramplet/WordleGramplet.py:91 -msgid "Processing..." -msgstr "" - -#: WordleGramplet/WordleGramplet.py:126 -msgid "[Missing]" -msgstr "" - -#: WordleGramplet/WordleGramplet.py:134 -msgid "Total unique surnames" -msgstr "" - -#: WordleGramplet/WordleGramplet.py:136 -msgid "Total people" -msgstr "" - -#: WordleGramplet/WordleGramplet.py:140 -msgid "Number of font sizes" -msgstr "" - -#: WordleGramplet/WordleGramplet.py:143 -msgid "Filter" -msgstr "" - -#: WordleGramplet/WordleGramplet.py:144 -msgid "Select filter to restrict list" -msgstr "" - -#: WordleGramplet/WordleGramplet.py:148 -msgid "Filter Person" -msgstr "" - -#: WordleGramplet/WordleGramplet.py:149 -msgid "The center person for the filter" -msgstr "" - -#: WordleGramplet/WordleGramplet.gpr.py:4 -#: WordleGramplet/WordleGramplet.gpr.py:10 -msgid "Wordle" -msgstr "" - -#: WordleGramplet/WordleGramplet.gpr.py:13 -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "" diff --git a/WordleGramplet/po/uk-local.po b/WordleGramplet/po/uk-local.po deleted file mode 100644 index c4a803c3aa..0000000000 --- a/WordleGramplet/po/uk-local.po +++ /dev/null @@ -1,32 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-03-06 13:57+0000\n" -"Last-Translator: Yurii Liubymyi \n" -"Language-Team: Ukrainian \n" -"Language: uk\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"X-Generator: Weblate 5.10.3-dev\n" - -msgid "[Missing]" -msgstr "[Відсутнє]" - -msgid "Number of font sizes" -msgstr "Розмір шрифту" - -msgid "Select filter to restrict list" -msgstr "Виберіть фільтр для обмеження списку" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "" -"Gramplet використовується для створення хмар слів за допомогою wordle.net" diff --git a/WordleGramplet/tests/test_wordlegramplet_imports.py b/WordleGramplet/tests/test_wordlegramplet_imports.py deleted file mode 100644 index d1749e8c50..0000000000 --- a/WordleGramplet/tests/test_wordlegramplet_imports.py +++ /dev/null @@ -1,91 +0,0 @@ -# -# Gramps - a GTK+/GNOME based genealogy program -# -# Copyright (C) 2026 Gramps Development Team -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -# - -""" -Regression test for WordleGramplet plugin-registration imports. - -Historically ``WordleGramplet/WordleGramplet.py`` had two import -problems that broke plugin registration on Python 3: - - - ``from itertools import imap`` (``imap`` is a Py2 builtin - removed in Py3 — ``map`` is already lazy on Py3). - - ``from gen.plug import Gramplet`` and two other ``gen.plug.*`` - imports — Gramps-3 era pre-namespace paths that no longer - resolve in Gramps 5+ (the modules live under ``gramps.gen.*``). - -The addon failed plugin registration with ``cannot import name -'imap' from 'itertools'`` (the first error Python hit); once that -was fixed in isolation the next line down then raised -``ModuleNotFoundError: No module named 'gen'``. This test pins -down that the module imports cleanly end-to-end. -""" - -import os -import sys -import unittest - -# Pin Gtk to 3.0 before importing — the gramps.gen.plug import -# chain transitively touches GTK-3-only enums in gramps.gui. -# Skip cleanly if GTK 3 is not available. -try: - import gi - - gi.require_version("Gtk", "3.0") - gi.require_version("Gdk", "3.0") -except (ImportError, ValueError) as err: - raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) - -# Make sure addon modules are importable from the parent directory. -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - -class TestWordleGrampletImports(unittest.TestCase): - """Regression: the module must import on Python 3 / Gramps 5+.""" - - def test_module_imports_and_exposes_class(self): - """WordleGramplet.py must import cleanly and expose its - ``WordleGramplet`` class as a Gramplet subclass. - - Before the migration this fails with either - ``ImportError: cannot import name 'imap' from 'itertools'`` - (on the unfixed tree) or - ``ModuleNotFoundError: No module named 'gen'`` (after the - narrow imap-only fix in this PR's earlier revision). - """ - # Addon dir and impl module share the name ``WordleGramplet``; - # under dotted-path loading the dir becomes a namespace - # package, so use the explicit submodule path. (Same trap as - # libaccess; see gramps bug 0012691 family.) - from WordleGramplet import WordleGramplet as mod - - self.assertTrue( - hasattr(mod, "WordleGramplet"), - "WordleGramplet class must be defined after import", - ) - from gramps.gen.plug import Gramplet - - self.assertTrue( - issubclass(mod.WordleGramplet, Gramplet), - "WordleGramplet must be a Gramplet subclass", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/lxml/etreeGramplet.gpr.py b/lxml/etreeGramplet.gpr.py index 8a6e281bad..4297d36898 100644 --- a/lxml/etreeGramplet.gpr.py +++ b/lxml/etreeGramplet.gpr.py @@ -11,7 +11,7 @@ description=_("Gramplet for testing etree with Gramps XML"), status=EXPERIMENTAL, audience = DEVELOPER, - version = '1.2.5', + version = '1.2.6', gramps_target_version="6.1", include_in_listing=True, height=400, diff --git a/lxml/lxmlGramplet.gpr.py b/lxml/lxmlGramplet.gpr.py index d12d6abd89..3bd4f4d807 100644 --- a/lxml/lxmlGramplet.gpr.py +++ b/lxml/lxmlGramplet.gpr.py @@ -11,7 +11,7 @@ description=_("Gramplet for testing lxml and XSLT"), status=EXPERIMENTAL, audience = DEVELOPER, -version = '1.2.5', +version = '1.2.6', gramps_target_version="6.1", include_in_listing=True, height=400, diff --git a/lxml/lxmlGramplet.py b/lxml/lxmlGramplet.py index 1888339c6f..8f2aac75c7 100644 --- a/lxml/lxmlGramplet.py +++ b/lxml/lxmlGramplet.py @@ -69,7 +69,6 @@ GZIP_OK = True except ImportError: GZIP_OK = False - ErrorDialog(_('Where is gzip?'), _('"gzip" is missing')) LOG.error('No gzip') #------------------------------------------------------------------------- @@ -91,8 +90,7 @@ LIBXSLT_VERSION = etree.LIBXSLT_VERSION except ImportError: LXML_OK = False - ErrorDialog(_('Missing python3 lxml'), _('Please, try to install "python3 lxml" package.')) - LOG.debug('No lxml') + LOG.warning('No lxml; XPATH/XSLT features are unavailable') #------------------------------------------------------------------------- # @@ -138,6 +136,16 @@ def init(self): a Run button. """ + # Report a missing optional dependency here — when the gramplet is + # actually opened and the GUI is running — rather than as a blocking + # modal dialog at module import time (which stalls plugin loading and + # can appear with no GUI, e.g. under the CLI or the test harness). + if not GZIP_OK: + ErrorDialog(_('Where is gzip?'), _('"gzip" is missing')) + if not LXML_OK: + ErrorDialog(_('Missing python3 lxml'), + _('Please, try to install "python3 lxml" package.')) + self.xmllint = "--nonet " # space at the end for additional options self.noout = True self.dropdtd = True