From b1b5a205699a48351de76eed289af06a87873c1b Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 11:09:45 -0700 Subject: [PATCH 01/65] Add examples/scripts folder for GrampyScript with Open-dialog previews Ships 10 worked example .gram.py scripts in a shared scripts/ folder that also serves as the default Open/Save location, so a user's own scripts naturally collect next to them. Descriptions live in a translatable script_descriptions.py module (picked up by the addon's existing xgettext pipeline) and are shown as a preview when browsing the Open dialog, with a fallback to a script's own leading comment for uncatalogued files. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 57 +++++++++ GrampyScript/MANIFEST | 2 + GrampyScript/script_descriptions.py | 113 ++++++++++++++++++ GrampyScript/scripts/01_list_people.gram.py | 9 ++ .../scripts/02_filter_by_surname.gram.py | 7 ++ .../scripts/03_family_overview.gram.py | 4 + .../scripts/04_gender_pie_chart.gram.py | 7 ++ GrampyScript/scripts/05_age_histogram.gram.py | 9 ++ .../06_mark_unsourced_people_private.gram.py | 12 ++ .../scripts/07_csv_ready_report.gram.py | 14 +++ .../scripts/08_active_person_summary.gram.py | 13 ++ .../scripts/09_selected_people_report.gram.py | 4 + .../10_find_missing_birth_dates.gram.py | 5 + GrampyScript/scripts/README.md | 27 +++++ .../tests/test_extract_header_comment.py | 72 +++++++++++ .../tests/test_script_descriptions.py | 55 +++++++++ 16 files changed, 410 insertions(+) create mode 100644 GrampyScript/MANIFEST create mode 100644 GrampyScript/script_descriptions.py create mode 100644 GrampyScript/scripts/01_list_people.gram.py create mode 100644 GrampyScript/scripts/02_filter_by_surname.gram.py create mode 100644 GrampyScript/scripts/03_family_overview.gram.py create mode 100644 GrampyScript/scripts/04_gender_pie_chart.gram.py create mode 100644 GrampyScript/scripts/05_age_histogram.gram.py create mode 100644 GrampyScript/scripts/06_mark_unsourced_people_private.gram.py create mode 100644 GrampyScript/scripts/07_csv_ready_report.gram.py create mode 100644 GrampyScript/scripts/08_active_person_summary.gram.py create mode 100644 GrampyScript/scripts/09_selected_people_report.gram.py create mode 100644 GrampyScript/scripts/10_find_missing_birth_dates.gram.py create mode 100644 GrampyScript/scripts/README.md create mode 100644 GrampyScript/tests/test_extract_header_comment.py create mode 100644 GrampyScript/tests/test_script_descriptions.py diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index ace7a5645..852b92aea 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -57,6 +57,7 @@ EditSource, ) from datadict2 import DataDict2, NoneData, set_sa +from script_descriptions import SCRIPT_DESCRIPTIONS _ = glocale.translation.gettext @@ -87,6 +88,27 @@ def get_columns(source, func_name): 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() + + +SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts") + + class ScriptOpenFileChooserDialog(Gtk.FileChooserDialog): def __init__(self, uistate): # type: (DisplayState) -> None @@ -116,6 +138,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): @@ -448,6 +501,8 @@ def open_script(self, widget): 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() @@ -483,6 +538,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() diff --git a/GrampyScript/MANIFEST b/GrampyScript/MANIFEST new file mode 100644 index 000000000..09e11f74c --- /dev/null +++ b/GrampyScript/MANIFEST @@ -0,0 +1,2 @@ +GrampyScript/scripts/*.gram.py +GrampyScript/scripts/README.md diff --git a/GrampyScript/script_descriptions.py b/GrampyScript/script_descriptions.py new file mode 100644 index 000000000..fbbdccef8 --- /dev/null +++ b/GrampyScript/script_descriptions.py @@ -0,0 +1,113 @@ +# +# 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/. Kept out of the .gram.py files themselves so the examples stay +free of gettext markup while still being picked up by the addon's normal +xgettext-based translation pipeline (see ../make.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." + ), + ), +} diff --git a/GrampyScript/scripts/01_list_people.gram.py b/GrampyScript/scripts/01_list_people.gram.py new file mode 100644 index 000000000..90d3fa04c --- /dev/null +++ b/GrampyScript/scripts/01_list_people.gram.py @@ -0,0 +1,9 @@ +# List All People + +for person in people(): + row( + person.gramps_id, + person.name.first_name, + person.surname.surname, + person.gender, + ) 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 000000000..a1f64deee --- /dev/null +++ b/GrampyScript/scripts/02_filter_by_surname.gram.py @@ -0,0 +1,7 @@ +# Filter By Surname + +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 000000000..9574b0a97 --- /dev/null +++ b/GrampyScript/scripts/03_family_overview.gram.py @@ -0,0 +1,4 @@ +# Family Overview + +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 000000000..cf70b8008 --- /dev/null +++ b/GrampyScript/scripts/04_gender_pie_chart.gram.py @@ -0,0 +1,7 @@ +# Gender Breakdown (Pie Chart) + +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 000000000..311f7b826 --- /dev/null +++ b/GrampyScript/scripts/05_age_histogram.gram.py @@ -0,0 +1,9 @@ +# Age At Death Histogram + +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 000000000..65dd9855b --- /dev/null +++ b/GrampyScript/scripts/06_mark_unsourced_people_private.gram.py @@ -0,0 +1,12 @@ +# Mark Unsourced People As Private + +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 000000000..2a0867b37 --- /dev/null +++ b/GrampyScript/scripts/07_csv_ready_report.gram.py @@ -0,0 +1,14 @@ +# CSV-Ready People Report + +columns("ID", "Given Name", "Surname", "Gender", "Birth Year") + +for person in people(): + birth = person.birth + birth_year = birth.get_date_object().get_year() if birth else "" + row( + person.gramps_id, + person.name.first_name, + person.surname.surname, + 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 000000000..d0c2b7cb6 --- /dev/null +++ b/GrampyScript/scripts/08_active_person_summary.gram.py @@ -0,0 +1,13 @@ +# Active Person Summary + +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 000000000..2d6084c6d --- /dev/null +++ b/GrampyScript/scripts/09_selected_people_report.gram.py @@ -0,0 +1,4 @@ +# Report On Selected People + +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 000000000..8e8b91dcf --- /dev/null +++ b/GrampyScript/scripts/10_find_missing_birth_dates.gram.py @@ -0,0 +1,5 @@ +# Find People Missing A Birth Date + +for person in people(): + if not person.birth: + row(person.gramps_id, person.name.first_name, person.surname.surname) diff --git a/GrampyScript/scripts/README.md b/GrampyScript/scripts/README.md new file mode 100644 index 000000000..294c1d2f4 --- /dev/null +++ b/GrampyScript/scripts/README.md @@ -0,0 +1,27 @@ +# 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. | + +Each script's title and description are also shown as a preview when you +highlight it in the Open dialog. + +**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/tests/test_extract_header_comment.py b/GrampyScript/tests/test_extract_header_comment.py new file mode 100644 index 000000000..dd9d2168e --- /dev/null +++ b/GrampyScript/tests/test_extract_header_comment.py @@ -0,0 +1,72 @@ +""" +Tests for the extract_header_comment() utility from GrampyScript. + +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 is pure ast/string +handling -- no GTK or Gramps imports required -- so, like get_columns, it +is tested here by re-importing it directly from the module source via +ast, 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__)))) + + +_SOURCE = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "GrampyScript.py" +) + + +def _load_extract_header_comment(): + src = open(_SOURCE).read() + tree = ast.parse(src) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "extract_header_comment": + snippet = ast.unparse(node) + ns = {} + exec(snippet, ns) + return ns["extract_header_comment"] + raise RuntimeError("extract_header_comment not found in GrampyScript.py") + + +extract_header_comment = _load_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_script_descriptions.py b/GrampyScript/tests/test_script_descriptions.py new file mode 100644 index 000000000..77284fd20 --- /dev/null +++ b/GrampyScript/tests/test_script_descriptions.py @@ -0,0 +1,55 @@ +""" +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 + +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 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): + ast.parse(open(path).read()) + + +if __name__ == "__main__": + unittest.main() From b4d78019a6ae242df755cd335e69091325b225c9 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 11:16:40 -0700 Subject: [PATCH 02/65] Move Execute button above the results notebook Places it directly below the script editor instead of below the Table/Output/Chart tabs, so it's closer to where the script is written. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index 852b92aea..b2797eea9 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -435,6 +435,23 @@ def build_gui(self): 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() @@ -455,23 +472,6 @@ 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 From 19b283374a3d3a3cf87f698f254c2b24982bc5be Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 11:19:12 -0700 Subject: [PATCH 03/65] Show the current script's filename in the status area Adds a persistent filename label on the left of the status bar (split from the transient status message via an HBox), updated whenever a script is loaded or saved, so it's always clear which script is loaded. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index b2797eea9..df95a0f8c 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -335,6 +335,7 @@ def init(self): 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) @@ -472,9 +473,6 @@ def build_gui(self): widget.pack_start(self.notebook, True, True, 0) - 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; @@ -483,14 +481,33 @@ def build_gui(self): """ 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('bordered-label') + self.filename_label.get_style_context().add_provider( + provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + 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 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, 1) + 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") + self.filename_label.set_text(name) + def new_script(self, widget): # type: (Any) -> None self.ebuf.set_text("") @@ -517,6 +534,7 @@ def open_script(self, widget): self.last_filename = filename config.set("defaults.last_filename", filename) config.save() + self.update_filename_label() self.statusmsg.set_text("Loaded %r" % self.last_filename) break @@ -554,6 +572,7 @@ def save_as_script(self, widget): self.last_filename = filename config.set("defaults.last_filename", filename) config.save() + self.update_filename_label() self.statusmsg.set_text("Saved as %r (now current)" % self.last_filename) break From 92f34057d0210b961ab8acc4e3797ca5271cdb72 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 11:23:41 -0700 Subject: [PATCH 04/65] Make the filename label bold and borderless, with more spacing The filename label was using the same bordered style as the status message, making the two indistinguishable. Give it its own bold, borderless style and more room from the status text next to it. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index df95a0f8c..55d1289ed 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -478,13 +478,17 @@ def build_gui(self): 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('bordered-label') + self.filename_label.get_style_context().add_class('bold-label') self.filename_label.get_style_context().add_provider( provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) @@ -498,7 +502,7 @@ def build_gui(self): status_box = Gtk.HBox() status_box.pack_start(self.filename_label, False, False, 1) - status_box.pack_start(self.statusmsg, True, True, 1) + status_box.pack_start(self.statusmsg, True, True, 10) widget.pack_start(status_box, False, False, 1) widget.show_all() From 64fc4715356c8a2db1c2deca018349ee2f623412 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 11:27:04 -0700 Subject: [PATCH 05/65] Prompt to save unsaved changes before New or Open Uses the buffer's built-in modified flag plus Gramps' standard SaveDialog (Save / Don't Save / Cancel) so New and Open no longer silently discard in-progress edits. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 42 +++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index 55d1289ed..0769efeec 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -44,7 +44,7 @@ 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.editors import ( EditCitation, EditEvent, @@ -348,6 +348,7 @@ def init(self): row(person) """ ) + self.ebuf.set_modified(False) def build_gui(self): """ @@ -512,13 +513,49 @@ def update_filename_label(self): name = os.path.basename(self.last_filename) if self.last_filename else _("Untitled") self.filename_label.set_text(name) + 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.ebuf.set_modified(False) self.statusmsg.set_text("Ready...") 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) @@ -534,6 +571,7 @@ 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) @@ -550,6 +588,7 @@ def save_script(self, widget): return with open(self.last_filename, "w") as fp: fp.write(self.get_text()) + self.ebuf.set_modified(False) self.statusmsg.set_text("Saved %r" % self.last_filename) def save_as_script(self, widget): @@ -573,6 +612,7 @@ 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() From c4cacef503c1652b45609b84c6adc29e82560cfc Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 11:50:30 -0700 Subject: [PATCH 06/65] Add update_script_descriptions.py to keep the examples catalog in sync Moves get_columns()/extract_header_comment() into a new script_utils.py (no GTK/Gramps imports) so both GrampyScript.py and this new dev tool can share them, and so the two tests exercising them no longer need the ast-extraction workaround. update_script_descriptions.py scans scripts/*.gram.py and keeps SCRIPT_DESCRIPTIONS in script_descriptions.py in sync: adds a stub entry for new scripts, drops entries for deleted ones, and warns (without overwriting) when a file's title comment has drifted from the catalogued title. Existing entries' exact source text is preserved via ast slicing. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 35 +--- GrampyScript/script_utils.py | 59 +++++++ .../tests/test_extract_header_comment.py | 31 +--- GrampyScript/tests/test_get_columns.py | 35 +--- GrampyScript/update_script_descriptions.py | 167 ++++++++++++++++++ 5 files changed, 239 insertions(+), 88 deletions(-) create mode 100644 GrampyScript/script_utils.py create mode 100644 GrampyScript/update_script_descriptions.py diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index 0769efeec..50714f4da 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 @@ -58,6 +57,7 @@ ) from datadict2 import DataDict2, NoneData, set_sa from script_descriptions import SCRIPT_DESCRIPTIONS +from script_utils import get_columns, extract_header_comment, SCRIPTS_DIR _ = glocale.translation.gettext @@ -76,39 +76,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 [] - - -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() - - -SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts") - - class ScriptOpenFileChooserDialog(Gtk.FileChooserDialog): def __init__(self, uistate): # type: (DisplayState) -> None diff --git a/GrampyScript/script_utils.py b/GrampyScript/script_utils.py new file mode 100644 index 000000000..9ae2032de --- /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/tests/test_extract_header_comment.py b/GrampyScript/tests/test_extract_header_comment.py index dd9d2168e..bbcf16998 100644 --- a/GrampyScript/tests/test_extract_header_comment.py +++ b/GrampyScript/tests/test_extract_header_comment.py @@ -1,40 +1,21 @@ """ -Tests for the extract_header_comment() utility from GrampyScript. +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 is pure ast/string -handling -- no GTK or Gramps imports required -- so, like get_columns, it -is tested here by re-importing it directly from the module source via -ast, without pulling in the full (GTK-dependent) GrampyScript module. +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 ast import os import sys import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -_SOURCE = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "GrampyScript.py" -) - - -def _load_extract_header_comment(): - src = open(_SOURCE).read() - tree = ast.parse(src) - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name == "extract_header_comment": - snippet = ast.unparse(node) - ns = {} - exec(snippet, ns) - return ns["extract_header_comment"] - raise RuntimeError("extract_header_comment not found in GrampyScript.py") - - -extract_header_comment = _load_extract_header_comment() +from script_utils import extract_header_comment class TestExtractHeaderComment(unittest.TestCase): diff --git a/GrampyScript/tests/test_get_columns.py b/GrampyScript/tests/test_get_columns.py index 6270d7b76..b3cdb045b 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/update_script_descriptions.py b/GrampyScript/update_script_descriptions.py new file mode 100644 index 000000000..4652e7eec --- /dev/null +++ b/GrampyScript/update_script_descriptions.py @@ -0,0 +1,167 @@ +# +# 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: keep script_descriptions.py's SCRIPT_DESCRIPTIONS dict in sync +with the files actually present in scripts/. + +Run it after adding a new example script, deleting one, or renaming one: + + python3 update_script_descriptions.py + +What it does automatically (safe, structural, nothing to lose): + - Adds a stub entry -- title taken from the new file's leading '#' + comment, description a "TODO" placeholder -- for any scripts/*.gram.py + file with no entry yet. + - Drops entries for files that no longer exist in scripts/. + +What it only *warns* about (needs a human judgment call): + - A file whose leading comment title no longer matches the title + already catalogued in SCRIPT_DESCRIPTIONS. Titles are not + auto-overwritten, since the catalogued one may have been deliberately + written differently (and richer) than the terse in-file comment. + +Existing entries' source text (title + description, translator comments, +line wrapping, quoting) is preserved byte-for-byte by slicing it straight +out of the current file with ast -- this script never touches wording it +didn't generate itself. +""" + +import ast +import glob +import os +import sys + +from script_utils import SCRIPTS_DIR, extract_header_comment + +DESCRIPTIONS_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "script_descriptions.py" +) + +STUB_DESCRIPTION = "TODO: describe what this script does." + + +def _slice_source(lines, node): + start_line, start_col = node.lineno - 1, node.col_offset + end_line, end_col = node.end_lineno - 1, node.end_col_offset + if start_line == end_line: + return lines[start_line][start_col:end_col] + parts = [lines[start_line][start_col:]] + parts.extend(lines[start_line + 1 : end_line]) + parts.append(lines[end_line][:end_col]) + return "".join(parts) + + +def _load_existing(path): + """ + Returns (header, entries) where header is the file text up through + "SCRIPT_DESCRIPTIONS = {" and entries maps filename -> (title, + raw_value_source) using the tuple's exact original source text. + """ + source = open(path, encoding="utf-8").read() + tree = ast.parse(source) + lines = source.splitlines(keepends=True) + + dict_node = None + 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 + ): + dict_node = node.value + break + if dict_node is None: + raise RuntimeError("SCRIPT_DESCRIPTIONS not found in %s" % path) + + header = "".join(lines[: dict_node.lineno - 1]) + "SCRIPT_DESCRIPTIONS = {\n" + + entries = {} + for key_node, value_node in zip(dict_node.keys, dict_node.values): + filename = ast.literal_eval(key_node) + title = value_node.elts[0].args[0].value + raw_value = _slice_source(lines, value_node) + entries[filename] = (title, raw_value) + return header, entries + + +def _stub_entry(title): + return '(\n _(%r),\n _(%r),\n )' % (title, STUB_DESCRIPTION) + + +def main(): + header, existing = _load_existing(DESCRIPTIONS_PATH) + + current_files = sorted( + os.path.basename(p) + for p in glob.glob(os.path.join(SCRIPTS_DIR, "*.gram.py")) + ) + + added, removed, retitled_warnings = [], [], [] + + body_lines = [] + for filename in current_files: + file_title = extract_header_comment( + open(os.path.join(SCRIPTS_DIR, filename)).read() + ) + if filename in existing: + title, raw_value = existing[filename] + if file_title and file_title != title: + retitled_warnings.append((filename, title, file_title)) + else: + title, raw_value = file_title or filename, _stub_entry( + file_title or filename + ) + added.append(filename) + body_lines.append(' "%s": %s,\n' % (filename, raw_value)) + + for filename in existing: + if filename not in current_files: + removed.append(filename) + + new_source = header + "".join(body_lines) + "}\n" + + with open(DESCRIPTIONS_PATH, "w", encoding="utf-8") as fp: + fp.write(new_source) + + if added: + print("Added stub entries (fill in real descriptions):") + for filename in added: + print(" + %s" % filename) + if removed: + print("Removed stale entries (file no longer in scripts/):") + for filename in removed: + print(" - %s" % filename) + if retitled_warnings: + print("Title mismatches (file comment changed, catalogued title did not):") + for filename, old_title, new_title in retitled_warnings: + print(" ! %s" % filename) + print(" catalogued: %r" % old_title) + print(" in file: %r" % new_title) + print( + " -> update the title in script_descriptions.py by hand if the " + "file's title is now the correct one." + ) + if not (added or removed or retitled_warnings): + print("script_descriptions.py is already in sync with scripts/.") + + return 1 if retitled_warnings else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 7db27469ecbe36e2a912cd6b17647d5caa1084e9 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 12:42:29 -0700 Subject: [PATCH 07/65] Add import support, custom_filter(), and delete() to GrampyScript Scripts can now import plain .py helper modules placed next to them (scripts dir and the open script's own dir are added to sys.path before exec), reuse an existing Gramps sidebar custom filter via custom_filter(), and remove an object via delete() instead of needing to know the per-class remove_* db call. Also fixes active_event to return a DataDict2 like every other active_* constant, instead of a raw handle. Adds three example scripts (and a script_helpers.py helper module) to scripts/, catalogued in script_descriptions.py and scripts/README.md. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 33 ++++++++++++++++++- GrampyScript/script_descriptions.py | 30 +++++++++++++++++ .../scripts/11_import_example.gram.py | 16 +++++++++ .../scripts/12_custom_filter_example.gram.py | 4 +++ .../13_delete_unused_repositories.gram.py | 12 +++++++ GrampyScript/scripts/README.md | 8 +++++ GrampyScript/scripts/script_helpers.py | 9 +++++ 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 GrampyScript/scripts/11_import_example.gram.py create mode 100644 GrampyScript/scripts/12_custom_filter_example.gram.py create mode 100644 GrampyScript/scripts/13_delete_unused_repositories.gram.py create mode 100644 GrampyScript/scripts/script_helpers.py diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index 50714f4da..37e98fa8f 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -33,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 @@ -275,6 +276,8 @@ def init(self): "events", "selected", "filtered", + "custom_filter", + "delete", ] self.constants = [ "True", @@ -1111,6 +1114,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: @@ -1196,7 +1208,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 @@ -1226,6 +1238,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( @@ -1243,6 +1273,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/script_descriptions.py b/GrampyScript/script_descriptions.py index fbbdccef8..9c43e50f3 100644 --- a/GrampyScript/script_descriptions.py +++ b/GrampyScript/script_descriptions.py @@ -110,4 +110,34 @@ "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/scripts/11_import_example.gram.py b/GrampyScript/scripts/11_import_example.gram.py new file mode 100644 index 000000000..8b0b97c8c --- /dev/null +++ b/GrampyScript/scripts/11_import_example.gram.py @@ -0,0 +1,16 @@ +# Births Per Decade (Import Example) + +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 000000000..da212b84b --- /dev/null +++ b/GrampyScript/scripts/12_custom_filter_example.gram.py @@ -0,0 +1,4 @@ +# Custom Filter Example + +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 000000000..d9e657efc --- /dev/null +++ b/GrampyScript/scripts/13_delete_unused_repositories.gram.py @@ -0,0 +1,12 @@ +# Delete Unused Repositories (Delete Example) + +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 index 294c1d2f4..47aab2acb 100644 --- a/GrampyScript/scripts/README.md +++ b/GrampyScript/scripts/README.md @@ -16,10 +16,18 @@ numbered files below are examples; anything else you save here is yours. | `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 diff --git a/GrampyScript/scripts/script_helpers.py b/GrampyScript/scripts/script_helpers.py new file mode 100644 index 000000000..482fd3454 --- /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 From 434f7685ddc47612172066c397027355a9e6b608 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 15:09:24 -0700 Subject: [PATCH 08/65] Add jedi-based Tab completion to the GrampyScript editor Wires a Tab-triggered, live-filtering completion popover into the script editor, covering plain Python names, the DSL's own functions (people(), custom_filter(), selected()/filtered(), ...), and nested attribute chains on Gramps records (person.primary_name.first_name), including through a user's own loop variables and list subscripts. completion.py wraps jedi.Interpreter for the actual lookups. stub_generator.py derives static type stubs straight from Gramps' own get_schema() so jedi can infer generator/loop-variable row types without ever executing anything (a live template object would require calling DataDict2's computed properties, e.g. father/birth, which only degrade to empty results for blank data anyway). namespace_builder.py supplies the remaining live objects (today, counter, database) that are safe to introspect directly. completion_popup.py is a standalone Gtk.Popover controller, kept independent of the Gramplet class so it's testable against a plain Gtk.TextView. DataDict2 gained a __dir__ override so introspection (jedi's runtime fallback) sees dynamic dict keys like primary_name, not just its declared properties. Also fixes the editor ScrolledWindow/TextView having no wrap mode or explicit scroll policy, which let long lines widen the whole gramplet instead of scrolling within it. Requires jedi (added to GrampyScript.gpr.py's requires_mod), which ships with Gramps 6.1. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.gpr.py | 1 + GrampyScript/GrampyScript.py | 17 ++ GrampyScript/completion.py | 92 ++++++ GrampyScript/completion_popup.py | 296 +++++++++++++++++++ GrampyScript/datadict2.py | 6 + GrampyScript/namespace_builder.py | 59 ++++ GrampyScript/stub_generator.py | 240 +++++++++++++++ GrampyScript/tests/test_completion.py | 145 +++++++++ GrampyScript/tests/test_completion_popup.py | 201 +++++++++++++ GrampyScript/tests/test_namespace_builder.py | 46 +++ GrampyScript/tests/test_stub_generator.py | 165 +++++++++++ 11 files changed, 1268 insertions(+) create mode 100644 GrampyScript/completion.py create mode 100644 GrampyScript/completion_popup.py create mode 100644 GrampyScript/namespace_builder.py create mode 100644 GrampyScript/stub_generator.py create mode 100644 GrampyScript/tests/test_completion.py create mode 100644 GrampyScript/tests/test_completion_popup.py create mode 100644 GrampyScript/tests/test_namespace_builder.py create mode 100644 GrampyScript/tests/test_stub_generator.py diff --git a/GrampyScript/GrampyScript.gpr.py b/GrampyScript/GrampyScript.gpr.py index 20258e0dc..55ddf10a8 100644 --- a/GrampyScript/GrampyScript.gpr.py +++ b/GrampyScript/GrampyScript.gpr.py @@ -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 37e98fa8f..377fb959d 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -59,6 +59,8 @@ 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 @@ -367,7 +369,9 @@ def build_gui(self): self.editor = Gtk.ScrolledWindow() self.editor.set_shadow_type(Gtk.ShadowType.IN) + self.editor.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) self.editor_textview = Gtk.TextView() + self.editor_textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self.editor.add(self.editor_textview) font_desc = self.editor_textview.get_pango_context().get_font_description() font_desc.set_family( @@ -377,6 +381,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 @@ -404,6 +409,9 @@ def build_gui(self): "comment", foreground="gray", style=Pango.Style.ITALIC ) 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) @@ -663,6 +671,7 @@ def copy_to_clipboard(self, widget): def on_buffer_changed(self, buffer): self.highlight_syntax() + self.completion.on_buffer_changed() def highlight_syntax(self): start_iter = self.ebuf.get_start_iter() @@ -876,10 +885,18 @@ 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 self.completion.on_key_press(event): + return True + if event.keyval == Gdk.KEY_Tab: # buffer = textview.get_buffer() iter_ = self.ebuf.get_iter_at_mark(self.ebuf.get_insert()) diff --git a/GrampyScript/completion.py b/GrampyScript/completion.py new file mode 100644 index 000000000..76950d945 --- /dev/null +++ b/GrampyScript/completion.py @@ -0,0 +1,92 @@ +# +# 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.""" + preamble = _get_stub_preamble() + full_source = preamble + source + interpreter = jedi.Interpreter(full_source, [namespace]) + try: + return interpreter.complete(line + preamble.count("\n"), column) + except Exception: + return [] + + +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 [completion.name for completion in _complete(source, line, column, namespace)] + + +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} 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. + """ + return [ + {"name": completion.name, "complete": completion.complete} + for completion in _complete(source, line, column, namespace) + ] diff --git a/GrampyScript/completion_popup.py b/GrampyScript/completion_popup.py new file mode 100644 index 000000000..21a7e4546 --- /dev/null +++ b/GrampyScript/completion_popup.py @@ -0,0 +1,296 @@ +# +# 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 open the popover at the cursor. Returns True if it + did (there was something completable to show).""" + 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 + 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"]) + 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 37df19c3a..a969311f5 100644 --- a/GrampyScript/datadict2.py +++ b/GrampyScript/datadict2.py @@ -334,6 +334,12 @@ 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: diff --git a/GrampyScript/namespace_builder.py b/GrampyScript/namespace_builder.py new file mode 100644 index 000000000..9bf9dec2b --- /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/stub_generator.py b/GrampyScript/stub_generator.py new file mode 100644 index 000000000..a054ea2c6 --- /dev/null +++ b/GrampyScript/stub_generator.py @@ -0,0 +1,240 @@ +# +# 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"'], +} + +# DataDict2's computed @property names (datadict2.py), layered onto every +# generated type since DataDict2 defines them once for every instance +# regardless of the wrapped record's real class. Best-effort types; "object" +# is used where the real return type is ambiguous or data-dependent. +COMPUTED_PROPERTIES = { + "gender": "str", + "age": "object", + "birth": "Event", + "death": "Event", + "place": "Place", + "parents": 'list["Person"]', + "father": "Person", + "mother": "Person", + "spouse": "Person", + "source": "Source", + "families": 'list["Family"]', + "parent_families": 'list["Family"]', + "children": 'list["Person"]', + "notes": 'list["Note"]', + "tags": 'list["Tag"]', + "citations": 'list["Citation"]', + "media": 'list["MediaRef"]', + "events": 'list["Event"]', + "reference": "Person", + "attributes": 'list["Attribute"]', + "addresses": 'list["Address"]', + "lds_ords": 'list["LdsOrdinance"]', + "references": 'list["PersonRef"]', + "back_references": "object", + "back_references_recursively": "object", + "name": "Name", + "surname": "Surname", + "names": 'list["Name"]', +} + +_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(), with + DataDict2's computed properties layered on top of each (matching real + attribute lookup order: properties shadow raw dict keys). + """ + registry = {} + for cls in root_classes: + _walk(cls.get_schema(), registry) + for fields in registry.values(): + fields.update(COMPUTED_PROPERTIES) + return registry + + +def render_stub_source( + registry, + generator_row_types=GENERATOR_ROW_TYPES, + table_functions=TABLE_FUNCTIONS, + active_variables=ACTIVE_VARIABLES, +): + """ + 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", + "", + ] + 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) + ) + 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 000000000..70c43a5fb --- /dev/null +++ b/GrampyScript/tests/test_completion.py @@ -0,0 +1,145 @@ +""" +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): + 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 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"}]) + + 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"}]) + + +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 000000000..bcb788daa --- /dev/null +++ b/GrampyScript/tests/test_completion_popup.py @@ -0,0 +1,201 @@ +""" +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() + + +class TestAccept(unittest.TestCase): + def test_accept_inserts_missing_suffix_only(self): + controller, buffer, window = _make_controller("active_person.primary_") + controller.trigger() + # first match should be primary_name (only dynamic key matching) + names = [item["name"] for item in controller.items] + self.assertEqual(names, ["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() + + +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_opens_then_accepts(self): + controller, buffer, window = _make_controller("active_person.primary_") + 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.assertEqual(text, "active_person.primary_name") + 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.primary_") + # 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.on_key_press(_FakeEvent(Gdk.KEY_Return))) + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + self.assertEqual(text, "active_person.primary_name") + 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.primary_") + 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_namespace_builder.py b/GrampyScript/tests/test_namespace_builder.py new file mode 100644 index 000000000..5efc008e3 --- /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_stub_generator.py b/GrampyScript/tests/test_stub_generator.py new file mode 100644 index 000000000..3467b6215 --- /dev/null +++ b/GrampyScript/tests/test_stub_generator.py @@ -0,0 +1,165 @@ +""" +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, 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_every_type(self): + # DataDict2's @property names apply to every wrapped record, not + # just Person, since it is the same class for every nested value. + for name in ["Person", "Family", "Name"]: + self.assertEqual(self.registry[name]["father"], "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) + + +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) + + +if __name__ == "__main__": + unittest.main() From f7e5b92656a266c61487c9b97d99dc5fe07f2e9d Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 15:20:27 -0700 Subject: [PATCH 09/65] Fix status label forcing the GrampyScript gramplet wider than its container statusmsg's text sometimes embeds the current file's full path (e.g. "Loaded '/home/.../scripts/some_script.gram.py'"), and an unbounded Gtk.Label requests enough natural width to fit that whole string, which was pushing the gramplet wider than its panel and forcing horizontal scrolling. Capping it with set_ellipsize()/ set_max_width_chars() bounds the natural width regardless of message content. Also reverts the wrap-mode/scroll-policy change from the previous commit, which guessed the code editor's TextView was the cause; it wasn't, and auto-wrapping code isn't desirable anyway since it breaks visual alignment of indentation. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index 377fb959d..f85fff7c7 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -369,9 +369,7 @@ def build_gui(self): self.editor = Gtk.ScrolledWindow() self.editor.set_shadow_type(Gtk.ShadowType.IN) - self.editor.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) self.editor_textview = Gtk.TextView() - self.editor_textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self.editor.add(self.editor_textview) font_desc = self.editor_textview.get_pango_context().get_font_description() font_desc.set_family( @@ -474,6 +472,15 @@ def build_gui(self): self.statusmsg = Gtk.Label(_("Ready...")) 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 From 6fef53862190dfe657be9b9dd0da972cb46eea21 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 15:39:24 -0700 Subject: [PATCH 10/65] Add a Help item to the GrampyScript Script menu Opens the addon's wiki help page (Addon:GrampyScript), reusing the same help_url the gramplet already exposes via self.gui. --- GrampyScript/GrampyScript.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index f85fff7c7..fed6a1e4f 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -45,6 +45,7 @@ from gramps.gui.utils import match_primary_mask from gramps.gen.config import config as configman from gramps.gui.dialog import OkDialog, ErrorDialog, SaveDialog +from gramps.gui.display import display_help, display_url from gramps.gui.editors import ( EditCitation, EditEvent, @@ -336,15 +337,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")) @@ -607,6 +612,13 @@ def save_as_script(self, widget): 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") From c24b6fc64011d9d6c3247b45a2304b3f2ba8533f Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 16:11:56 -0700 Subject: [PATCH 11/65] Make example script descriptions self-hosted and generated Each scripts/*.gram.py file now carries its own description as a module docstring; script_descriptions.py is fully regenerated from those docstrings (plus each file's title comment) via update_script_descriptions.py, instead of hand-maintaining translated text disconnected from the examples it describes. Also fix the editor's syntax highlighter, which had no notion of string literals and was bolding keywords found inside quoted strings (most visibly inside the new docstrings). Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 42 ++-- GrampyScript/script_descriptions.py | 92 +++++---- GrampyScript/scripts/01_list_people.gram.py | 4 + .../scripts/02_filter_by_surname.gram.py | 4 + .../scripts/03_family_overview.gram.py | 4 + .../scripts/04_gender_pie_chart.gram.py | 4 + GrampyScript/scripts/05_age_histogram.gram.py | 5 + .../06_mark_unsourced_people_private.gram.py | 5 + .../scripts/07_csv_ready_report.gram.py | 5 + .../scripts/08_active_person_summary.gram.py | 4 + .../scripts/09_selected_people_report.gram.py | 4 + .../10_find_missing_birth_dates.gram.py | 4 + .../scripts/11_import_example.gram.py | 5 + .../scripts/12_custom_filter_example.gram.py | 6 + .../13_delete_unused_repositories.gram.py | 6 + .../tests/test_script_descriptions.py | 21 ++ GrampyScript/update_script_descriptions.py | 184 ++++++++---------- 17 files changed, 243 insertions(+), 156 deletions(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index fed6a1e4f..ece0f7035 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -411,6 +411,7 @@ 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) @@ -699,37 +700,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) diff --git a/GrampyScript/script_descriptions.py b/GrampyScript/script_descriptions.py index 9c43e50f3..1e35346d3 100644 --- a/GrampyScript/script_descriptions.py +++ b/GrampyScript/script_descriptions.py @@ -19,9 +19,20 @@ """ Translatable titles and descriptions for the bundled example scripts in -scripts/. Kept out of the .gram.py files themselves so the examples stay -free of gettext markup while still being picked up by the addon's normal -xgettext-based translation pipeline (see ../make.py). +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 @@ -32,41 +43,39 @@ "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." + "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." + "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." + "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." + "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." + "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": ( @@ -81,63 +90,60 @@ "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." + "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." + "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." + "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." + "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." + "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." + "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." + "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/scripts/01_list_people.gram.py b/GrampyScript/scripts/01_list_people.gram.py index 90d3fa04c..ee569eb71 100644 --- a/GrampyScript/scripts/01_list_people.gram.py +++ b/GrampyScript/scripts/01_list_people.gram.py @@ -1,4 +1,8 @@ # 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( diff --git a/GrampyScript/scripts/02_filter_by_surname.gram.py b/GrampyScript/scripts/02_filter_by_surname.gram.py index a1f64deee..4107b6147 100644 --- a/GrampyScript/scripts/02_filter_by_surname.gram.py +++ b/GrampyScript/scripts/02_filter_by_surname.gram.py @@ -1,4 +1,8 @@ # 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" diff --git a/GrampyScript/scripts/03_family_overview.gram.py b/GrampyScript/scripts/03_family_overview.gram.py index 9574b0a97..ea8394bbb 100644 --- a/GrampyScript/scripts/03_family_overview.gram.py +++ b/GrampyScript/scripts/03_family_overview.gram.py @@ -1,4 +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 index cf70b8008..4fe1a5120 100644 --- a/GrampyScript/scripts/04_gender_pie_chart.gram.py +++ b/GrampyScript/scripts/04_gender_pie_chart.gram.py @@ -1,4 +1,8 @@ # 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(): diff --git a/GrampyScript/scripts/05_age_histogram.gram.py b/GrampyScript/scripts/05_age_histogram.gram.py index 311f7b826..b959a7587 100644 --- a/GrampyScript/scripts/05_age_histogram.gram.py +++ b/GrampyScript/scripts/05_age_histogram.gram.py @@ -1,4 +1,9 @@ # 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(): diff --git a/GrampyScript/scripts/06_mark_unsourced_people_private.gram.py b/GrampyScript/scripts/06_mark_unsourced_people_private.gram.py index 65dd9855b..1783a6d21 100644 --- a/GrampyScript/scripts/06_mark_unsourced_people_private.gram.py +++ b/GrampyScript/scripts/06_mark_unsourced_people_private.gram.py @@ -1,4 +1,9 @@ # 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") diff --git a/GrampyScript/scripts/07_csv_ready_report.gram.py b/GrampyScript/scripts/07_csv_ready_report.gram.py index 2a0867b37..14b83e2f1 100644 --- a/GrampyScript/scripts/07_csv_ready_report.gram.py +++ b/GrampyScript/scripts/07_csv_ready_report.gram.py @@ -1,4 +1,9 @@ # 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("ID", "Given Name", "Surname", "Gender", "Birth Year") diff --git a/GrampyScript/scripts/08_active_person_summary.gram.py b/GrampyScript/scripts/08_active_person_summary.gram.py index d0c2b7cb6..371315b16 100644 --- a/GrampyScript/scripts/08_active_person_summary.gram.py +++ b/GrampyScript/scripts/08_active_person_summary.gram.py @@ -1,4 +1,8 @@ # Active Person Summary +""" +Show a compact family summary for the currently active person: their record, +parents, spouse, and children. +""" person = active_person if person: diff --git a/GrampyScript/scripts/09_selected_people_report.gram.py b/GrampyScript/scripts/09_selected_people_report.gram.py index 2d6084c6d..74f840d9d 100644 --- a/GrampyScript/scripts/09_selected_people_report.gram.py +++ b/GrampyScript/scripts/09_selected_people_report.gram.py @@ -1,4 +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 index 8e8b91dcf..eb6a21555 100644 --- a/GrampyScript/scripts/10_find_missing_birth_dates.gram.py +++ b/GrampyScript/scripts/10_find_missing_birth_dates.gram.py @@ -1,4 +1,8 @@ # 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: diff --git a/GrampyScript/scripts/11_import_example.gram.py b/GrampyScript/scripts/11_import_example.gram.py index 8b0b97c8c..a01dd9089 100644 --- a/GrampyScript/scripts/11_import_example.gram.py +++ b/GrampyScript/scripts/11_import_example.gram.py @@ -1,4 +1,9 @@ # 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 diff --git a/GrampyScript/scripts/12_custom_filter_example.gram.py b/GrampyScript/scripts/12_custom_filter_example.gram.py index da212b84b..c84eeffe5 100644 --- a/GrampyScript/scripts/12_custom_filter_example.gram.py +++ b/GrampyScript/scripts/12_custom_filter_example.gram.py @@ -1,4 +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 index d9e657efc..b1f2e169e 100644 --- a/GrampyScript/scripts/13_delete_unused_repositories.gram.py +++ b/GrampyScript/scripts/13_delete_unused_repositories.gram.py @@ -1,4 +1,10 @@ # 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") diff --git a/GrampyScript/tests/test_script_descriptions.py b/GrampyScript/tests/test_script_descriptions.py index 77284fd20..a0df5b6b2 100644 --- a/GrampyScript/tests/test_script_descriptions.py +++ b/GrampyScript/tests/test_script_descriptions.py @@ -14,6 +14,12 @@ 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" @@ -44,6 +50,21 @@ def test_entries_have_title_and_description(self): 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) + on_disk = open(DESCRIPTIONS_PATH, encoding="utf-8").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")): diff --git a/GrampyScript/update_script_descriptions.py b/GrampyScript/update_script_descriptions.py index 4652e7eec..d8f508362 100644 --- a/GrampyScript/update_script_descriptions.py +++ b/GrampyScript/update_script_descriptions.py @@ -18,35 +18,29 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """ -Dev tool: keep script_descriptions.py's SCRIPT_DESCRIPTIONS dict in sync -with the files actually present in scripts/. +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 after adding a new example script, deleting one, or renaming one: +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 -What it does automatically (safe, structural, nothing to lose): - - Adds a stub entry -- title taken from the new file's leading '#' - comment, description a "TODO" placeholder -- for any scripts/*.gram.py - file with no entry yet. - - Drops entries for files that no longer exist in scripts/. - -What it only *warns* about (needs a human judgment call): - - A file whose leading comment title no longer matches the title - already catalogued in SCRIPT_DESCRIPTIONS. Titles are not - auto-overwritten, since the catalogued one may have been deliberately - written differently (and richer) than the terse in-file comment. - -Existing entries' source text (title + description, translator comments, -line wrapping, quoting) is preserved byte-for-byte by slicing it straight -out of the current file with ast -- this script never touches wording it -didn't generate itself. +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 @@ -54,113 +48,99 @@ os.path.dirname(os.path.abspath(__file__)), "script_descriptions.py" ) -STUB_DESCRIPTION = "TODO: describe what this script does." +ENTRY_INDENT = " " * 8 +TEXT_INDENT = " " * 12 +DESCRIPTION_WIDTH = 65 -def _slice_source(lines, node): - start_line, start_col = node.lineno - 1, node.col_offset - end_line, end_col = node.end_lineno - 1, node.end_col_offset - if start_line == end_line: - return lines[start_line][start_col:end_col] - parts = [lines[start_line][start_col:]] - parts.extend(lines[start_line + 1 : end_line]) - parts.append(lines[end_line][:end_col]) - return "".join(parts) - - -def _load_existing(path): - """ - Returns (header, entries) where header is the file text up through - "SCRIPT_DESCRIPTIONS = {" and entries maps filename -> (title, - raw_value_source) using the tuple's exact original source text. - """ +def _load_header(path): + """Return the file text up through the "SCRIPT_DESCRIPTIONS = {" line.""" source = open(path, encoding="utf-8").read() tree = ast.parse(source) lines = source.splitlines(keepends=True) - dict_node = None 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 ): - dict_node = node.value - break - if dict_node is None: - raise RuntimeError("SCRIPT_DESCRIPTIONS not found in %s" % path) + return "".join(lines[: node.lineno - 1]) + "SCRIPT_DESCRIPTIONS = {\n" + raise RuntimeError("SCRIPT_DESCRIPTIONS not found in %s" % path) - header = "".join(lines[: dict_node.lineno - 1]) + "SCRIPT_DESCRIPTIONS = {\n" - entries = {} - for key_node, value_node in zip(dict_node.keys, dict_node.values): - filename = ast.literal_eval(key_node) - title = value_node.elts[0].args[0].value - raw_value = _slice_source(lines, value_node) - entries[filename] = (title, raw_value) - return header, entries +def _dquote(text): + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' -def _stub_entry(title): - return '(\n _(%r),\n _(%r),\n )' % (title, STUB_DESCRIPTION) +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 main(): - header, existing = _load_existing(DESCRIPTIONS_PATH) +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), + ) - current_files = sorted( - os.path.basename(p) - for p in glob.glob(os.path.join(SCRIPTS_DIR, "*.gram.py")) + +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) + source = open(path, encoding="utf-8").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 - added, removed, retitled_warnings = [], [], [] - - body_lines = [] - for filename in current_files: - file_title = extract_header_comment( - open(os.path.join(SCRIPTS_DIR, filename)).read() - ) - if filename in existing: - title, raw_value = existing[filename] - if file_title and file_title != title: - retitled_warnings.append((filename, title, file_title)) - else: - title, raw_value = file_title or filename, _stub_entry( - file_title or filename - ) - added.append(filename) - body_lines.append(' "%s": %s,\n' % (filename, raw_value)) - - for filename in existing: - if filename not in current_files: - removed.append(filename) - - new_source = header + "".join(body_lines) + "}\n" + 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) - if added: - print("Added stub entries (fill in real descriptions):") - for filename in added: - print(" + %s" % filename) - if removed: - print("Removed stale entries (file no longer in scripts/):") - for filename in removed: - print(" - %s" % filename) - if retitled_warnings: - print("Title mismatches (file comment changed, catalogued title did not):") - for filename, old_title, new_title in retitled_warnings: - print(" ! %s" % filename) - print(" catalogued: %r" % old_title) - print(" in file: %r" % new_title) - print( - " -> update the title in script_descriptions.py by hand if the " - "file's title is now the correct one." - ) - if not (added or removed or retitled_warnings): - print("script_descriptions.py is already in sync with scripts/.") - - return 1 if retitled_warnings else 0 + print("Regenerated script_descriptions.py from %d scripts." % len(entries)) + return 0 if __name__ == "__main__": From e2640e0a3d7b1b86627b0bcc64a9014f9b480e53 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 19:40:09 -0700 Subject: [PATCH 12/65] Append () to function completions in GrampyScript editor Completions for callables (people(), families(), custom_filter(), dict methods, etc.) now insert with parens, landing the cursor between them when the function takes arguments. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/completion.py | 45 +++++++++++++++++---- GrampyScript/completion_popup.py | 5 +++ GrampyScript/tests/test_completion.py | 31 +++++++++++++- GrampyScript/tests/test_completion_popup.py | 26 ++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/GrampyScript/completion.py b/GrampyScript/completion.py index 76950d945..54439d946 100644 --- a/GrampyScript/completion.py +++ b/GrampyScript/completion.py @@ -77,16 +77,45 @@ def get_completions(source, line, column, namespace): return [completion.name 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} 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. + 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 (jedi type "function", e.g. `people`, + `families`) get "()" appended to both `name` (so the popup reads + "people()") and `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. """ - return [ - {"name": completion.name, "complete": completion.complete} - for completion in _complete(source, line, column, namespace) - ] + items = [] + for completion in _complete(source, line, column, namespace): + name = completion.name + complete = completion.complete + cursor_offset = 0 + if completion.type == "function": + name += "()" + 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 index 21a7e4546..9646da588 100644 --- a/GrampyScript/completion_popup.py +++ b/GrampyScript/completion_popup.py @@ -217,6 +217,11 @@ 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): diff --git a/GrampyScript/tests/test_completion.py b/GrampyScript/tests/test_completion.py index 70c43a5fb..12ce86ddc 100644 --- a/GrampyScript/tests/test_completion.py +++ b/GrampyScript/tests/test_completion.py @@ -119,13 +119,40 @@ class TestCompletionItems(_MockSaBase): 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"}]) + 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"}]) + 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): diff --git a/GrampyScript/tests/test_completion_popup.py b/GrampyScript/tests/test_completion_popup.py index bcb788daa..53d238335 100644 --- a/GrampyScript/tests/test_completion_popup.py +++ b/GrampyScript/tests/test_completion_popup.py @@ -101,6 +101,32 @@ def test_accept_with_no_items_just_closes(self): self.assertFalse(controller.is_open()) window.destroy() + def test_accept_no_arg_function_places_cursor_after_parens(self): + controller, buffer, window = _make_controller("peop") + controller.trigger() + names = [item["name"] for item in controller.items] + self.assertIn("people()", names) + controller.selected_index = names.index("people()") + controller.accept() + 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() + names = [item["name"] for item in controller.items] + self.assertIn("custom_filter()", names) + controller.selected_index = names.index("custom_filter()") + controller.accept() + 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): From 58bc0a725684016a2f80e7aa328cc6e35451b4dc Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 19:43:05 -0700 Subject: [PATCH 13/65] Auto-insert single-match completions instead of showing a one-row popup Why show a dropdown with nothing to choose between? trigger() now inserts directly when there's exactly one candidate, falling back to the popover only when there's a real choice to make. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/completion_popup.py | 10 +++- GrampyScript/tests/test_completion_popup.py | 52 ++++++++++++++------- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/GrampyScript/completion_popup.py b/GrampyScript/completion_popup.py index 9646da588..8ce9c66d1 100644 --- a/GrampyScript/completion_popup.py +++ b/GrampyScript/completion_popup.py @@ -178,8 +178,10 @@ def _compute_items(self): return items def trigger(self): - """Try to open the popover at the cursor. Returns True if it - did (there was something completable to show).""" + """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() @@ -188,6 +190,10 @@ def trigger(self): 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 diff --git a/GrampyScript/tests/test_completion_popup.py b/GrampyScript/tests/test_completion_popup.py index 53d238335..047ee62d2 100644 --- a/GrampyScript/tests/test_completion_popup.py +++ b/GrampyScript/tests/test_completion_popup.py @@ -79,14 +79,26 @@ def test_close_resets_state(self): 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.primary_") + controller, buffer, window = _make_controller("active_person.") controller.trigger() - # first match should be primary_name (only dynamic key matching) names = [item["name"] for item in controller.items] - self.assertEqual(names, ["primary_name"]) + 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") @@ -102,12 +114,11 @@ def test_accept_with_no_items_just_closes(self): 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() - names = [item["name"] for item in controller.items] - self.assertIn("people()", names) - controller.selected_index = names.index("people()") - controller.accept() 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() @@ -117,10 +128,6 @@ def test_accept_no_arg_function_places_cursor_after_parens(self): def test_accept_function_with_args_places_cursor_between_parens(self): controller, buffer, window = _make_controller("custom_fil") controller.trigger() - names = [item["name"] for item in controller.items] - self.assertIn("custom_filter()", names) - controller.selected_index = names.index("custom_filter()") - controller.accept() 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() @@ -144,17 +151,27 @@ def test_move_selection_clamped(self): class TestOnKeyPress(unittest.TestCase): - def test_tab_opens_then_accepts(self): + 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.assertEqual(text, "active_person.primary_name") + self.assertTrue(text.startswith("active_person.")) + self.assertGreater(len(text), len("active_person.")) window.destroy() def test_tab_falls_through_when_nothing_completable(self): @@ -188,13 +205,16 @@ def test_left_right_close_but_are_not_consumed(self): window.destroy() def test_return_accepts_only_while_open(self): - controller, buffer, window = _make_controller("active_person.primary_") + 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.assertEqual(text, "active_person.primary_name") + self.assertTrue(text.startswith("active_person.")) + self.assertGreater(len(text), len("active_person.")) window.destroy() @@ -212,7 +232,7 @@ def test_refresh_narrows_as_more_is_typed(self): window.destroy() def test_refresh_closes_when_context_no_longer_completable(self): - controller, buffer, window = _make_controller("active_person.primary_") + controller, buffer, window = _make_controller("active_person.") controller.trigger() self.assertTrue(controller.is_open()) From 63215112d476d8ebf45df96d88730387cb3bb9d7 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 20:05:53 -0700 Subject: [PATCH 14/65] Give age and back_references proper types in the completion stub age was typed as "object" even though DataDict2.age actually returns a gramps.gen.lib.date.Span (from Date - Date), so jedi had nothing to complete on it. back_references/back_references_recursively had the same "object" placeholder, which is worse than useless since jedi can't iterate a bare object at all -- completions on their loop items returned nothing. Both are now typed precisely: age as Span (imported into the stub preamble), and the back-reference properties as a union of every row type, mirroring the existing selected()/filtered() trick. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/stub_generator.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/GrampyScript/stub_generator.py b/GrampyScript/stub_generator.py index a054ea2c6..66165dcc6 100644 --- a/GrampyScript/stub_generator.py +++ b/GrampyScript/stub_generator.py @@ -102,13 +102,22 @@ "custom_filter": ["name: str", 'namespace: str = "Person"'], } +# 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". +_BACK_REFERENCE_TYPE = 'list[Union[%s]]' % ", ".join( + '"%s"' % name for name in sorted(set(GENERATOR_ROW_TYPES.values())) +) + # DataDict2's computed @property names (datadict2.py), layered onto every # generated type since DataDict2 defines them once for every instance # regardless of the wrapped record's real class. Best-effort types; "object" # is used where the real return type is ambiguous or data-dependent. COMPUTED_PROPERTIES = { "gender": "str", - "age": "object", + "age": "Span", "birth": "Event", "death": "Event", "place": "Place", @@ -130,8 +139,8 @@ "addresses": 'list["Address"]', "lds_ords": 'list["LdsOrdinance"]', "references": 'list["PersonRef"]', - "back_references": "object", - "back_references_recursively": "object", + "back_references": _BACK_REFERENCE_TYPE, + "back_references_recursively": _BACK_REFERENCE_TYPE, "name": "Name", "surname": "Surname", "names": 'list["Name"]', @@ -209,6 +218,7 @@ def render_stub_source( lines = [ "from __future__ import annotations", "from typing import Iterator, Union", + "from gramps.gen.lib.date import Span", "", ] for name in sorted(registry): From d14fd4736ffbf2319a3f54c2b469cced4c3ee042 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 20:46:06 -0700 Subject: [PATCH 15/65] Give each record type its own accurate completion fields COMPUTED_PROPERTIES was a single flat dict layered onto every schema class, including nested structural types (Name, Attribute, ...), so the editor offered fields like father/spouse/gender/age everywhere -- even where the underlying DataDict2 property would raise (gender on a non-Person) or silently do nothing. It's now name -> (type, valid root types), and build_registry() only attaches a property to the root record types it's actually valid on. `reference` moves out entirely, onto the nested *Ref wrapper types it actually belongs to. Also fixes two real datadict2.py bugs the audit turned up: surname/name used unguarded self["surname"]/self["name"], raising KeyError on any class without that field; reference always called get_raw_person_data regardless of which *Ref type it was wrapping. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/datadict2.py | 29 +++++- GrampyScript/stub_generator.py | 108 ++++++++++++++-------- GrampyScript/tests/test_datadict2.py | 44 +++++++++ GrampyScript/tests/test_stub_generator.py | 22 ++++- 4 files changed, 154 insertions(+), 49 deletions(-) diff --git a/GrampyScript/datadict2.py b/GrampyScript/datadict2.py index a969311f5..34ad6faa8 100644 --- a/GrampyScript/datadict2.py +++ b/GrampyScript/datadict2.py @@ -31,6 +31,17 @@ 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,15 +319,13 @@ 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): diff --git a/GrampyScript/stub_generator.py b/GrampyScript/stub_generator.py index 66165dcc6..a59fce531 100644 --- a/GrampyScript/stub_generator.py +++ b/GrampyScript/stub_generator.py @@ -107,43 +107,61 @@ # 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". -_BACK_REFERENCE_TYPE = 'list[Union[%s]]' % ", ".join( - '"%s"' % name for name in sorted(set(GENERATOR_ROW_TYPES.values())) -) +_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), layered onto every -# generated type since DataDict2 defines them once for every instance -# regardless of the wrapped record's real class. Best-effort types; "object" -# is used where the real return type is ambiguous or data-dependent. +# 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", - "age": "Span", - "birth": "Event", - "death": "Event", - "place": "Place", - "parents": 'list["Person"]', - "father": "Person", - "mother": "Person", - "spouse": "Person", - "source": "Source", - "families": 'list["Family"]', - "parent_families": 'list["Family"]', - "children": 'list["Person"]', - "notes": 'list["Note"]', - "tags": 'list["Tag"]', - "citations": 'list["Citation"]', - "media": 'list["MediaRef"]', - "events": 'list["Event"]', - "reference": "Person", - "attributes": 'list["Attribute"]', - "addresses": 'list["Address"]', - "lds_ords": 'list["LdsOrdinance"]', - "references": 'list["PersonRef"]', - "back_references": _BACK_REFERENCE_TYPE, - "back_references_recursively": _BACK_REFERENCE_TYPE, - "name": "Name", - "surname": "Surname", - "names": 'list["Name"]', + "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"} @@ -190,15 +208,25 @@ def _walk(schema, registry): 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(), with - DataDict2's computed properties layered on top of each (matching real - attribute lookup order: properties shadow raw dict keys). + 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 fields in registry.values(): - fields.update(COMPUTED_PROPERTIES) + 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 diff --git a/GrampyScript/tests/test_datadict2.py b/GrampyScript/tests/test_datadict2.py index d7ab9e523..6dd6ceb1d 100644 --- a/GrampyScript/tests/test_datadict2.py +++ b/GrampyScript/tests/test_datadict2.py @@ -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) @@ -163,6 +169,44 @@ def test_family_gramps_id(self): self.assertEqual(dd["_class"], "Family") +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- diff --git a/GrampyScript/tests/test_stub_generator.py b/GrampyScript/tests/test_stub_generator.py index 3467b6215..7bf0fc0f2 100644 --- a/GrampyScript/tests/test_stub_generator.py +++ b/GrampyScript/tests/test_stub_generator.py @@ -41,12 +41,26 @@ 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_every_type(self): - # DataDict2's @property names apply to every wrapped record, not - # just Person, since it is the same class for every nested value. - for name in ["Person", "Family", "Name"]: + 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. From f2ad0f52ed65e7674228e2769dc0485ae28e4de0 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 07:10:33 -0700 Subject: [PATCH 16/65] Make completions consistent for callables and drop classes entirely get_completions() returned bare function names (e.g. "people", "as_age") while get_completion_items() appended "()" to the same completions -- inconsistent, and a bare name reads as a field rather than a callable. Both now share a _display_name() helper so callables agree everywhere. Also exclude jedi type "class" completions altogether: the stub preamble injects scaffold classes (Person, Family, ...) purely for static analysis, and 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 dropped too, since the DSL has no use for instantiating classes directly. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/completion.py | 37 ++++++++++++++------- GrampyScript/tests/test_completion.py | 46 +++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/GrampyScript/completion.py b/GrampyScript/completion.py index 54439d946..d1b4abe40 100644 --- a/GrampyScript/completion.py +++ b/GrampyScript/completion.py @@ -49,14 +49,32 @@ def _get_stub_preamble(): def _complete(source, line, column, namespace): """Shared jedi call underlying both get_completions() and - get_completion_items(); returns raw jedi Completion objects.""" + 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: - return interpreter.complete(line + preamble.count("\n"), column) + 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): @@ -74,7 +92,7 @@ def get_completions(source, line, column, namespace): to runtime introspection (dir()/getattr()) for anything it can't statically analyze. """ - return [completion.name for completion in _complete(source, line, column, namespace)] + return [_display_name(completion) for completion in _complete(source, line, column, namespace)] def _takes_arguments(completion): @@ -100,20 +118,17 @@ def get_completion_items(source, line, column, namespace): "rt"), so callers can insert it directly without recomputing/re-typing the already-typed prefix. - Function/method completions (jedi type "function", e.g. `people`, - `families`) get "()" appended to both `name` (so the popup reads - "people()") and `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. + 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 = completion.name + name = _display_name(completion) complete = completion.complete cursor_offset = 0 if completion.type == "function": - name += "()" complete += "()" if _takes_arguments(completion): cursor_offset = 1 diff --git a/GrampyScript/tests/test_completion.py b/GrampyScript/tests/test_completion.py index 12ce86ddc..d76cfd64f 100644 --- a/GrampyScript/tests/test_completion.py +++ b/GrampyScript/tests/test_completion.py @@ -46,8 +46,10 @@ def _complete(self, source, 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) + self.assertIn("print()", names) def test_completes_namespace_variable(self): names = self._complete("active_per", {"active_person": DataDict2(_make_person())}) @@ -111,6 +113,46 @@ def test_distinguishes_row_type_by_generator(self): 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 @@ -160,7 +202,7 @@ 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) + self.assertIn("print()", names) def test_incomplete_code_does_not_raise(self): # Mid-typing code is often syntactically invalid; must not crash. From b3d409a3f28eea29b510a03025af0f17149ec54f Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 07:21:26 -0700 Subject: [PATCH 17/65] Hint at Tab completion in the status bar and fix New not resetting filename The status message duplicated the filename already shown by filename_label, so drop those redundant messages and use the freed-up space to surface the Tab-completion shortcut. Also fix New leaving the old filename label in place, which caused Save to silently overwrite the previous file instead of prompting Save As. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index ece0f7035..896adb2ec 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -311,9 +311,7 @@ def init(self): 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 @@ -476,7 +474,7 @@ def build_gui(self): provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) - self.statusmsg = Gtk.Label(_("Ready...")) + 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 @@ -540,7 +538,9 @@ def new_script(self, widget): def _do_new_script(self): self.ebuf.set_text("") self.ebuf.set_modified(False) - self.statusmsg.set_text("Ready...") + self.last_filename = "" + self.update_filename_label() + self.statusmsg.set_text(_("Ready... (Tab for completions)")) def open_script(self, widget): # type: (Gtk.Widget) -> None @@ -568,7 +568,6 @@ def _do_open_script(self): config.set("defaults.last_filename", filename) config.save() self.update_filename_label() - self.statusmsg.set_text("Loaded %r" % self.last_filename) break choose_file_dialog.destroy() @@ -580,7 +579,7 @@ def save_script(self, widget): with open(self.last_filename, "w") as fp: fp.write(self.get_text()) self.ebuf.set_modified(False) - self.statusmsg.set_text("Saved %r" % self.last_filename) + self.statusmsg.set_text("Saved") def save_as_script(self, widget): choose_file_dialog = ScriptSaveFileChooserDialog(self.uistate) @@ -608,7 +607,7 @@ def save_as_script(self, widget): config.set("defaults.last_filename", filename) config.save() self.update_filename_label() - self.statusmsg.set_text("Saved as %r (now current)" % self.last_filename) + self.statusmsg.set_text("Saved as (now current)") break choose_file_dialog.destroy() From 3a35c8fa3dc3b068977e0544abe7e527f2cc871b Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 07:27:16 -0700 Subject: [PATCH 18/65] Add completion for columns() and other void DSL functions columns, begin_changes, end_changes, delete, row, and chart are all top-level DSL callables bound as local closures inside execute_code(), so jedi never saw them since they weren't part of the completion stub or namespace. Adds a VOID_FUNCTIONS entry in stub_generator.py that renders their signatures as "-> None" completions. --- GrampyScript/stub_generator.py | 19 ++++++++++ GrampyScript/tests/test_stub_generator.py | 44 ++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/GrampyScript/stub_generator.py b/GrampyScript/stub_generator.py index a59fce531..5b7230744 100644 --- a/GrampyScript/stub_generator.py +++ b/GrampyScript/stub_generator.py @@ -102,6 +102,22 @@ "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 @@ -235,6 +251,7 @@ def render_stub_source( 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_* @@ -266,6 +283,8 @@ def render_stub_source( 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)) diff --git a/GrampyScript/tests/test_stub_generator.py b/GrampyScript/tests/test_stub_generator.py index 7bf0fc0f2..55121c48a 100644 --- a/GrampyScript/tests/test_stub_generator.py +++ b/GrampyScript/tests/test_stub_generator.py @@ -10,7 +10,13 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from stub_generator import ACTIVE_VARIABLES, GENERATOR_ROW_TYPES, build_registry, render_stub_source +from stub_generator import ( + ACTIVE_VARIABLES, + GENERATOR_ROW_TYPES, + VOID_FUNCTIONS, + build_registry, + render_stub_source, +) from completion import get_completions @@ -112,6 +118,21 @@ 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): """ @@ -175,5 +196,26 @@ def test_custom_filter_offers_real_fields_with_explicit_namespace(self): 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() From bfe1a7554dafa408330ba9dcee33b188f1b00d21 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 10:00:05 -0700 Subject: [PATCH 19/65] Close files explicitly instead of relying on GC open(path).read() without a context manager leaves the file handle open until garbage collected, which triggers ResourceWarning under python -m unittest. Use with-blocks in the four spots that did this. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/tests/test_script_descriptions.py | 6 ++++-- GrampyScript/update_script_descriptions.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/GrampyScript/tests/test_script_descriptions.py b/GrampyScript/tests/test_script_descriptions.py index a0df5b6b2..4785cebd1 100644 --- a/GrampyScript/tests/test_script_descriptions.py +++ b/GrampyScript/tests/test_script_descriptions.py @@ -56,7 +56,8 @@ def test_regenerating_produces_no_changes(self): self.assertEqual(errors, []) header = _load_header(DESCRIPTIONS_PATH) regenerated = build_source(entries, header) - on_disk = open(DESCRIPTIONS_PATH, encoding="utf-8").read() + with open(DESCRIPTIONS_PATH, encoding="utf-8") as f: + on_disk = f.read() self.assertEqual( regenerated, on_disk, @@ -69,7 +70,8 @@ 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): - ast.parse(open(path).read()) + with open(path) as f: + ast.parse(f.read()) if __name__ == "__main__": diff --git a/GrampyScript/update_script_descriptions.py b/GrampyScript/update_script_descriptions.py index d8f508362..8ccd9e351 100644 --- a/GrampyScript/update_script_descriptions.py +++ b/GrampyScript/update_script_descriptions.py @@ -55,7 +55,8 @@ def _load_header(path): """Return the file text up through the "SCRIPT_DESCRIPTIONS = {" line.""" - source = open(path, encoding="utf-8").read() + with open(path, encoding="utf-8") as f: + source = f.read() tree = ast.parse(source) lines = source.splitlines(keepends=True) @@ -103,7 +104,8 @@ def collect_entries(): errors = [] for path in sorted(glob.glob(os.path.join(SCRIPTS_DIR, "*.gram.py"))): filename = os.path.basename(path) - source = open(path, encoding="utf-8").read() + 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: From a01627d1ee9273e2f76ec6821783935ebb7b30fa Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 18:24:00 -0700 Subject: [PATCH 20/65] Fix set_*() on nested DataDict2 wrappers silently discarding changes A nested wrapper's _object was rebuilt via data_to_object() from just its own dict slice, disconnected from the real object tree. Calling a set_*() method (e.g. surname.set_origintype()) mutated that throwaway clone, then the commit step re-serialized the untouched real object, so the change never reached the database. Now nested wrappers resolve _object by walking the root's real object via self.path, so set_*() calls (and attribute assignment) mutate the actual object that gets persisted. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/datadict2.py | 30 ++++++++++++++------- GrampyScript/tests/test_datadict2.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/GrampyScript/datadict2.py b/GrampyScript/datadict2.py index 34ad6faa8..a190272d2 100644 --- a/GrampyScript/datadict2.py +++ b/GrampyScript/datadict2.py @@ -331,19 +331,23 @@ def surname(self): def names(self): return DataList2([self.primary_name] + [self.alternate_names]) + 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 @@ -362,7 +366,15 @@ def __dir__(self): 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( diff --git a/GrampyScript/tests/test_datadict2.py b/GrampyScript/tests/test_datadict2.py index 6dd6ceb1d..171c10cf7 100644 --- a/GrampyScript/tests/test_datadict2.py +++ b/GrampyScript/tests/test_datadict2.py @@ -266,5 +266,45 @@ def test_empty_list(self): 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) + + if __name__ == "__main__": unittest.main() From 705a9f6cedbbab6f1fca59d60bd3edda0dee9c88 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 18:29:22 -0700 Subject: [PATCH 21/65] Fix DataList2 re-wrapping already-wrapped items and corrupting root/path [dd.primary_name] + dd.alternate_names goes through DataList2.__radd__, producing a DataList2 whose elements are already DataDict2/DataList2 instances. __getitem__ unconditionally re-wrapped dict/list values, and since those wrapper classes subclass dict/list, it re-wrapped already- wrapped items too -- discarding their real root/path and substituting this list's own (often None, defaulting to self) root. That produced a DataDict2 whose root was itself but whose path was non-empty, an inconsistent state that made attribute assignment recurse forever trying to resolve self.root._object. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/datadict2.py | 9 ++++++++- GrampyScript/tests/test_datadict2.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/GrampyScript/datadict2.py b/GrampyScript/datadict2.py index a190272d2..8051c8c23 100644 --- a/GrampyScript/datadict2.py +++ b/GrampyScript/datadict2.py @@ -436,7 +436,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]) diff --git a/GrampyScript/tests/test_datadict2.py b/GrampyScript/tests/test_datadict2.py index 171c10cf7..17c43b842 100644 --- a/GrampyScript/tests/test_datadict2.py +++ b/GrampyScript/tests/test_datadict2.py @@ -305,6 +305,25 @@ def test_nested_set_method_calls_callback_with_root(self): 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") + if __name__ == "__main__": unittest.main() From e7a0bcedbe10ec4f0f939cdebc7585409214e202 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 18:35:52 -0700 Subject: [PATCH 22/65] Fix .string on GrampsType-based fields returning raw custom-text, not the label The raw serialized "string" field of a GrampsType value (NameOriginType, NameType, EventType, ...) is only the *custom*-type override text -- it is always "" for predefined values like PATRILINEAL. Since DataDict2's generic dict-key lookup returned that raw field directly, `.string` looked empty even after setting a real origin type. Add a `string` property that, when the wrapped value is a GrampsType, returns the actual computed label (str(the_type)) instead. Falls back to normal attribute lookup for anything without a "string" field, so unrelated objects are unaffected. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/datadict2.py | 17 +++++++++++++++- GrampyScript/tests/test_datadict2.py | 29 +++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/GrampyScript/datadict2.py b/GrampyScript/datadict2.py index 8051c8c23..e9d735334 100644 --- a/GrampyScript/datadict2.py +++ b/GrampyScript/datadict2.py @@ -27,7 +27,7 @@ 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) @@ -331,6 +331,21 @@ def surname(self): def names(self): 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.""" diff --git a/GrampyScript/tests/test_datadict2.py b/GrampyScript/tests/test_datadict2.py index 17c43b842..00fa2f066 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 @@ -325,5 +325,32 @@ def test_concatenated_name_list_assignment_updates_real_object(self): 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() From e73e334e9f15e61ef50d3e0ab109d9a618333fa5 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 18:40:02 -0700 Subject: [PATCH 23/65] Fix names property nesting alternate_names and __radd__ reversing order `names` was `[self.primary_name] + [self.alternate_names]` -- the extra brackets around alternate_names nested the whole list as a single element instead of spreading its items in. Separately, DataList2.__radd__ returned `self + value` instead of the mathematically required `value + self` (Python calls b.__radd__(a) to compute `a + b`), so `plain_list + data_list2` -- the exact pattern used to loop over primary + alternate names -- came out reversed. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/datadict2.py | 7 +++++-- GrampyScript/tests/test_datadict2.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/GrampyScript/datadict2.py b/GrampyScript/datadict2.py index e9d735334..2f11844b7 100644 --- a/GrampyScript/datadict2.py +++ b/GrampyScript/datadict2.py @@ -329,7 +329,7 @@ def surname(self): @property def names(self): - return DataList2([self.primary_name] + [self.alternate_names]) + return DataList2([self.primary_name] + self.alternate_names) @property def string(self): @@ -481,7 +481,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/tests/test_datadict2.py b/GrampyScript/tests/test_datadict2.py index 00fa2f066..d61ae373a 100644 --- a/GrampyScript/tests/test_datadict2.py +++ b/GrampyScript/tests/test_datadict2.py @@ -168,6 +168,22 @@ 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 @@ -260,6 +276,14 @@ def test_add_concatenates(self): 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) From d51fe77e02d0586398bdc5c7d28d85ca0d41b566 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 18:46:13 -0700 Subject: [PATCH 24/65] Fix DataList2 fan-out of set_*() methods across all items `dl.set_privacy(True)` fanned out attribute access first, collecting each item's unevaluated set_*() wrapper closure into a DataList2 -- then failed to call, since a DataList2 of closures isn't callable itself. Special-case set_*() the same way DataDict2 already does: return one callable that applies the same args to every item in the list. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/datadict2.py | 9 ++++++++- GrampyScript/tests/test_datadict2.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/GrampyScript/datadict2.py b/GrampyScript/datadict2.py index 2f11844b7..2970365cf 100644 --- a/GrampyScript/datadict2.py +++ b/GrampyScript/datadict2.py @@ -443,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=""): diff --git a/GrampyScript/tests/test_datadict2.py b/GrampyScript/tests/test_datadict2.py index d61ae373a..8460b106e 100644 --- a/GrampyScript/tests/test_datadict2.py +++ b/GrampyScript/tests/test_datadict2.py @@ -271,6 +271,17 @@ 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"))]) From 0966affd733a16cd2b1c7bb5846d9a443a25731c Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 19:04:10 -0700 Subject: [PATCH 25/65] Fix Grampy bulk changes not appearing in Undo/Redo history begin_changes()/end_changes() called the lowlevel db._txn_begin()/_txn_commit() (raw SQL BEGIN/COMMIT) instead of db.transaction_begin()/transaction_commit(), so the DbTxn was never pushed onto undodb and script edits were invisible to Undo/Redo despite being written to disk. Co-Authored-By: Claude Sonnet 5 --- GrampyScript/GrampyScript.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index 896adb2ec..90a83ceda 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -1198,11 +1198,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(): From 68bd175a4d62af23ab72344d63b31abf9b73cf01 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Thu, 9 Jul 2026 08:43:00 -0700 Subject: [PATCH 26/65] Merge GrampyScript: bundled example scripts, Open-dialog previews, UI polish#978 --- GrampyScript/GrampyScript.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GrampyScript/GrampyScript.gpr.py b/GrampyScript/GrampyScript.gpr.py index 55ddf10a8..79c3a54c7 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.8', fname="GrampyScript.py", authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], From a142b57722f571138a6b5ae7158e4dfde467e3c9 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 07:50:06 -0700 Subject: [PATCH 27/65] GrampsAssistant: document custom_filter() and delete() DSL functions PR #978 added custom_filter(name, namespace="Person") and delete(obj) to GrampyScript's execution scope. GrampsAssistant drives that same scope via tools.py's execute_script/evaluate_expression docstrings, so the model needs to know these exist to use or suggest them. Co-Authored-By: Claude Sonnet 5 --- GrampsAssistant/tools.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/GrampsAssistant/tools.py b/GrampsAssistant/tools.py index 3f48a9c16..025937bb5 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() From 00fb47494c082fc10b8560c331b770feee7aa400 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Tue, 7 Jul 2026 09:51:34 -0700 Subject: [PATCH 28/65] Added help URL --- GrampsAssistant/grampsassistant.gpr.py | 1 + GrampsAssistant/grampsassistant.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/GrampsAssistant/grampsassistant.gpr.py b/GrampsAssistant/grampsassistant.gpr.py index 017817ce6..d39e96526 100644 --- a/GrampsAssistant/grampsassistant.gpr.py +++ b/GrampsAssistant/grampsassistant.gpr.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 0d5589c05..c7b05c982 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 # ------------------------------------------------------------------ From dd6ee2fc94eccd9801e226c839009d688685699e Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Thu, 9 Jul 2026 08:46:22 -0700 Subject: [PATCH 29/65] Merge GrampsAssistant: document custom_filter() and delete() DSL functions; added help url #979 --- GrampsAssistant/grampsassistant.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GrampsAssistant/grampsassistant.gpr.py b/GrampsAssistant/grampsassistant.gpr.py index d39e96526..f7d70eacf 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", From c9171c90dc917c76d74ee4f8ab7210f0ac74522a Mon Sep 17 00:00:00 2001 From: Javad Razavian Date: Sat, 4 Jul 2026 01:55:52 +0200 Subject: [PATCH 30/65] Add DateOfDeathGramplet - gramplet listing death dates sorted by month and day --- .../DateOfDeathGramplet.gpr.py | 34 ++++++++ DateOfDeathGramplet/DateOfDeathGramplet.py | 79 +++++++++++++++++++ DateOfDeathGramplet/po/ca-local.po | 28 +++++++ DateOfDeathGramplet/po/da-local.po | 28 +++++++ DateOfDeathGramplet/po/de-local.po | 28 +++++++ DateOfDeathGramplet/po/es-local.po | 28 +++++++ DateOfDeathGramplet/po/fa-local.po | 27 +++++++ DateOfDeathGramplet/po/fi-local.po | 29 +++++++ DateOfDeathGramplet/po/fr-local.po | 28 +++++++ DateOfDeathGramplet/po/he-local.po | 29 +++++++ DateOfDeathGramplet/po/hr-local.po | 29 +++++++ DateOfDeathGramplet/po/hu-local.po | 28 +++++++ DateOfDeathGramplet/po/it-local.po | 28 +++++++ DateOfDeathGramplet/po/lt-local.po | 32 ++++++++ DateOfDeathGramplet/po/nb-local.po | 29 +++++++ DateOfDeathGramplet/po/nl-local.po | 28 +++++++ DateOfDeathGramplet/po/pl-local.po | 33 ++++++++ DateOfDeathGramplet/po/pt_BR-local.po | 27 +++++++ DateOfDeathGramplet/po/pt_PT-local.po | 28 +++++++ DateOfDeathGramplet/po/ru-local.po | 30 +++++++ DateOfDeathGramplet/po/sk-local.po | 28 +++++++ DateOfDeathGramplet/po/sv-local.po | 28 +++++++ DateOfDeathGramplet/po/template.pot | 35 ++++++++ DateOfDeathGramplet/po/tr-local.po | 31 ++++++++ DateOfDeathGramplet/po/uk-local.po | 29 +++++++ 25 files changed, 781 insertions(+) create mode 100644 DateOfDeathGramplet/DateOfDeathGramplet.gpr.py create mode 100644 DateOfDeathGramplet/DateOfDeathGramplet.py create mode 100644 DateOfDeathGramplet/po/ca-local.po create mode 100644 DateOfDeathGramplet/po/da-local.po create mode 100644 DateOfDeathGramplet/po/de-local.po create mode 100644 DateOfDeathGramplet/po/es-local.po create mode 100644 DateOfDeathGramplet/po/fa-local.po create mode 100644 DateOfDeathGramplet/po/fi-local.po create mode 100644 DateOfDeathGramplet/po/fr-local.po create mode 100644 DateOfDeathGramplet/po/he-local.po create mode 100644 DateOfDeathGramplet/po/hr-local.po create mode 100644 DateOfDeathGramplet/po/hu-local.po create mode 100644 DateOfDeathGramplet/po/it-local.po create mode 100644 DateOfDeathGramplet/po/lt-local.po create mode 100644 DateOfDeathGramplet/po/nb-local.po create mode 100644 DateOfDeathGramplet/po/nl-local.po create mode 100644 DateOfDeathGramplet/po/pl-local.po create mode 100644 DateOfDeathGramplet/po/pt_BR-local.po create mode 100644 DateOfDeathGramplet/po/pt_PT-local.po create mode 100644 DateOfDeathGramplet/po/ru-local.po create mode 100644 DateOfDeathGramplet/po/sk-local.po create mode 100644 DateOfDeathGramplet/po/sv-local.po create mode 100644 DateOfDeathGramplet/po/template.pot create mode 100644 DateOfDeathGramplet/po/tr-local.po create mode 100644 DateOfDeathGramplet/po/uk-local.po diff --git a/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py new file mode 100644 index 000000000..f84ba0daa --- /dev/null +++ b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py @@ -0,0 +1,34 @@ +# +# 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 dates of death sorted by month and day"), + status=STABLE, + version = '1.0.2', + fname="DateOfDeathGramplet.py", + height=200, + gramplet="DateOfDeathGramplet", + gramps_target_version="6.0", + gramplet_title=_("Date of Death"), + help_url="DateOfDeathGramplet", +) diff --git a/DateOfDeathGramplet/DateOfDeathGramplet.py b/DateOfDeathGramplet/DateOfDeathGramplet.py new file mode 100644 index 000000000..7d1b14e25 --- /dev/null +++ b/DateOfDeathGramplet/DateOfDeathGramplet.py @@ -0,0 +1,79 @@ +# +# 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 +import gramps.gen.datehandler +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.")) + + 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 + + age = "" + birth_ref = person.get_birth_ref() + 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 + + self.result.append((date_of_death, person, age)) + + self.result.sort(key=lambda item: (item[0].get_month(), + item[0].get_day())) + self.clear_text() + + for date_of_death, person, age in self.result: + name = person.get_primary_name() + displayer = gramps.gen.datehandler.displayer + self.append_text("{}: ".format(displayer.display(date_of_death))) + self.link(name_displayer.display_name(name), "Person", + person.handle) + if age: + self.append_text(" ({})\n".format(age[0])) + else: + self.append_text("\n") + self.append_text("", scroll_to="begin") diff --git a/DateOfDeathGramplet/po/ca-local.po b/DateOfDeathGramplet/po/ca-local.po new file mode 100644 index 000000000..f28963406 --- /dev/null +++ b/DateOfDeathGramplet/po/ca-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: ca\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "un gramplet que mostra les dates de defunció ordenades per mes i dia" + +msgid "No Family Tree loaded." +msgstr "No hi ha cap arbre familiar carregat." + +msgid "Processing..." +msgstr "Processant…" + diff --git a/DateOfDeathGramplet/po/da-local.po b/DateOfDeathGramplet/po/da-local.po new file mode 100644 index 000000000..54a8d7d02 --- /dev/null +++ b/DateOfDeathGramplet/po/da-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-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 "Date of Death" +msgstr "Dødsdato" + +msgid "a gramplet that displays dates of death sorted by month and day" +msgstr "en gramplet der viser dødsdatoer sorteret efter måned og dag" + +msgid "No Family Tree loaded." +msgstr "Ingen stamtræ indlæst." + +msgid "Processing..." +msgstr "Behandler…" + diff --git a/DateOfDeathGramplet/po/de-local.po b/DateOfDeathGramplet/po/de-local.po new file mode 100644 index 000000000..022a5ec5c --- /dev/null +++ b/DateOfDeathGramplet/po/de-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: de\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "ein Gramplet, das die Todesdaten sortiert nach Monat und Tag anzeigt" + +msgid "No Family Tree loaded." +msgstr "Kein Stammbaum geladen." + +msgid "Processing..." +msgstr "Verarbeite…" + diff --git a/DateOfDeathGramplet/po/es-local.po b/DateOfDeathGramplet/po/es-local.po new file mode 100644 index 000000000..efb3f18fd --- /dev/null +++ b/DateOfDeathGramplet/po/es-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: GRAMPS 3.1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "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 "Processing..." +msgstr "Procesando…" + diff --git a/DateOfDeathGramplet/po/fa-local.po b/DateOfDeathGramplet/po/fa-local.po new file mode 100644 index 000000000..0df3dd05c --- /dev/null +++ b/DateOfDeathGramplet/po/fa-local.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: DateOfDeathGramplet\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-04 12: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 sorted by month and day" +msgstr "یک گرمپلت که تاریخ‌های فوت را مرتب بر اساس ماه و روز نمایش می‌دهد" + +msgid "No Family Tree loaded." +msgstr "هیچ شجره‌نامه‌ای بارگذاری نشده است." + +msgid "Processing..." +msgstr "در حال پردازش..." diff --git a/DateOfDeathGramplet/po/fi-local.po b/DateOfDeathGramplet/po/fi-local.po new file mode 100644 index 000000000..49558baeb --- /dev/null +++ b/DateOfDeathGramplet/po/fi-local.po @@ -0,0 +1,29 @@ +msgid "" +msgstr "" +"Project-Id-Version: fi\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "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 "Processing..." +msgstr "Käsitellään…" + diff --git a/DateOfDeathGramplet/po/fr-local.po b/DateOfDeathGramplet/po/fr-local.po new file mode 100644 index 000000000..3262efcf5 --- /dev/null +++ b/DateOfDeathGramplet/po/fr-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: trunk\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-02 12:49-0700\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 dates of death sorted by month and day" +msgstr "un gramplet qui affiche les dates de décès triées par mois et jour" + +msgid "No Family Tree loaded." +msgstr "Aucun arbre généalogique chargé." + +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 000000000..a2a33767f --- /dev/null +++ b/DateOfDeathGramplet/po/he-local.po @@ -0,0 +1,29 @@ +msgid "" +msgstr "" +"Project-Id-Version: Gramps 5.2.0 – mediamerge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "גרמפלט המציג תאריכי פטירה ממוינים לפי חודש ויום" + +msgid "No Family Tree loaded." +msgstr "לא נטען עץ משפחה." + +msgid "Processing..." +msgstr "מעבד…" + diff --git a/DateOfDeathGramplet/po/hr-local.po b/DateOfDeathGramplet/po/hr-local.po new file mode 100644 index 000000000..d4f0c52a9 --- /dev/null +++ b/DateOfDeathGramplet/po/hr-local.po @@ -0,0 +1,29 @@ +msgid "" +msgstr "" +"Project-Id-Version: Gramps 5.x\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\n" +"PO-Revision-Date: 2025-03-02 14:58+0000\n" +"Last-Translator: Milo Ivir \n" +"Language-Team: Croatian \n" +"Language: hr\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 "Datum smrti" + +msgid "a gramplet that displays dates of death sorted by month and day" +msgstr "gramplet koji prikazuje datume smrti poredane po mjesecu i danu" + +msgid "No Family Tree loaded." +msgstr "Nije učitano obiteljsko stablo." + +msgid "Processing..." +msgstr "Obrađujem…" + diff --git a/DateOfDeathGramplet/po/hu-local.po b/DateOfDeathGramplet/po/hu-local.po new file mode 100644 index 000000000..7857f2e50 --- /dev/null +++ b/DateOfDeathGramplet/po/hu-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: hu\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "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 "Processing..." +msgstr "Feldolgozás…" + diff --git a/DateOfDeathGramplet/po/it-local.po b/DateOfDeathGramplet/po/it-local.po new file mode 100644 index 000000000..5fada08c1 --- /dev/null +++ b/DateOfDeathGramplet/po/it-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: gramps 3\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "un gramplet che mostra le date di morte ordinate per mese e giorno" + +msgid "No Family Tree loaded." +msgstr "Nessun albero genealogico caricato." + +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 000000000..5ada4a121 --- /dev/null +++ b/DateOfDeathGramplet/po/lt-local.po @@ -0,0 +1,32 @@ +msgid "" +msgstr "" +"Project-Id-Version: lt\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "grampletas, rodantis mirties datas, surūšiuotas pagal mėnesį ir dieną" + +msgid "No Family Tree loaded." +msgstr "Neįkeltas joks šeimos medis." + +msgid "Processing..." +msgstr "Apdorojama…" + diff --git a/DateOfDeathGramplet/po/nb-local.po b/DateOfDeathGramplet/po/nb-local.po new file mode 100644 index 000000000..8031a3a95 --- /dev/null +++ b/DateOfDeathGramplet/po/nb-local.po @@ -0,0 +1,29 @@ +msgid "" +msgstr "" +"Project-Id-Version: nb\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-02 12:49-0700\n" +"PO-Revision-Date: 2026-06-01 12:35+0000\n" +"Last-Translator: Harald Herreros \n" +"Language-Team: Norwegian Bokmål \n" +"Language: nb\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.6\n" +"Generated-By: pygettext.py 1.4\n" + +msgid "Date of Death" +msgstr "Dødsdato" + +msgid "a gramplet that displays dates of death sorted by month and day" +msgstr "en gramplet som viser dødsdatoer sortert etter måned og dag" + +msgid "No Family Tree loaded." +msgstr "Ingen familietre lastet." + +msgid "Processing..." +msgstr "Behandler…" + diff --git a/DateOfDeathGramplet/po/nl-local.po b/DateOfDeathGramplet/po/nl-local.po new file mode 100644 index 000000000..4c659415f --- /dev/null +++ b/DateOfDeathGramplet/po/nl-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: MediaMerge 5.x\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\n" +"PO-Revision-Date: 2025-04-14 22:32+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.11-dev\n" + +msgid "Date of Death" +msgstr "Overlijdensdatum" + +msgid "a gramplet that displays dates of death sorted by month and day" +msgstr "een gramplet dat de overlijdensdata toont gesorteerd op maand en dag" + +msgid "No Family Tree loaded." +msgstr "Geen stamboom geladen." + +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 000000000..3d36f21ea --- /dev/null +++ b/DateOfDeathGramplet/po/pl-local.po @@ -0,0 +1,33 @@ +msgid "" +msgstr "" +"Project-Id-Version: GRAMPS 3.1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "gramplet wyświetlający daty śmierci posortowane według miesiąca i dnia" + +msgid "No Family Tree loaded." +msgstr "Nie załadowano drzewa genealogicznego." + +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 000000000..4bc5c638d --- /dev/null +++ b/DateOfDeathGramplet/po/pt_BR-local.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: trunk\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "um gramplet que mostra as datas de falecimento ordenadas por mês e dia" + +msgid "No Family Tree loaded." +msgstr "Nenhuma árvore familiar carregada." + +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 000000000..1400ca71e --- /dev/null +++ b/DateOfDeathGramplet/po/pt_PT-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: gramps51\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-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 "Date of Death" +msgstr "Data de falecimento" + +msgid "a gramplet that displays dates of death sorted by month and day" +msgstr "um gramplet que mostra as datas de falecimento ordenadas por mês e dia" + +msgid "No Family Tree loaded." +msgstr "Nenhuma árvore genealógica carregada." + +msgid "Processing..." +msgstr "A processar…" + diff --git a/DateOfDeathGramplet/po/ru-local.po b/DateOfDeathGramplet/po/ru-local.po new file mode 100644 index 000000000..40df974eb --- /dev/null +++ b/DateOfDeathGramplet/po/ru-local.po @@ -0,0 +1,30 @@ +msgid "" +msgstr "" +"Project-Id-Version: gramps50\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-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 "Date of Death" +msgstr "Дата смерти" + +msgid "a gramplet that displays dates of death sorted by month and day" +msgstr "грамплет, отображающий даты смерти, отсортированные по месяцу и дню" + +msgid "No Family Tree loaded." +msgstr "Не загружено ни одного семейного древа." + +msgid "Processing..." +msgstr "Обработка…" + diff --git a/DateOfDeathGramplet/po/sk-local.po b/DateOfDeathGramplet/po/sk-local.po new file mode 100644 index 000000000..c7b02743c --- /dev/null +++ b/DateOfDeathGramplet/po/sk-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: GRAMPS 3.1.3\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "gramplet zobrazujúci dátumy úmrtia zoradené podľa mesiaca a dňa" + +msgid "No Family Tree loaded." +msgstr "Nie je načítaný žiadny rodokmeň." + +msgid "Processing..." +msgstr "Spracúvam…" + diff --git a/DateOfDeathGramplet/po/sv-local.po b/DateOfDeathGramplet/po/sv-local.po new file mode 100644 index 000000000..84e4d4834 --- /dev/null +++ b/DateOfDeathGramplet/po/sv-local.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-0800\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 dates of death sorted by month and day" +msgstr "en gramplet som visar dödsdatum sorterade efter månad och dag" + +msgid "No Family Tree loaded." +msgstr "Inget släktträd laddat." + +msgid "Processing..." +msgstr "Bearbetar…" + diff --git a/DateOfDeathGramplet/po/template.pot b/DateOfDeathGramplet/po/template.pot new file mode 100644 index 000000000..76ab76e83 --- /dev/null +++ b/DateOfDeathGramplet/po/template.pot @@ -0,0 +1,35 @@ +# 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 12: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" + +#: DateOfDeathGramplet/DateOfDeathGramplet.gpr.py:25 +#: DateOfDeathGramplet/DateOfDeathGramplet.gpr.py:34 +msgid "Date of Death" +msgstr "" + +#: DateOfDeathGramplet/DateOfDeathGramplet.gpr.py:26 +msgid "a gramplet that displays dates of death sorted by month and day" +msgstr "" + +#: DateOfDeathGramplet/DateOfDeathGramplet.py:28 +msgid "No Family Tree loaded." +msgstr "" + +#: DateOfDeathGramplet/DateOfDeathGramplet.py:39 +msgid "Processing..." +msgstr "" diff --git a/DateOfDeathGramplet/po/tr-local.po b/DateOfDeathGramplet/po/tr-local.po new file mode 100644 index 000000000..5ae402e9a --- /dev/null +++ b/DateOfDeathGramplet/po/tr-local.po @@ -0,0 +1,31 @@ +msgid "" +msgstr "" +"Project-Id-Version: 4.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-02 12:49-0700\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 dates of death sorted by month and day" +msgstr "ölüm tarihlerini aya ve güne göre sıralayan bir gramplet" + +msgid "No Family Tree loaded." +msgstr "Hiçbir aile ağacı yüklenmedi." + +msgid "Processing..." +msgstr "İşleniyor…" + diff --git a/DateOfDeathGramplet/po/uk-local.po b/DateOfDeathGramplet/po/uk-local.po new file mode 100644 index 000000000..f9fdd2aaa --- /dev/null +++ b/DateOfDeathGramplet/po/uk-local.po @@ -0,0 +1,29 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-06 08:56-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 "Date of Death" +msgstr "Дата смерті" + +msgid "a gramplet that displays dates of death sorted by month and day" +msgstr "грамплет, який відображає дати смерті, відсортовані за місяцем та днем" + +msgid "No Family Tree loaded." +msgstr "Не завантажено жодного родинного дерева." + +msgid "Processing..." +msgstr "Обробка…" + From 21d1ce47842623592033b72502fb8e743d4c073e Mon Sep 17 00:00:00 2001 From: Javad Razavian Date: Sat, 4 Jul 2026 14:08:48 +0200 Subject: [PATCH 31/65] DateOfDeathGramplet: add proximity sort option, fix cross-calendar sort, update description - Add sort mode dropdown (proximity/month-day) matching BirthdaysGramplet - Fix cross-calendar sort by using gregorian() before constructing death_this_year - Move death/birth ref checks into __calculate() for cleaner main() - Update description string in .gpr.py and all .po files - Bump version to 1.1.0 --- .../DateOfDeathGramplet.gpr.py | 6 +- DateOfDeathGramplet/DateOfDeathGramplet.py | 79 +++++++++++++++---- DateOfDeathGramplet/po/ca-local.po | 16 +++- DateOfDeathGramplet/po/da-local.po | 16 +++- DateOfDeathGramplet/po/de-local.po | 16 +++- DateOfDeathGramplet/po/es-local.po | 17 +++- DateOfDeathGramplet/po/fa-local.po | 18 ++++- DateOfDeathGramplet/po/fi-local.po | 17 +++- DateOfDeathGramplet/po/fr-local.po | 16 +++- DateOfDeathGramplet/po/he-local.po | 16 +++- DateOfDeathGramplet/po/hr-local.po | 16 +++- DateOfDeathGramplet/po/hu-local.po | 18 ++++- DateOfDeathGramplet/po/it-local.po | 16 +++- DateOfDeathGramplet/po/lt-local.po | 16 +++- DateOfDeathGramplet/po/nb-local.po | 16 +++- DateOfDeathGramplet/po/nl-local.po | 16 +++- DateOfDeathGramplet/po/pl-local.po | 16 +++- DateOfDeathGramplet/po/pt_BR-local.po | 16 +++- DateOfDeathGramplet/po/pt_PT-local.po | 16 +++- DateOfDeathGramplet/po/ru-local.po | 16 +++- DateOfDeathGramplet/po/sk-local.po | 16 +++- DateOfDeathGramplet/po/sv-local.po | 16 +++- DateOfDeathGramplet/po/template.pot | 18 +++-- DateOfDeathGramplet/po/tr-local.po | 16 +++- DateOfDeathGramplet/po/uk-local.po | 16 +++- 25 files changed, 350 insertions(+), 111 deletions(-) diff --git a/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py index f84ba0daa..596e778c5 100644 --- a/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py +++ b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py @@ -22,13 +22,13 @@ GRAMPLET, id="DateOfDeath", name=_("Date of Death"), - description=_("a gramplet that displays dates of death sorted by month and day"), + description=_("a gramplet that displays death dates in sorted order"), status=STABLE, - version = '1.0.2', + version = '1.1.0', fname="DateOfDeathGramplet.py", height=200, gramplet="DateOfDeathGramplet", - gramps_target_version="6.0", + gramps_target_version="6.1", gramplet_title=_("Date of Death"), help_url="DateOfDeathGramplet", ) diff --git a/DateOfDeathGramplet/DateOfDeathGramplet.py b/DateOfDeathGramplet/DateOfDeathGramplet.py index 7d1b14e25..045bfc0a5 100644 --- a/DateOfDeathGramplet/DateOfDeathGramplet.py +++ b/DateOfDeathGramplet/DateOfDeathGramplet.py @@ -21,7 +21,9 @@ 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: @@ -32,6 +34,29 @@ 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) @@ -52,28 +77,54 @@ def main(self): if not date_of_death.is_regular(): continue - age = "" - birth_ref = person.get_birth_ref() - 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 + self.__calculate(database, person) - self.result.append((date_of_death, person, age)) - - self.result.sort(key=lambda item: (item[0].get_month(), - item[0].get_day())) + 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 date_of_death, person, age in self.result: + 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_of_death))) + 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[0])) + 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 index f28963406..5007e55cf 100644 --- a/DateOfDeathGramplet/po/ca-local.po +++ b/DateOfDeathGramplet/po/ca-local.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: ca\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:56-0800\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-Team: Danish \n" "Language-Team: German \n" "Language-Team: Spanish \n" "Language-Team: Persian \n" "Language-Team: Finnish \n" "Language-Team: French \n" "Language-Team: Hebrew \n" "Language-Team: Croatian \n" "Language-Team: Hungarian \n" "Language-Team: Italian \n" "Language-Team: Lithuanian \n" "Language-Team: Norwegian Bokmål \n" "Language-Team: Dutch \n" "Language-Team: Polish \n" "Language-Team: Brazilian Portuguese>\n" @@ -16,12 +16,20 @@ msgstr "" msgid "Date of Death" msgstr "Data de falecimento" -msgid "a gramplet that displays dates of death sorted by month and day" -msgstr "um gramplet que mostra as datas de falecimento ordenadas por mês e dia" +msgid "a gramplet that displays death dates in sorted order" +msgstr "" 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 index 1400ca71e..b2b41d4c2 100644 --- a/DateOfDeathGramplet/po/pt_PT-local.po +++ b/DateOfDeathGramplet/po/pt_PT-local.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: gramps51\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:56-0800\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-Team: Russian\n" @@ -19,12 +19,20 @@ msgstr "" msgid "Date of Death" msgstr "Дата смерти" -msgid "a gramplet that displays dates of death sorted by month and day" -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 index c7b02743c..3096c4ec0 100644 --- a/DateOfDeathGramplet/po/sk-local.po +++ b/DateOfDeathGramplet/po/sk-local.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: GRAMPS 3.1.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:56-0800\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-Team: Swedish \n" "Language-Team: LANGUAGE \n" @@ -17,19 +17,23 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: DateOfDeathGramplet/DateOfDeathGramplet.gpr.py:25 -#: DateOfDeathGramplet/DateOfDeathGramplet.gpr.py:34 msgid "Date of Death" msgstr "" -#: DateOfDeathGramplet/DateOfDeathGramplet.gpr.py:26 -msgid "a gramplet that displays dates of death sorted by month and day" +msgid "a gramplet that displays death dates in sorted order" msgstr "" -#: DateOfDeathGramplet/DateOfDeathGramplet.py:28 msgid "No Family Tree loaded." msgstr "" -#: DateOfDeathGramplet/DateOfDeathGramplet.py:39 +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 index 5ae402e9a..b9d400db9 100644 --- a/DateOfDeathGramplet/po/tr-local.po +++ b/DateOfDeathGramplet/po/tr-local.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: 4.1.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-02 12:49-0700\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-Team: Ukrainian Date: Sat, 4 Jul 2026 15:10:59 +0200 Subject: [PATCH 32/65] DateOfDeathGramplet: fill translations for updated description string Update all 22 .po files with adapted translations for the new description 'a gramplet that displays death dates in sorted order' (removed 'month and day' reference). --- DateOfDeathGramplet/po/ca-local.po | 2 +- DateOfDeathGramplet/po/da-local.po | 2 +- DateOfDeathGramplet/po/de-local.po | 2 +- DateOfDeathGramplet/po/es-local.po | 2 +- DateOfDeathGramplet/po/fa-local.po | 2 +- DateOfDeathGramplet/po/fi-local.po | 2 +- DateOfDeathGramplet/po/fr-local.po | 2 +- DateOfDeathGramplet/po/he-local.po | 2 +- DateOfDeathGramplet/po/hr-local.po | 2 +- DateOfDeathGramplet/po/hu-local.po | 2 +- DateOfDeathGramplet/po/it-local.po | 2 +- DateOfDeathGramplet/po/lt-local.po | 2 +- DateOfDeathGramplet/po/nb-local.po | 2 +- DateOfDeathGramplet/po/nl-local.po | 2 +- DateOfDeathGramplet/po/pl-local.po | 2 +- DateOfDeathGramplet/po/pt_BR-local.po | 2 +- DateOfDeathGramplet/po/pt_PT-local.po | 2 +- DateOfDeathGramplet/po/ru-local.po | 2 +- DateOfDeathGramplet/po/sk-local.po | 2 +- DateOfDeathGramplet/po/sv-local.po | 2 +- DateOfDeathGramplet/po/tr-local.po | 2 +- DateOfDeathGramplet/po/uk-local.po | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/DateOfDeathGramplet/po/ca-local.po b/DateOfDeathGramplet/po/ca-local.po index 5007e55cf..9c4f1da3d 100644 --- a/DateOfDeathGramplet/po/ca-local.po +++ b/DateOfDeathGramplet/po/ca-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Data de defunció" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "un gramplet que mostra les dates de defunció ordenades" msgid "No Family Tree loaded." msgstr "No hi ha cap arbre familiar carregat." diff --git a/DateOfDeathGramplet/po/da-local.po b/DateOfDeathGramplet/po/da-local.po index 77d61040e..43b61a8af 100644 --- a/DateOfDeathGramplet/po/da-local.po +++ b/DateOfDeathGramplet/po/da-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Dødsdato" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "en gramplet der viser dødsdatoer sorteret" msgid "No Family Tree loaded." msgstr "Ingen stamtræ indlæst." diff --git a/DateOfDeathGramplet/po/de-local.po b/DateOfDeathGramplet/po/de-local.po index c06b73bea..31d8777e9 100644 --- a/DateOfDeathGramplet/po/de-local.po +++ b/DateOfDeathGramplet/po/de-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Todesdatum" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "ein Gramplet, das die Todesdaten sortiert anzeigt" msgid "No Family Tree loaded." msgstr "Kein Stammbaum geladen." diff --git a/DateOfDeathGramplet/po/es-local.po b/DateOfDeathGramplet/po/es-local.po index f88cf6685..d900b6eb8 100644 --- a/DateOfDeathGramplet/po/es-local.po +++ b/DateOfDeathGramplet/po/es-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Fecha de fallecimiento" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +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." diff --git a/DateOfDeathGramplet/po/fa-local.po b/DateOfDeathGramplet/po/fa-local.po index 02eae53b9..a9d5d31f6 100644 --- a/DateOfDeathGramplet/po/fa-local.po +++ b/DateOfDeathGramplet/po/fa-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "تاریخ فوت" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "نموداری که تاریخ‌های مرگ را به ترتیب مرتب‌شده نمایش می‌دهد" msgid "No Family Tree loaded." msgstr "هیچ شجره‌نامه‌ای بارگذاری نشده است." diff --git a/DateOfDeathGramplet/po/fi-local.po b/DateOfDeathGramplet/po/fi-local.po index adc0a8842..c5895ae0e 100644 --- a/DateOfDeathGramplet/po/fi-local.po +++ b/DateOfDeathGramplet/po/fi-local.po @@ -19,7 +19,7 @@ msgid "Date of Death" msgstr "Kuolinpäivä" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +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." diff --git a/DateOfDeathGramplet/po/fr-local.po b/DateOfDeathGramplet/po/fr-local.po index bc900e96a..06b1c6e93 100644 --- a/DateOfDeathGramplet/po/fr-local.po +++ b/DateOfDeathGramplet/po/fr-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Date de décès" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +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é." diff --git a/DateOfDeathGramplet/po/he-local.po b/DateOfDeathGramplet/po/he-local.po index 86bd79878..6fe19b913 100644 --- a/DateOfDeathGramplet/po/he-local.po +++ b/DateOfDeathGramplet/po/he-local.po @@ -19,7 +19,7 @@ msgid "Date of Death" msgstr "תאריך פטירה" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "גרמפלט המציג תאריכי פטירה ממוינים" msgid "No Family Tree loaded." msgstr "לא נטען עץ משפחה." diff --git a/DateOfDeathGramplet/po/hr-local.po b/DateOfDeathGramplet/po/hr-local.po index 4c0381010..9d80f69a2 100644 --- a/DateOfDeathGramplet/po/hr-local.po +++ b/DateOfDeathGramplet/po/hr-local.po @@ -19,7 +19,7 @@ msgid "Date of Death" msgstr "Datum smrti" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "gramplet koji prikazuje datume smrti poredane" msgid "No Family Tree loaded." msgstr "Nije učitano obiteljsko stablo." diff --git a/DateOfDeathGramplet/po/hu-local.po b/DateOfDeathGramplet/po/hu-local.po index 9df5b0076..4e2a1578b 100644 --- a/DateOfDeathGramplet/po/hu-local.po +++ b/DateOfDeathGramplet/po/hu-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Halálozási dátum" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +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" diff --git a/DateOfDeathGramplet/po/it-local.po b/DateOfDeathGramplet/po/it-local.po index 11f6544e2..5268afef2 100644 --- a/DateOfDeathGramplet/po/it-local.po +++ b/DateOfDeathGramplet/po/it-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Data di morte" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "un gramplet che mostra le date di morte ordinate" msgid "No Family Tree loaded." msgstr "Nessun albero genealogico caricato." diff --git a/DateOfDeathGramplet/po/lt-local.po b/DateOfDeathGramplet/po/lt-local.po index b9695b3cc..2404535be 100644 --- a/DateOfDeathGramplet/po/lt-local.po +++ b/DateOfDeathGramplet/po/lt-local.po @@ -22,7 +22,7 @@ msgid "Date of Death" msgstr "Mirties data" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "grampletas, rodantis mirties datas, surūšiuotas" msgid "No Family Tree loaded." msgstr "Neįkeltas joks šeimos medis." diff --git a/DateOfDeathGramplet/po/nb-local.po b/DateOfDeathGramplet/po/nb-local.po index 2858a5738..ce32c6ae3 100644 --- a/DateOfDeathGramplet/po/nb-local.po +++ b/DateOfDeathGramplet/po/nb-local.po @@ -19,7 +19,7 @@ msgid "Date of Death" msgstr "Dødsdato" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "en gramplet som viser dødsdatoer sortert" msgid "No Family Tree loaded." msgstr "Ingen familietre lastet." diff --git a/DateOfDeathGramplet/po/nl-local.po b/DateOfDeathGramplet/po/nl-local.po index 6ff7e2620..202fdbc4d 100644 --- a/DateOfDeathGramplet/po/nl-local.po +++ b/DateOfDeathGramplet/po/nl-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Overlijdensdatum" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "een gramplet dat de overlijdensdata gesorteerd toont" msgid "No Family Tree loaded." msgstr "Geen stamboom geladen." diff --git a/DateOfDeathGramplet/po/pl-local.po b/DateOfDeathGramplet/po/pl-local.po index a24b3600d..7112a0598 100644 --- a/DateOfDeathGramplet/po/pl-local.po +++ b/DateOfDeathGramplet/po/pl-local.po @@ -23,7 +23,7 @@ msgid "Date of Death" msgstr "Data śmierci" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "gramplet wyświetlający daty śmierci w posortowanej kolejności" msgid "No Family Tree loaded." msgstr "Nie załadowano drzewa genealogicznego." diff --git a/DateOfDeathGramplet/po/pt_BR-local.po b/DateOfDeathGramplet/po/pt_BR-local.po index 6f8d383f0..917a57b12 100644 --- a/DateOfDeathGramplet/po/pt_BR-local.po +++ b/DateOfDeathGramplet/po/pt_BR-local.po @@ -17,7 +17,7 @@ msgid "Date of Death" msgstr "Data de falecimento" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "um gramplet que mostra as datas de falecimento ordenadas" msgid "No Family Tree loaded." msgstr "Nenhuma árvore familiar carregada." diff --git a/DateOfDeathGramplet/po/pt_PT-local.po b/DateOfDeathGramplet/po/pt_PT-local.po index b2b41d4c2..eb31eef07 100644 --- a/DateOfDeathGramplet/po/pt_PT-local.po +++ b/DateOfDeathGramplet/po/pt_PT-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Data de falecimento" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "um gramplet que mostra as datas de falecimento ordenadas" msgid "No Family Tree loaded." msgstr "Nenhuma árvore genealógica carregada." diff --git a/DateOfDeathGramplet/po/ru-local.po b/DateOfDeathGramplet/po/ru-local.po index fcda403d4..e2dc56a80 100644 --- a/DateOfDeathGramplet/po/ru-local.po +++ b/DateOfDeathGramplet/po/ru-local.po @@ -20,7 +20,7 @@ msgid "Date of Death" msgstr "Дата смерти" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "грамплет, отображающий даты смерти в отсортированном порядке" msgid "No Family Tree loaded." msgstr "Не загружено ни одного семейного древа." diff --git a/DateOfDeathGramplet/po/sk-local.po b/DateOfDeathGramplet/po/sk-local.po index 3096c4ec0..ccdbb1554 100644 --- a/DateOfDeathGramplet/po/sk-local.po +++ b/DateOfDeathGramplet/po/sk-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Dátum úmrtia" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "gramplet zobrazujúci dátumy úmrtia v zoradenom poradí" msgid "No Family Tree loaded." msgstr "Nie je načítaný žiadny rodokmeň." diff --git a/DateOfDeathGramplet/po/sv-local.po b/DateOfDeathGramplet/po/sv-local.po index 7ea19a819..f8457f961 100644 --- a/DateOfDeathGramplet/po/sv-local.po +++ b/DateOfDeathGramplet/po/sv-local.po @@ -18,7 +18,7 @@ msgid "Date of Death" msgstr "Dödsdatum" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "en gramplet som visar dödsdatum sorterade" msgid "No Family Tree loaded." msgstr "Inget släktträd laddat." diff --git a/DateOfDeathGramplet/po/tr-local.po b/DateOfDeathGramplet/po/tr-local.po index b9d400db9..7bda2f11d 100644 --- a/DateOfDeathGramplet/po/tr-local.po +++ b/DateOfDeathGramplet/po/tr-local.po @@ -21,7 +21,7 @@ msgid "Date of Death" msgstr "Ölüm tarihi" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +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." diff --git a/DateOfDeathGramplet/po/uk-local.po b/DateOfDeathGramplet/po/uk-local.po index 3675f4f9d..34ede0e3e 100644 --- a/DateOfDeathGramplet/po/uk-local.po +++ b/DateOfDeathGramplet/po/uk-local.po @@ -19,7 +19,7 @@ msgid "Date of Death" msgstr "Дата смерті" msgid "a gramplet that displays death dates in sorted order" -msgstr "" +msgstr "грамплет, який відображає дати смерті у відсортованому порядку" msgid "No Family Tree loaded." msgstr "Не завантажено жодного родинного дерева." From 3dbc07ae584ca2526fc2f722cee599c5f8fdbc51 Mon Sep 17 00:00:00 2001 From: Javad Razavian Date: Thu, 9 Jul 2026 22:00:24 +0200 Subject: [PATCH 33/65] upd: gpr.py --- DateOfDeathGramplet/DateOfDeathGramplet.gpr.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py index 596e778c5..6badce503 100644 --- a/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py +++ b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py @@ -26,9 +26,11 @@ status=STABLE, version = '1.1.0', 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="DateOfDeathGramplet", + help_url="Addon:DateOfDeathGramplet", ) From 6eea8bc850419ef01dc871925d8c4efcc10b8386 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Thu, 9 Jul 2026 14:06:37 -0700 Subject: [PATCH 34/65] =?UTF-8?q?Merge=20DateOfDeathGramplet=20-=20grample?= =?UTF-8?q?t=20listing=20death=20dates=20sorted=20by=20=E2=80=A6#973?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DateOfDeathGramplet/DateOfDeathGramplet.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py index 6badce503..f088cd8fb 100644 --- a/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py +++ b/DateOfDeathGramplet/DateOfDeathGramplet.gpr.py @@ -24,7 +24,7 @@ name=_("Date of Death"), description=_("a gramplet that displays death dates in sorted order"), status=STABLE, - version = '1.1.0', + version = '1.1.1', fname="DateOfDeathGramplet.py", authors = ["Javad Razavian"], authors_email = ["javadr@gmail.com"], From 3a58376fc7d61ae3bc4d55bac3e186f7e6ed568f Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Thu, 9 Jul 2026 03:59:36 +0200 Subject: [PATCH 35/65] lxml: don't pop a blocking dialog at import when gzip/lxml is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lxmlGramplet raised ErrorDialog(...).run() at module *import* time when gzip or lxml was unavailable. That is a blocking modal shown before any GUI action: it stalls plugin loading until dismissed, and fires (or aborts) when Gramps is imported without a display — e.g. under the CLI or a test harness, where a missing python3-lxml made the module hang or scatter dialogs. The gramplet already degrades gracefully — it falls back to xml.etree when lxml is absent — so the missing dependency is not fatal. Set the availability flags silently at import (log instead of a dialog), and show the notice in init(), when the gramplet is actually opened and the GUI is running. No .gpr.py change: the addon version is incremented automatically at publish time. Co-Authored-By: Claude Opus 4.8 (1M context) --- lxml/lxmlGramplet.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lxml/lxmlGramplet.py b/lxml/lxmlGramplet.py index 1888339c6..8f2aac75c 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 From e4f858a16fa5ab45732d5c678e9991cf8db9fb7f Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Thu, 9 Jul 2026 16:43:46 -0700 Subject: [PATCH 36/65] Merge lxml: don't show a blocking dialog at import when lxml/gzip is missing#981 --- lxml/etreeGramplet.gpr.py | 2 +- lxml/lxmlGramplet.gpr.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lxml/etreeGramplet.gpr.py b/lxml/etreeGramplet.gpr.py index 8a6e281ba..4297d3689 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 d12d6abd8..3bd4f4d80 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, From a6f2e2085241989c87981f82e36465339a71606f Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sat, 11 Jul 2026 08:40:08 -0700 Subject: [PATCH 37/65] PDFForms: added help URL Co-Authored-By: Claude Sonnet 5 --- PDFForms/PDFForms.gpr.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PDFForms/PDFForms.gpr.py b/PDFForms/PDFForms.gpr.py index 87416aced..6d6e2b3b6 100644 --- a/PDFForms/PDFForms.gpr.py +++ b/PDFForms/PDFForms.gpr.py @@ -38,6 +38,7 @@ tool_modes=[TOOL_MODE_GUI], requires_mod=["reportlab"], depends_on=["Form Gramplet"], + help_url="Addon:PDFForms", ) register( @@ -56,4 +57,5 @@ extension="pdf", requires_mod=["pypdf"], depends_on=["Form Gramplet"], + help_url="Addon:PDFForms", ) From 6cfeb012e4fd744b0655a46263e59a03c95c95aa Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Mon, 13 Jul 2026 08:40:37 -0700 Subject: [PATCH 38/65] Merge PDFForms: added help URL #984 --- PDFForms/PDFForms.gpr.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PDFForms/PDFForms.gpr.py b/PDFForms/PDFForms.gpr.py index 6d6e2b3b6..ef5f82ec0 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", @@ -49,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", From 80527f15862e8f16efcbe3790d0cf09f4a7e3933 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sat, 11 Jul 2026 08:10:05 -0700 Subject: [PATCH 39/65] GrampyScript: fix histogram IndexError, add editor conveniences Histogram chart's bucketing computed the bucket index from the raw value instead of its offset from min_val, so any dataset with negative numbers (or just not zero-anchored) could index past the end of the buckets list. Bucket count and index math now match, with the top edge (value == max_val) clamped into the last bucket. Editor additions: - Pasted (or loaded) tab characters are converted to 4 spaces. - Enter carries over the current line's indentation, adding a level after a trailing ':' and removing one after return/pass/break/ continue/raise. - Tab with a selection indents the touched lines; Shift+Tab dedents (with or without a selection). - Ctrl+/ toggles '#' comments on the current line or selection. - The filename label shows a '*' prefix while there are unsaved changes. Also updates a few example scripts to use the new row(person, ...) display shorthand. --- GrampyScript/GrampyScript.py | 171 ++++++++++++++++-- GrampyScript/scripts/01_list_people.gram.py | 5 +- .../scripts/07_csv_ready_report.gram.py | 6 +- .../10_find_missing_birth_dates.gram.py | 2 +- 4 files changed, 159 insertions(+), 25 deletions(-) diff --git a/GrampyScript/GrampyScript.py b/GrampyScript/GrampyScript.py index 90a83ceda..9f365634e 100644 --- a/GrampyScript/GrampyScript.py +++ b/GrampyScript/GrampyScript.py @@ -305,6 +305,7 @@ 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) @@ -397,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 ) @@ -500,8 +503,13 @@ def build_gui(self): 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, @@ -692,6 +700,21 @@ 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() end_iter = self.ebuf.get_end_iter() @@ -932,38 +955,147 @@ def on_editor_focus_out(self, widget, event): return False def on_key_press(self, textview, event): + 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 event.keyval == Gdk.KEY_Tab: + 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()) @@ -1120,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) diff --git a/GrampyScript/scripts/01_list_people.gram.py b/GrampyScript/scripts/01_list_people.gram.py index ee569eb71..4bcd8a2a6 100644 --- a/GrampyScript/scripts/01_list_people.gram.py +++ b/GrampyScript/scripts/01_list_people.gram.py @@ -6,8 +6,7 @@ for person in people(): row( - person.gramps_id, - person.name.first_name, - person.surname.surname, + person, person.gender, + person.age, ) diff --git a/GrampyScript/scripts/07_csv_ready_report.gram.py b/GrampyScript/scripts/07_csv_ready_report.gram.py index 14b83e2f1..3f5104939 100644 --- a/GrampyScript/scripts/07_csv_ready_report.gram.py +++ b/GrampyScript/scripts/07_csv_ready_report.gram.py @@ -5,15 +5,13 @@ Table tab's contents. """ -columns("ID", "Given Name", "Surname", "Gender", "Birth Year") +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.gramps_id, - person.name.first_name, - person.surname.surname, + person, person.gender, birth_year, ) diff --git a/GrampyScript/scripts/10_find_missing_birth_dates.gram.py b/GrampyScript/scripts/10_find_missing_birth_dates.gram.py index eb6a21555..ac7656305 100644 --- a/GrampyScript/scripts/10_find_missing_birth_dates.gram.py +++ b/GrampyScript/scripts/10_find_missing_birth_dates.gram.py @@ -6,4 +6,4 @@ for person in people(): if not person.birth: - row(person.gramps_id, person.name.first_name, person.surname.surname) + row(person) From 2f19f7df73d223216eaf9e7e8ad5cac006575382 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Mon, 13 Jul 2026 08:43:15 -0700 Subject: [PATCH 40/65] Merge GrampyScript: fix histogram IndexError, add editor conveniences --- GrampyScript/GrampyScript.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GrampyScript/GrampyScript.gpr.py b/GrampyScript/GrampyScript.gpr.py index 79c3a54c7..34f77a285 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.8', + version = '0.0.9', fname="GrampyScript.py", authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], From c9ae50de5268476f9e0debfd540f2dc15990f200 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Wed, 15 Jul 2026 05:47:02 -0700 Subject: [PATCH 41/65] Themes: fix TypeError on preferences_activate(initial_panel=...) GrampsPreferences.__init__ gained an initial_panel keyword so callers can open Preferences directly on a specific panel. themes_load.py monkey-patches GrampsPreferences.__init__ with MyPrefs.__init__, which didn't accept the new keyword, raising: TypeError: MyPrefs.__init__() got an unexpected keyword argument 'initial_panel' MyPrefs.__init__ now accepts initial_panel and forwards it to select_panel(), matching the core implementation. Adds a regression test. --- Themes/tests/__init__.py | 40 +++++++++ Themes/tests/test_initial_panel.py | 125 +++++++++++++++++++++++++++++ Themes/themes.py | 4 +- 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 Themes/tests/__init__.py create mode 100644 Themes/tests/test_initial_panel.py diff --git a/Themes/tests/__init__.py b/Themes/tests/__init__.py new file mode 100644 index 000000000..e8a8ad189 --- /dev/null +++ b/Themes/tests/__init__.py @@ -0,0 +1,40 @@ +# +# 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. +# + +"""Test package for the Themes addon. + +Pins the GTK 3 stack (Gtk + Gdk) before any test module imports +``themes``. The module imports ``gi.repository.Gdk.Screen`` directly, +which GTK 4's Gdk no longer provides -- on a host where GTK 4 is the +default GI resolution a bare import would bind the wrong version and +crash. Pinning here -- mirroring ``gramps/gen/constfunc.py`` -- applies +on every launch path, including a direct ``python3 -m unittest`` run +with no test runner. +""" + +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError): + # No PyGObject / GTK 3 here; the test modules guard their imports + # and skip cleanly. + pass diff --git a/Themes/tests/test_initial_panel.py b/Themes/tests/test_initial_panel.py new file mode 100644 index 000000000..597bbcc3a --- /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.py b/Themes/themes.py index 5438fec68..2ea7ce0e1 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 ''' From 0cf9795d55630c494754c7705cdfe7d3e038cc1d Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sat, 18 Jul 2026 08:42:08 -0700 Subject: [PATCH 42/65] Themes: empty tests/__init__.py, GTK pinning now handled repo-wide PR #950 added a repo-root tests/__init__.py that pins GTK/Gdk to 3.0 for the whole suite, matching the empty tests/__init__.py convention already used by every other addon. The per-addon pin here was redundant with that infrastructure. Co-Authored-By: Claude Sonnet 5 --- Themes/tests/__init__.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/Themes/tests/__init__.py b/Themes/tests/__init__.py index e8a8ad189..e69de29bb 100644 --- a/Themes/tests/__init__.py +++ b/Themes/tests/__init__.py @@ -1,40 +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. -# - -"""Test package for the Themes addon. - -Pins the GTK 3 stack (Gtk + Gdk) before any test module imports -``themes``. The module imports ``gi.repository.Gdk.Screen`` directly, -which GTK 4's Gdk no longer provides -- on a host where GTK 4 is the -default GI resolution a bare import would bind the wrong version and -crash. Pinning here -- mirroring ``gramps/gen/constfunc.py`` -- applies -on every launch path, including a direct ``python3 -m unittest`` run -with no test runner. -""" - -try: - import gi - - gi.require_version("Gtk", "3.0") - gi.require_version("Gdk", "3.0") -except (ImportError, ValueError): - # No PyGObject / GTK 3 here; the test modules guard their imports - # and skip cleanly. - pass From 7466c0b5a55c1d11760dd9c89bd2d28c604ec9be Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Sat, 18 Jul 2026 09:20:19 -0700 Subject: [PATCH 43/65] Merge Themes: fix TypeError on preferences_activate(initial_panel=...)#985 --- Themes/themes.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Themes/themes.gpr.py b/Themes/themes.gpr.py index 24818b0fd..4d67cc76f 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"], From c93d9d9a6ecc7f5fce5d862478412cff3aafc831 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Wed, 15 Jul 2026 06:22:13 -0700 Subject: [PATCH 44/65] Form: drop column-size sum-to-100 warning is never used to compute layout in Form (editform.py/entrygrid.py size columns from their text content, not the XML value), and PDFForms consumes it purely as a relative weight that works with any positive total. The sum-to-100 warning added for bug 11010 was therefore firing on 78 shipped forms without there being any actual functional issue to fix. Co-Authored-By: Claude Sonnet 5 --- Form/form.py | 4 - Form/form_validator.py | 65 --------------- Form/tests/test_form_validator.py | 121 ---------------------------- Form/tests/test_integration_form.py | 46 ----------- 4 files changed, 236 deletions(-) diff --git a/Form/form.py b/Form/form.py index 940c92be1..eba50fd66 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 5b82cd98c..4b82451de 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/tests/test_form_validator.py b/Form/tests/test_form_validator.py index 0efc350eb..4c9109af0 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 82839c0cf..ad00f394a 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 # --------------------------------------------------------------------------- From 707cfcbbd4ed4a2d5047219331adcf0a92063375 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Sat, 18 Jul 2026 09:43:59 -0700 Subject: [PATCH 45/65] Merge Form: drop column-size sum-to-100 warning#986 --- Form/CensusCheckQuickview.gpr.py | 4 ++-- Form/formgramplet.gpr.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Form/CensusCheckQuickview.gpr.py b/Form/CensusCheckQuickview.gpr.py index be1735549..199445353 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/formgramplet.gpr.py b/Form/formgramplet.gpr.py index 2acbaf2a4..f607bb3e9 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", From 35ee9acfa67b05b2e62bb51a13b5c9b220f591df Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sun, 19 Jul 2026 08:21:00 -0700 Subject: [PATCH 46/65] Remove WordleGramplet WordleGramplet generated word clouds via wordle.net, which no longer exists. The gramplet was already unstable and excluded from the listing, so there is nothing left for it to work against. --- WordleGramplet/WordleGramplet.gpr.py | 16 -- WordleGramplet/WordleGramplet.py | 172 ------------------ WordleGramplet/po/da-local.po | 30 --- WordleGramplet/po/de-local.po | 30 --- WordleGramplet/po/es-local.po | 18 -- WordleGramplet/po/fi-local.po | 32 ---- WordleGramplet/po/fr-local.po | 30 --- WordleGramplet/po/he-local.po | 31 ---- WordleGramplet/po/hr-local.po | 31 ---- WordleGramplet/po/it-local.po | 27 --- WordleGramplet/po/lt-local.po | 25 --- WordleGramplet/po/nb-local.po | 19 -- WordleGramplet/po/nl-local.po | 30 --- WordleGramplet/po/pt_PT-local.po | 30 --- WordleGramplet/po/ru-local.po | 32 ---- WordleGramplet/po/sk-local.po | 30 --- WordleGramplet/po/sv-local.po | 30 --- WordleGramplet/po/template.pot | 71 -------- WordleGramplet/po/uk-local.po | 32 ---- WordleGramplet/tests/__init__.py | 0 .../tests/test_wordlegramplet_imports.py | 91 --------- 21 files changed, 807 deletions(-) delete mode 100644 WordleGramplet/WordleGramplet.gpr.py delete mode 100644 WordleGramplet/WordleGramplet.py delete mode 100644 WordleGramplet/po/da-local.po delete mode 100644 WordleGramplet/po/de-local.po delete mode 100644 WordleGramplet/po/es-local.po delete mode 100644 WordleGramplet/po/fi-local.po delete mode 100644 WordleGramplet/po/fr-local.po delete mode 100644 WordleGramplet/po/he-local.po delete mode 100644 WordleGramplet/po/hr-local.po delete mode 100644 WordleGramplet/po/it-local.po delete mode 100644 WordleGramplet/po/lt-local.po delete mode 100644 WordleGramplet/po/nb-local.po delete mode 100755 WordleGramplet/po/nl-local.po delete mode 100644 WordleGramplet/po/pt_PT-local.po delete mode 100644 WordleGramplet/po/ru-local.po delete mode 100644 WordleGramplet/po/sk-local.po delete mode 100644 WordleGramplet/po/sv-local.po delete mode 100644 WordleGramplet/po/template.pot delete mode 100644 WordleGramplet/po/uk-local.po delete mode 100644 WordleGramplet/tests/__init__.py delete mode 100644 WordleGramplet/tests/test_wordlegramplet_imports.py diff --git a/WordleGramplet/WordleGramplet.gpr.py b/WordleGramplet/WordleGramplet.gpr.py deleted file mode 100644 index 26f0b06bb..000000000 --- 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 86f40db04..000000000 --- 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 2e788ca38..000000000 --- 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 da8e305f1..000000000 --- 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 7e50e7b2c..000000000 --- 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 ba6492615..000000000 --- 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 361263ead..000000000 --- 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 8839d8b55..000000000 --- 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/hr-local.po b/WordleGramplet/po/hr-local.po deleted file mode 100644 index 5577ef922..000000000 --- a/WordleGramplet/po/hr-local.po +++ /dev/null @@ -1,31 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: Gramps 5.x\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2025-03-02 14:58+0000\n" -"Last-Translator: Milo Ivir \n" -"Language-Team: Croatian \n" -"Language: hr\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 "[Nedostaje]" - -msgid "Number of font sizes" -msgstr "Broj veličina fonta" - -msgid "Select filter to restrict list" -msgstr "Odaberi filtar za ograničavanje popisa" - -msgid "Wordle" -msgstr "Wordle" - -msgid "Gramplet used to make word clouds with wordle.net" -msgstr "Gramplet za izradu oblaka s riječima pomoću wordle.net" diff --git a/WordleGramplet/po/it-local.po b/WordleGramplet/po/it-local.po deleted file mode 100644 index d1b26ed32..000000000 --- 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 54f4c266b..000000000 --- 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/nb-local.po b/WordleGramplet/po/nb-local.po deleted file mode 100644 index 0466f6603..000000000 --- a/WordleGramplet/po/nb-local.po +++ /dev/null @@ -1,19 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: nb\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" -"PO-Revision-Date: 2026-06-01 12:35+0000\n" -"Last-Translator: Harald Herreros \n" -"Language-Team: Norwegian Bokmål \n" -"Language: nb\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.6\n" -"Generated-By: pygettext.py 1.4\n" - -msgid "Select filter to restrict list" -msgstr "Velg filter for å begrense listen" diff --git a/WordleGramplet/po/nl-local.po b/WordleGramplet/po/nl-local.po deleted file mode 100755 index bc038627e..000000000 --- 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 33d43706b..000000000 --- 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 5c7c9afe0..000000000 --- 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 770e09611..000000000 --- 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 70112672f..000000000 --- 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 01d3d81c9..000000000 --- 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 c4a803c3a..000000000 --- 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/__init__.py b/WordleGramplet/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/WordleGramplet/tests/test_wordlegramplet_imports.py b/WordleGramplet/tests/test_wordlegramplet_imports.py deleted file mode 100644 index d1749e8c5..000000000 --- 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() From 22156970bd11893208996349b7cba1bb74d3fcbc Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sun, 19 Jul 2026 07:14:13 -0700 Subject: [PATCH 47/65] Fix crash in IsFamilyFilterMatchEvent filter rule The prepare() method built the matching event handle set in self.selected_handles but then tried to update self.events, an attribute that is never defined. This raised an AttributeError whenever the "Events of families matching a " rule was applied, breaking the filter entirely. Reported at: https://gramps.discourse.group/t/crash-of-an-event-filter-using-a-functional-family-filter/9733 --- FilterRules/isfamilyfiltermatchevent.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/FilterRules/isfamilyfiltermatchevent.py b/FilterRules/isfamilyfiltermatchevent.py index 25e0997ee..c533b1e23 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: """ From 6483ea654b9c8b5eefbb97ae9ae58112eb4f33eb Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sun, 19 Jul 2026 07:22:24 -0700 Subject: [PATCH 48/65] Add regression tests for IsFamilyFilterMatchEvent Covers the AttributeError fixed in the previous commit: prepare() crashed because it updated the never-defined self.events instead of self.selected_handles. Tests build a small in-memory database with two families/events, register a custom Family filter matching one of them, and assert prepare()/apply_to_one()/GenericFilter.apply() all behave correctly without raising. --- FilterRules/tests/__init__.py | 0 .../tests/test_isfamilyfiltermatchevent.py | 178 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 FilterRules/tests/__init__.py create mode 100644 FilterRules/tests/test_isfamilyfiltermatchevent.py diff --git a/FilterRules/tests/__init__.py b/FilterRules/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FilterRules/tests/test_isfamilyfiltermatchevent.py b/FilterRules/tests/test_isfamilyfiltermatchevent.py new file mode 100644 index 000000000..ce303bbe9 --- /dev/null +++ b/FilterRules/tests/test_isfamilyfiltermatchevent.py @@ -0,0 +1,178 @@ +# +# 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). Pin Gtk to 3.0 before any gramps +# import (mirrors what gramps.grampsapp does at startup); otherwise +# PyGObject loads GTK4 and the gramps.gui import chain crashes on +# Gtk.IconSize.MENU (a GTK3-only enum). Skip cleanly if GTK 3 / PyGObject +# aren't available. +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError, AttributeError) as err: + raise unittest.SkipTest("GTK 3.0 / 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() From f2344d6cb426d0cb52316042ef37a259ffb45076 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sun, 19 Jul 2026 07:26:21 -0700 Subject: [PATCH 49/65] Drop redundant GTK/GDK pinning in filter rule test PR #950 pins GTK/GDK to 3.0 repo-wide via tests/__init__.py, so the per-file gi.require_version() calls here were redundant. Keep only the ImportError guard for hosts without PyGObject at all. --- .../tests/test_isfamilyfiltermatchevent.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/FilterRules/tests/test_isfamilyfiltermatchevent.py b/FilterRules/tests/test_isfamilyfiltermatchevent.py index ce303bbe9..3a1f9d9f8 100644 --- a/FilterRules/tests/test_isfamilyfiltermatchevent.py +++ b/FilterRules/tests/test_isfamilyfiltermatchevent.py @@ -43,18 +43,13 @@ import unittest # The addon imports Gtk at module load (via -# gramps.gui.editors.filtereditor). Pin Gtk to 3.0 before any gramps -# import (mirrors what gramps.grampsapp does at startup); otherwise -# PyGObject loads GTK4 and the gramps.gui import chain crashes on -# Gtk.IconSize.MENU (a GTK3-only enum). Skip cleanly if GTK 3 / PyGObject -# aren't available. +# 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 - - gi.require_version("Gtk", "3.0") - gi.require_version("Gdk", "3.0") -except (ImportError, ValueError, AttributeError) as err: - raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) +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 From 32b0eeb627e983ad16c3b06f2bd917c33e1be893 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Sun, 19 Jul 2026 16:39:00 -0700 Subject: [PATCH 50/65] Merge Fix crash in IsFamilyFilterMatchEvent filter rule#990 Also update 2 filters that were incorrectly targeted for 6.0 branch instead of 6.1 branch --- FilterRules/activepersonrule.gpr.py | 2 +- FilterRules/ageatdeath.gpr.py | 2 +- FilterRules/associationsofpersonmatch.gpr.py | 2 +- FilterRules/degreesofseparation.gpr.py | 2 +- FilterRules/familieswitheventfiltermatch.gpr.py | 2 +- FilterRules/hasrolerule.gpr.py | 4 ++-- FilterRules/hassourcefilter.gpr.py | 2 +- FilterRules/infamilyrule.gpr.py | 2 +- FilterRules/isfamilyfiltermatchevent.gpr.py | 2 +- FilterRules/isrelatedwithfiltermatch.gpr.py | 2 +- FilterRules/matcheventfilterrole.gpr.py | 4 ++-- FilterRules/matchparentoffilterfamily.gpr.py | 4 ++-- FilterRules/matchpersonfilterrole.gpr.py | 2 +- FilterRules/multipleparents.gpr.py | 2 +- FilterRules/peopleeventscount.gpr.py | 2 +- 15 files changed, 18 insertions(+), 18 deletions(-) diff --git a/FilterRules/activepersonrule.gpr.py b/FilterRules/activepersonrule.gpr.py index 6e0e1c65a..12cb0c391 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 5409577a7..6ec50f564 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 2d06c54f0..e0abc867d 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 5d2be8448..5b17ea6e8 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 5adc41a1e..77444832f 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 8efbd4204..7bb7b4c10 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 3ab286c3a..66d656521 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 18657cb03..424964aeb 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 2b5511199..06dca6e2a 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/isrelatedwithfiltermatch.gpr.py b/FilterRules/isrelatedwithfiltermatch.gpr.py index a3848a56b..1b3e74c3a 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 7b8d14511..848389ea1 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 9f1dfb68c..2f7206569 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 014aef6f9..4b7e932f3 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 e9cb4639c..dded1ed92 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 112a5f215..62cfe0e27 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", From 5868a5c34a352c743af98a7229655f88acd90491 Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Mon, 20 Jul 2026 02:26:24 +0200 Subject: [PATCH 51/65] Add the Addon Development manual under docs/addon-development Seventeen manual pages for addon authors - overview and getting started, tutorials per addon kind, the addon-kinds catalogue, registration fundamentals, data access, API reference, testing, debugging, troubleshooting, code analysis, internationalization, packaging, post-merge community steps, compatibility, per-release changes, normative guidelines, and roadmap - plus the diagrams they embed and a folder index. The new docs/ tree is invisible to make.py and CI: every enumeration keys on *.gpr.py or *.py globs that match nothing under docs/, verified with manifest-check and a no-op 'build docs' run. --- docs/addon-development/01-overview.md | 180 ++++++ docs/addon-development/02-tutorials.md | 594 ++++++++++++++++++ docs/addon-development/03-addon-kinds.md | 206 ++++++ docs/addon-development/04-fundamentals.md | 384 +++++++++++ docs/addon-development/05-data-access.md | 229 +++++++ docs/addon-development/06-api-reference.md | 209 ++++++ docs/addon-development/07-testing.md | 302 +++++++++ docs/addon-development/08-debug.md | 184 ++++++ docs/addon-development/09-troubleshoot.md | 213 +++++++ docs/addon-development/10-code-analysis.md | 235 +++++++ .../11-internationalization.md | 180 ++++++ docs/addon-development/12-packaging.md | 285 +++++++++ docs/addon-development/13-community.md | 84 +++ docs/addon-development/14-compatibility.md | 114 ++++ docs/addon-development/15-whats-new.md | 79 +++ docs/addon-development/16-guidelines.md | 183 ++++++ docs/addon-development/17-roadmap.md | 106 ++++ docs/addon-development/README.md | 24 + .../_media/addon-kinds-ui-map.svg | 172 +++++ docs/addon-development/_media/data-model.dot | 80 +++ docs/addon-development/_media/data-model.svg | 168 +++++ .../_media/packaging-pipeline.dot | 85 +++ .../_media/packaging-pipeline.svg | 124 ++++ .../_media/plugin-discovery.dot | 40 ++ .../_media/plugin-discovery.svg | 90 +++ 25 files changed, 4550 insertions(+) create mode 100644 docs/addon-development/01-overview.md create mode 100644 docs/addon-development/02-tutorials.md create mode 100644 docs/addon-development/03-addon-kinds.md create mode 100644 docs/addon-development/04-fundamentals.md create mode 100644 docs/addon-development/05-data-access.md create mode 100644 docs/addon-development/06-api-reference.md create mode 100644 docs/addon-development/07-testing.md create mode 100644 docs/addon-development/08-debug.md create mode 100644 docs/addon-development/09-troubleshoot.md create mode 100644 docs/addon-development/10-code-analysis.md create mode 100644 docs/addon-development/11-internationalization.md create mode 100644 docs/addon-development/12-packaging.md create mode 100644 docs/addon-development/13-community.md create mode 100644 docs/addon-development/14-compatibility.md create mode 100644 docs/addon-development/15-whats-new.md create mode 100644 docs/addon-development/16-guidelines.md create mode 100644 docs/addon-development/17-roadmap.md create mode 100644 docs/addon-development/README.md create mode 100644 docs/addon-development/_media/addon-kinds-ui-map.svg create mode 100644 docs/addon-development/_media/data-model.dot create mode 100644 docs/addon-development/_media/data-model.svg create mode 100644 docs/addon-development/_media/packaging-pipeline.dot create mode 100644 docs/addon-development/_media/packaging-pipeline.svg create mode 100644 docs/addon-development/_media/plugin-discovery.dot create mode 100644 docs/addon-development/_media/plugin-discovery.svg diff --git a/docs/addon-development/01-overview.md b/docs/addon-development/01-overview.md new file mode 100644 index 000000000..85a906284 --- /dev/null +++ b/docs/addon-development/01-overview.md @@ -0,0 +1,180 @@ +# Addon Development + +[Index](01-overview.md) · [Next →](02-tutorials.md) + +## Overview + +A Gramps **addon** extends the application without modifying core. You add a feature, ship it on your own schedule, and users install it from the in-app Plugin Manager — no fork of Gramps, no waiting on a core release to put new functionality in front of people. An addon is just a folder of Python on the plugin path, so the barrier to entry is low; the trade-off is that you build against Gramps' API and track it across versions. This is how most of Gramps' reports, tools, and gramplets are delivered, and the same door is open to you. + +Addons are discovered from the plugin directory; see [the addon list](https://gramps-project.org/wiki/index.php/6.0_Addons) for what ships today. + +This page is the **start point** for the section: first a map to every other page, then everything a first-time author needs to go from "Gramps is installed" to "my addon shows up in the menu" — anatomy, prerequisites, and a minimal working Gramplet. The normative MUST / SHOULD rules every addon is held to live in [Rules](16-guidelines.md). + +## The section at a glance + +**New to addon development?** Work through this page, then read in order — from your first loaded addon to a tested, rules-compliant one: + +*this page* → [Addon Kinds](03-addon-kinds.md) → [Fundamentals](04-fundamentals.md) → [Data access](05-data-access.md) → [Testing](07-testing.md) → [Rules](16-guidelines.md) + +**Looking for something specific?** Jump straight to it: + +| If you want to… | Go to | +|-----------------|-------| +| Install the tooling and see your first addon load | *this page, below* | +| Follow an end-to-end walkthrough for your addon kind | [Tutorials](02-tutorials.md) | +| Choose which kind of addon to build | [Addon Kinds](03-addon-kinds.md) | +| Learn the cross-cutting basics — `.gpr.py`, discovery, `_()`, logging, lifecycle | [Fundamentals](04-fundamentals.md) | +| Read from or write to the database | [Data access](05-data-access.md) | +| Look up the `gramps.gen` API an addon may import | [API Reference](06-api-reference.md) | +| Write and run tests | [Testing](07-testing.md) | +| Debug an addon that isn't behaving | [Debug](08-debug.md) | +| Diagnose a common failure mode | [Troubleshoot](09-troubleshoot.md) | +| Pass the static checks (Black, ruff) | [Code Analysis](10-code-analysis.md) | +| Translate your addon's strings | [Internationalization](11-internationalization.md) | +| Package and submit your addon | [Packaging](12-packaging.md) | +| List, announce, and support your published addon | [Community](13-community.md) | +| Port across Gramps versions | [Compatibility](14-compatibility.md) | +| See per-version changes that affect addons | [What's New](15-whats-new.md) | +| Know the rules to follow — and to cite in review | [Rules](16-guidelines.md) | +| See what's planned, or propose a change | [Roadmap](17-roadmap.md) | + +The one page to bookmark is [Rules](16-guidelines.md) — the normative MUST / SHOULD / MAY reference every addon is held to. + +## What an addon can extend (at a glance) + +Almost every part of the Gramps UI is a plugin point. The common kinds: + +| Kind | Adds | Shows up in | +|------|------|-------------| +| **Gramplet** | a lightweight widget over the current selection | Dashboard / sidebar | +| **View** | a full alternative way to browse the tree | main view area | +| **Report** | text or graphical output (PDF, HTML, ODF, …) | Reports menu | +| **Tool** | an operation over the database | Tools menu | +| **Importer / Exporter** | reading or writing an external format | File → Import / Export | +| **Quick View** | a one-call report on a selected object | right-click menus | + +…plus filter rules, sidebars, map providers, relationship calculators, citation formatters, docgen output backends, and more. The full catalogue — with the registration fields and base class each kind needs — is [Addon Kinds](03-addon-kinds.md). + +## Anatomy of an addon + +An addon is a folder under Gramps' user plugin directory — one folder per addon — holding at minimum a registration file and an implementation module: + +| File | Purpose | +|------|---------| +| `.gpr.py` | Registration: id, name, version, Gramps target, kind, entry point | +| `.py` | The implementation Gramps loads on demand | +| `po/` | Translation catalogs (optional) | +| `tests/` | Unit tests (optional, recommended) | + +At startup Gramps scans every `.gpr.py` and builds a metadata catalog from the `register(...)` call(s); the implementation module named by `fname` loads **lazily**, on first use. The consequence to remember: an error in `.gpr.py` hides the addon entirely, while an error in the implementation only surfaces when the addon is invoked. + +The registration declares the Gramps version it targets (`gramps_target_version`) — an addon on `maintenance/gramps60` expects the 6.0 API; see [Compatibility](14-compatibility.md) for cross-version concerns. + +What you build next depends on the **kind** — Gramplet, View, Report, Tool, Importer/Exporter, Quick View, and more — each adding its own registration fields and base class. Choose one in [Addon Kinds](03-addon-kinds.md); the full `.gpr.py` field reference and the discovery model are in [Fundamentals](04-fundamentals.md). + +## Prerequisites + +| Requirement | Why | +|-------------|-----| +| Gramps 6.0 installed and runnable | The target you're developing against | +| Python 3.10+ | Matches Gramps 6.0's minimum | +| A text editor or IDE | Any will do; Gramps doesn't impose one | +| Familiarity with Python imports and packages | Addons are Python modules | + +You do **not** need to build Gramps from source for addon work. Addons load from the user plugin directory and are picked up at next start. + +## Where addons live + +Each addon is a folder under Gramps' user plugin directory, one folder per addon. The exact path is platform-specific; see [the Addons page](https://gramps-project.org/wiki/index.php/6.0_Addons) for the canonical locations. The folder name must be a valid Python import name (no spaces — addons share code via `import `); it need **not** match the registration `id`, which is an independent plugin key ([Rules](16-guidelines.md) → Structure). + +On Gramps 6.0, plugin discovery does **not** follow symlinks — the addon must be physically present under the plugin path, so the development loop is copying (or `rsync`ing) from your working tree on save. + +**Changed in 6.1**: plugin discovery follows symlinks (with realpath-based dedup against symlink loops), so you can `ln -s /` into the user plugin directory and edit in place. Windows users: the 6.1 symlink test is skipped on Windows because the platform's symlink behavior is inconsistent without elevated privileges; the `rsync`/copy loop remains the safe default there. (gramps commit `9443dcbb30` on `maintenance/gramps61`.) + +## Your first addon: a minimal Gramplet + +A *Gramplet* is the lightest-weight addon kind — a sidebar widget. Two files are enough. + +### 1. Create the addon folder + +Make a folder named `HelloGramplet` under the user plugin directory. + +### 2. Add the registration file + +Save this as `HelloGramplet/HelloGramplet.gpr.py`: + +```python +register( + GRAMPLET, + id="HelloGramplet", + name=_("Hello Gramplet"), + description=_("A minimal example Gramplet"), + version="1.0.0", + gramps_target_version="6.0", + status=STABLE, + fname="hellogramplet.py", + gramplet="HelloGramplet", + gramplet_title=_("Hello"), +) +``` + +The `id` is the addon's stable identifier. `fname` is the implementation module. `gramplet` is the class inside it that Gramps will instantiate. + +### 3. Add the implementation + +Save this as `HelloGramplet/hellogramplet.py`: + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.plug import Gramplet + +_ = glocale.get_addon_translator(__file__).gettext + + +class HelloGramplet(Gramplet): + def init(self): + self.set_text(_("Hello from your first Gramplet!")) +``` + +`init()` is the construction hook — Gramps calls it once when the Gramplet is first shown. The `_ = glocale...` line binds the translation function for this module — see [Translation](#translation) below. + +### 4. Restart Gramps + +Plugin discovery happens at startup. After the restart, the new Gramplet appears under *View → Sidebar* (or the Dashboard, depending on view). + +## Reload / test cycle + +There is no hot-reload for addons. The development loop is: + +1. Edit the source. +2. Sync the change into the plugin directory (or work directly there). +3. Restart Gramps. +4. Observe. + +For faster iteration on non-GUI logic, write a `unittest`-based test alongside the addon and run it without launching Gramps — see [Testing](07-testing.md) for the conventions. + +## Translation + +Wrap every user-visible string in `_()` so it can be translated: + +```python +self.set_text(_("Hello from your first Gramplet!")) +``` + +`_` is set up differently in the two files. In `.gpr.py` it is injected by the plugin loader — just use it, never import it. In the implementation module nothing is injected: bind it explicitly at the top of the file, as the walkthrough's `hellogramplet.py` does: + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale + +_ = glocale.get_addon_translator(__file__).gettext +``` + +Translation catalogues live in a per-addon `po/` directory — optional for a first experiment, required for an addon you intend to share; [Internationalization](11-internationalization.md) covers the workflow. + +## Next steps + +- [Tutorials](02-tutorials.md) — end-to-end walkthroughs per addon kind; read a similar addon's source as your second tutorial ([6.0 Addons](https://gramps-project.org/wiki/index.php/6.0_Addons) lists what exists). +- [Addon Kinds](03-addon-kinds.md) — choose the kind of addon to build; registration fields and base class per kind. +- [Fundamentals](04-fundamentals.md) — every `.gpr.py` field, the discovery model, and the lifecycle hooks the implementation overrides. +- [Testing](07-testing.md) — unit-test conventions and the `tests/` package layout. +- [Addons development](https://gramps-project.org/wiki/index.php/Addons_development) — cross-version porting notes and the wider development reference. diff --git a/docs/addon-development/02-tutorials.md b/docs/addon-development/02-tutorials.md new file mode 100644 index 000000000..60a69f3dc --- /dev/null +++ b/docs/addon-development/02-tutorials.md @@ -0,0 +1,594 @@ +# Tutorials + +[← Previous](01-overview.md) · [Index](01-overview.md) · [Next →](03-addon-kinds.md) + + + +## Overview + +End-to-end walkthroughs that take an author from empty folder to working addon. Each tutorial picks one kind, covers registration, implementation, and the reload cycle, and points at the conventions used to test it. + +Read these in order or skip to the one that matches what you're building — they're independent. They assume you've already followed [the getting-started walkthrough in 01-overview](01-overview.md#your-first-addon-a-minimal-gramplet), so we don't re-explain the user plugin directory or the restart cycle. + +| Tutorial | Kind | What it shows | +|---------------------------|---------------|---------------------------------------------------------------------| +| [A live Gramplet](#a-live-gramplet) | `GRAMPLET` | Reading the DB, refreshing on selection change, signal subscriptions | +| [A simple Tool](#a-simple-tool) | `TOOL` | The Tool / ToolOptions pair, opening a dialog, writing in a `DbTxn` | +| [A text Report](#a-text-report) | `REPORT` | The Report / ReportOptions pair, the docgen abstraction, paragraph styles | +| [A Quick View](#a-quick-view) | `QUICKVIEW` | The `run()` entry point, the Simple Access API, context-menu integration | +| [A custom filter Rule](#a-custom-filter-rule) | `RULE` | Subclassing the namespace Rule base, declaring `labels`, `apply_to_one` | + +For the conceptual map, see [01-overview](01-overview.md). For the full inventory of addon kinds and their registration constants, see [03-addon-kinds](03-addon-kinds.md). + +### A note on tutorial-style code + +The implementation modules below show the smallest code that demonstrates each kind. Two things are deliberately omitted to keep the lesson in focus, and both are **required** for shipped addons: + +- A **GPL-2.0-or-later license header** at the top of every `.py` file. Copy the header from any existing addon, or see [16-guidelines → Coding style](16-guidelines.md#coding-style). +- **Type hints** on public functions and methods (Python 3.10+ syntax — `X | None`, `list[X]`). The tutorials skip them for readability; production addons should include them per [16-guidelines → Coding style](16-guidelines.md#coding-style). + +Both are CI-checked on gramps core PRs (Black formats around the license header; `mypy` verifies the type hints); addons-source doesn't gate on them today but the rules apply to addon code regardless. + +## A live Gramplet + +**Goal.** Build a sidebar Gramplet that reads the active person from the database and shows their direct events, refreshing whenever the active person changes or the database is updated. + +The Hello Gramplet from [the overview's walkthrough](01-overview.md#your-first-addon-a-minimal-gramplet) was static text. This one is dynamic — it subscribes to signals and re-reads the DB on each update. + +### Layout + +Two files in a new folder `PersonEvents/`: + +``` +PersonEvents/ +├── PersonEvents.gpr.py +└── personevents.py +``` + +### `PersonEvents/PersonEvents.gpr.py` + +```python +register( + GRAMPLET, + id="PersonEvents", + name=_("Person Events"), + description=_("Lists the active person's direct events."), + version="1.0.0", + gramps_target_version="6.0", + status=STABLE, + fname="personevents.py", + gramplet="PersonEventsGramplet", + gramplet_title=_("Events"), + height=200, + expand=True, +) +``` + +`height` and `expand` are Gramplet-specific layout fields; the rest are the same registration shape introduced in [04-fundamentals → The `.gpr.py` registration file](04-fundamentals.md#the-gprpy-registration-file). + +### `PersonEvents/personevents.py` + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.plug import Gramplet + +_ = glocale.get_addon_translator(__file__).gettext + + +class PersonEventsGramplet(Gramplet): + """List the active person's direct events; refresh on changes.""" + + def init(self): + """Build the static parts of the UI once.""" + self.set_use_markup(True) + self.set_text(_("No active person.")) + + def db_changed(self): + """Subscribe to DB signals each time the active DB changes.""" + self.connect(self.dbstate.db, "person-update", self.update) + self.connect(self.dbstate.db, "person-delete", self.update) + self.connect(self.dbstate.db, "event-update", self.update) + + def active_changed(self, handle): + """Active person changed — re-render.""" + self.update() + + def main(self): + """Pull events for the active person and render them.""" + person_handle = self.get_active("Person") + if not person_handle: + self.set_text(_("No active person.")) + return + + person = self.dbstate.db.get_person_from_handle(person_handle) + if person is None: + self.set_text(_("Active person not found.")) + return + + lines = [f"{person.gramps_id}\n"] + for event_ref in person.get_event_ref_list(): + event = self.dbstate.db.get_event_from_handle(event_ref.ref) + if event is None: + continue + date = event.get_date_object() + lines.append(f"{event.get_type()} {date}") + + self.set_text("\n".join(lines)) +``` + +### What's new vs. Hello Gramplet + +- **`db_changed()`** subscribes to DB signals. Using `self.connect(...)` (defined on `Gramplet`) instead of `self.dbstate.db.connect(...)` means Gramps tracks the subscription keys for you and disconnects them automatically when the gramplet closes or the DB swaps out. The forgotten-disconnect bug class is gone. +- **`active_changed(handle)`** is called by Gramps when the user selects a different person in the active view. The default does nothing; calling `self.update()` triggers a redraw. +- **`get_active("Person")`** returns the handle of the active person for the current view, or `None`. It honours navigation context — in a Place view it returns the active place, etc. +- **`set_use_markup(True)`** lets `set_text()` interpret Pango markup (``, ``, …); see [Gramplet textual methods](https://gramps-project.org/wiki/index.php/Gramplets_development#Textual_Output_Methods). + +### Try it + +Drop the folder into your user plugin directory (or symlink it if you're on Gramps 6.1+), restart Gramps, open a tree, and add the Gramplet from the sidebar menu. Click around different people — the displayed events should change with the selection. + +For the API surface this tutorial used (handles, refs, `iter_*`, `commit_*`), see [05-data-access](05-data-access.md). For the signal inventory, see [04-fundamentals → Signals](04-fundamentals.md#signals-addons-reacting-to-changes). + +## A simple Tool + +**Goal.** A menu-launched Tool that scans the database for people with no recorded birth date and shows the list in a dialog. + +Tools differ from gramplets in two ways: they're invoked from the Tools menu (not always visible), and they always carry an Options class — even a tool with no options must register an empty `ToolOptions` subclass. + +### Layout + +``` +MissingBirthDates/ +├── MissingBirthDates.gpr.py +└── missingbirthdates.py +``` + +### `MissingBirthDates/MissingBirthDates.gpr.py` + +```python +register( + TOOL, + id="MissingBirthDates", + name=_("Missing Birth Dates"), + description=_("Lists people with no recorded birth date."), + version="1.0.0", + gramps_target_version="6.0", + status=STABLE, + fname="missingbirthdates.py", + category=TOOL_ANAL, + toolclass="MissingBirthDates", + optionclass="MissingBirthDatesOptions", + tool_modes=[TOOL_MODE_GUI], +) +``` + +`category=TOOL_ANAL` puts the tool under *Tools → Analysis and Exploration*. Other categories (`TOOL_DBPROC`, `TOOL_DBFIX`, …) are listed in [03-addon-kinds → `TOOL`](03-addon-kinds.md#tool). + +### `MissingBirthDates/missingbirthdates.py` + +```python +from gi.repository import Gtk + +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gui.dialog import OkDialog +from gramps.gui.plug import tool + +_ = glocale.get_addon_translator(__file__).gettext + + +class MissingBirthDates(tool.Tool): + """Scan the DB and report people with no recorded birth date.""" + + def __init__(self, dbstate, user, options_class, name, callback=None): + tool.Tool.__init__(self, dbstate, options_class, name) + + db = dbstate.db + missing = [] + for person in db.iter_people(): + birth_ref = person.get_birth_ref() + if birth_ref is None: + missing.append(person) + continue + event = db.get_event_from_handle(birth_ref.ref) + if event is None or event.get_date_object().is_empty(): + missing.append(person) + + if not missing: + OkDialog( + _("Missing Birth Dates"), + _("Every person has a recorded birth date."), + parent=user.uistate.window, + ) + return + + lines = [f"{p.gramps_id}: {p.get_primary_name().get_name()}" + for p in missing] + OkDialog( + _("Missing Birth Dates"), + _("{n} people with no recorded birth date:\n\n{listing}").format( + n=len(missing), + listing="\n".join(lines), + ), + parent=user.uistate.window, + ) + + +class MissingBirthDatesOptions(tool.ToolOptions): + """No options — placeholder required by the tool framework.""" +``` + +### What's new + +- **`tool.Tool.__init__(self, dbstate, options_class, name)`** — the base-class constructor. The body of `__init__` is *where the tool runs*; there's no separate `run()` method for GUI tools. +- **`MissingBirthDatesOptions`** is required even though we have no options. The `register(...)` call names it via `optionclass`, and Gramps would refuse to load the tool without it. +- **`OkDialog`** is the simplest modal report-back surface; for richer output, build a `Gtk.Dialog` directly (see `gramps/plugins/tool/dumpgenderstats.py` for the standard recipe). + +### Writing data + +If your tool *modifies* the database, all writes go inside a `DbTxn`: + +```python +from gramps.gen.db import DbTxn + +with DbTxn(_("Mark unreferenced media private"), db) as trans: + for media in db.iter_media(): + if not db.find_backlink_handles(media.handle): + media.set_privacy(True) + db.commit_media(media, trans) +``` + +The transaction message is user-visible in the Undo history; translate it. See [05-data-access → Mutating data](05-data-access.md#mutating-data) for the full pattern. + +### Try it + +After restart, the tool appears in *Tools → Analysis and Exploration → Missing Birth Dates*. Run it on `example.gramps` to see the dialog. + +## A text Report + +**Goal.** A simple text report that summarises the database — number of people, number of families, count by gender. Produces the same content through PDF, HTML, ODF, or any other docgen-supported format. + +Reports are the heaviest of the everyday addon kinds. Three pieces work together: + +- A **Report** class that knows how to walk the data and emit it as paragraphs and tables, leaving format details to the docgen. +- An **Options** class that defines user-adjustable options and the paragraph / font styles. +- A **registration** call wiring both into the menu. + +### Layout + +``` +DbSummary/ +├── DbSummary.gpr.py +└── dbsummary.py +``` + +### `DbSummary/DbSummary.gpr.py` + +```python +register( + REPORT, + id="DbSummary", + name=_("Database Summary"), + description=_("Produces a short summary of the family tree."), + version="1.0.0", + gramps_target_version="6.0", + status=STABLE, + fname="dbsummary.py", + category=CATEGORY_TEXT, + require_active=False, + reportclass="DbSummaryReport", + optionclass="DbSummaryOptions", + report_modes=[REPORT_MODE_GUI, REPORT_MODE_CLI], +) +``` + +`category=CATEGORY_TEXT` makes this a text report — Gramps will offer the user the text-output document backends (PDF, ODF, plain text, …). `require_active=False` because a database summary doesn't need a specific active person. + +### `DbSummary/dbsummary.py` + +```python +from collections import Counter + +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.lib import Person +from gramps.gen.plug import docgen +from gramps.gen.plug.report import MenuReportOptions, Report +from gramps.gen.plug.report import stdoptions + +_ = glocale.get_addon_translator(__file__).gettext + + +class DbSummaryReport(Report): + """A text report summarising the database.""" + + def __init__(self, database, options_class, user): + Report.__init__(self, database, options_class, user) + self.set_locale( + options_class.menu.get_option_by_name("trans").get_value() + ) + self._count() + + def _count(self): + """Walk every Person and tally.""" + self.total = 0 + gender_counts = Counter() + surnames = Counter() + for person in self.database.iter_people(): + self.total += 1 + gender_counts[person.get_gender()] += 1 + primary = person.get_primary_name() + surnames[primary.get_primary_surname().get_surname()] += 1 + self.gender_counts = gender_counts + self.unique_surnames = len(surnames) + self.top_surname = ( + surnames.most_common(1)[0] if surnames else (_("(none)"), 0) + ) + + def write_report(self): + """Emit paragraphs into self.doc.""" + self.doc.start_paragraph("DBS-Title") + self.doc.write_text(self._("Database Summary")) + self.doc.end_paragraph() + + self.doc.start_paragraph("DBS-Normal") + self.doc.write_text( + self._("Total persons: {n}").format(n=self.total)) + self.doc.end_paragraph() + + for gender_code, label in [ + (Person.MALE, _("Males")), + (Person.FEMALE, _("Females")), + (Person.UNKNOWN, _("Unknown gender")), + ]: + self.doc.start_paragraph("DBS-Normal") + self.doc.write_text( + self._("{label}: {n}").format( + label=label, + n=self.gender_counts.get(gender_code, 0), + ) + ) + self.doc.end_paragraph() + + self.doc.start_paragraph("DBS-Normal") + self.doc.write_text( + self._("Unique surnames: {n}").format(n=self.unique_surnames) + ) + self.doc.end_paragraph() + + self.doc.start_paragraph("DBS-Normal") + self.doc.write_text( + self._("Most common surname: {name} ({n})").format( + name=self.top_surname[0], n=self.top_surname[1]) + ) + self.doc.end_paragraph() + + +class DbSummaryOptions(MenuReportOptions): + """Options form and default styles for DbSummaryReport.""" + + def add_menu_options(self, menu): + category = _("Report Options") + stdoptions.add_localization_option(menu, category) + + def make_default_style(self, default_style): + # Title style: 18 pt bold sans-serif, centred, header level 1. + font = docgen.FontStyle() + font.set_size(18) + font.set_type_face(docgen.FONT_SANS_SERIF) + font.set_bold(True) + para = docgen.ParagraphStyle() + para.set_header_level(1) + para.set_alignment(docgen.PARA_ALIGN_CENTER) + para.set_font(font) + para.set_description(_("Style used for the title of the report.")) + default_style.add_paragraph_style("DBS-Title", para) + + # Body style: 12 pt serif. + font = docgen.FontStyle() + font.set_size(12) + font.set_type_face(docgen.FONT_SERIF) + para = docgen.ParagraphStyle() + para.set_font(font) + para.set_description(_("Style used for normal report text.")) + default_style.add_paragraph_style("DBS-Normal", para) +``` + +### What's new + +- **Two classes, one file.** The `register()` call points `reportclass` at the Report and `optionclass` at the Options. +- **`self.doc` is not a file.** It's the live document — a docgen backend instance. The report writes paragraphs and text into it regardless of output format. +- **Paragraph style names are prefixed.** Use `DBS-` (or any short prefix unique to your report) on every style name. Reports get composed into Book reports, where every style name has to be unique across all contributing reports. +- **Localisation is explicit.** `stdoptions.add_localization_option` adds the standard "report locale" option to the form; the report reads it with `self.set_locale(...)` and uses `self._()` for strings that should follow the *report's* chosen locale rather than the UI locale. The leading underscore in `self._` is intentional. +- **`MenuReportOptions`** is the convenient base; for a no-options report, override only `add_menu_options` (to add the locale option) and `make_default_style` (to define paragraph styles). + +### Try it + +After restart, the report appears in *Reports → Text Reports → Database Summary*. Run it through any text document backend (PDF, ODF, plain text) to see the same content reformatted by each. + +For more on the docgen abstraction, see [Report Generation](https://gramps-project.org/wiki/index.php/Report_Generation). For richer reports (tables, multiple paragraph levels, graphical reports using `CATEGORY_DRAW`), see [Report API](https://gramps-project.org/wiki/index.php/Report_API). + +## A Quick View + +**Goal.** A right-click action on a person that lists their siblings — brothers and sisters from every family they're a child in. + +Quick Views are the shortest path to a usable report. There's no class to subclass and no options form to maintain — just a `run()` function and the registration. They're written against the **Simple Access API** (`SimpleAccess`, `SimpleDoc`), which trades some power for very little code. + +### Layout + +``` +Siblings/ +├── Siblings.gpr.py +└── siblings.py +``` + +### `Siblings/Siblings.gpr.py` + +```python +register( + QUICKVIEW, + id="Siblings", + name=_("Siblings"), + description=_("Lists the active person's siblings."), + version="1.0.0", + gramps_target_version="6.0", + status=STABLE, + fname="siblings.py", + category=CATEGORY_QR_PERSON, + runfunc="run", +) +``` + +`category=CATEGORY_QR_PERSON` puts the entry on the person context menu. `runfunc="run"` names the function Gramps calls. The full set of categories is listed in [03-addon-kinds → `QUICKVIEW`](03-addon-kinds.md#quickview). + +### `Siblings/siblings.py` + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.simple import SimpleAccess, SimpleDoc +from gramps.gui.plug.quick import QuickTable + +_ = glocale.get_addon_translator(__file__).gettext + + +def run(database, document, person): + """Display all siblings of the given person.""" + sdb = SimpleAccess(database) + sdoc = SimpleDoc(document) + + sdoc.title(_("Siblings of {name}").format(name=sdb.name(person))) + sdoc.paragraph("") + + table = QuickTable(sdb) + table.columns(_("Person"), _("Gender"), _("Birth date")) + + own_gid = sdb.gid(person) + for family in sdb.child_in(person): + for child in sdb.children(family): + if sdb.gid(child) == own_gid: + continue + table.row(child, sdb.gender(child), sdb.birth_date(child)) + document.has_data = True + + table.write(sdoc) +``` + +### What's new + +- **`run(database, document, person)`** — the function signature is fixed by the QuickView kind. The third argument is the *selected object* of the category (`CATEGORY_QR_PERSON` → person, `CATEGORY_QR_FAMILY` → family, …). +- **`SimpleAccess`** is the high-level read interface — `sdb.children(family)`, `sdb.birth_date(person)`, `sdb.name(person)`. It hides handle dereferencing, refs, and date formatting. For the full surface, see [Simple Access API](https://gramps-project.org/wiki/index.php/Simple_Access_API). +- **`SimpleDoc`** is the matching write interface — `sdoc.title(...)`, `sdoc.paragraph(...)`, `sdoc.header1(...)`. +- **`QuickTable`** builds an interactive table where each row links back to a real Gramps object — clicking a person opens that person. +- **`document.has_data = True`** tells Gramps the report produced output. When all rows are filtered out, the empty-state path triggers instead. + +### Try it + +After restart, right-click any person in the People view or the person editor. *Quick View → Siblings* appears in the menu. The result opens in a Quick View window; clicking a row in the table opens that person. + +For Quick Views that don't fit the Simple Access surface, you can reach for the full DB API — see [05-data-access](05-data-access.md). The two are complementary; a complex Quick View can use both. + +## A custom filter Rule + +**Goal.** A filter rule "Has at least N children" that the user can add to a custom person filter from the Filter Editor. + +Filter rules are the smallest addon kind by line count and the one with the most reuse: a single rule, written once, drops into every filter the user composes — search, narrative website, reports, gramplets that accept a filter. + +### Layout + +``` +HasNChildren/ +├── HasNChildren.gpr.py +└── hasnchildren.py +``` + +### `HasNChildren/HasNChildren.gpr.py` + +```python +register( + RULE, + id="HasNChildren", + name=_("People with at least N children"), + description=_("Matches people who have at least N children."), + version="1.0.0", + gramps_target_version="6.0", + status=STABLE, + fname="hasnchildren.py", + ruleclass="HasNChildren", + namespace="Person", +) +``` + +`namespace="Person"` says this rule applies to people. The other namespaces (`Family`, `Event`, `Place`, `Source`, `Citation`, `Repository`, `Media`, `Note`) get their own rules — Gramps' filter editor groups rules by namespace. + +### `HasNChildren/hasnchildren.py` + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.filters.rules import Rule + +_ = glocale.get_addon_translator(__file__).gettext + + +class HasNChildren(Rule): + """Matches people with at least N children.""" + + labels = [_("Minimum count:")] + name = _("People with at least N children") + category = _("Family filters") + description = _("Matches people with at least N children") + + def apply_to_one(self, db, person): + try: + minimum = int(self.list[0]) + except (TypeError, ValueError): + return False + total = 0 + for family_handle in person.get_family_handle_list(): + family = db.get_family_from_handle(family_handle) + if family is None: + continue + total += len(family.get_child_ref_list()) + if total >= minimum: + return True + return False +``` + +### What's new + +- **`labels`** declares the user-prompted arguments — one entry per text box in the filter-editor dialog. The user's typed values arrive on `self.list` in the same order. Always parse defensively; `self.list[0]` is a string straight from the GUI. +- **`name`, `category`, `description`** are class attributes — Gramps reads them off the class (no instance needed) when building the Add Rule dialog. `category` is the section the rule appears under in that dialog. +- **`apply_to_one(self, db, person)`** is the per-object hook. It returns `True` for a match, `False` for a non-match. Gramps calls it for every person in the namespace when applying the filter. On Gramps 6.0 the API is `apply_to_one`; older releases used `apply` (see [gramps/gen/filters/rules/_rule.py:162](https://github.com/gramps-project/gramps/blob/maintenance/gramps60/gramps/gen/filters/rules/_rule.py#L162)). + +### Optional hooks + +- **`prepare(self, db, user)`** — called once before the rule is applied to many objects, on demand. Use it to precompute lookup tables when `apply_to_one` would otherwise repeat expensive work. Pair with `reset()` to release memory afterwards. +- **`allow_regex = True`** — opt the first label into regex input. + +### Try it + +After restart, *Edit → Person Filter Editor → Add → Add Rule* shows "People with at least N children" under *Family filters*. The user types a number in the "Minimum count" field; the rule does the rest. + +The rule is also visible from gramplets like *Filter Gramplet* and as an input to any tool or report that accepts a person filter — no extra work needed; rules are uniform across the framework. + +## See also + +- [01-overview → Your first addon](01-overview.md#your-first-addon-a-minimal-gramplet) — the prerequisites and the development loop these tutorials build on. +- [03-addon-kinds](03-addon-kinds.md) — registration details per kind. +- [04-fundamentals](04-fundamentals.md) — `.gpr.py` fields, signals, `requires_mod`, lifecycle hooks. +- [05-data-access](05-data-access.md) — the DB API patterns used by these tutorials. +- [07-testing](07-testing.md) — how to test what you just wrote without launching Gramps. +- [Report API](https://gramps-project.org/wiki/index.php/Report_API), [Report Generation](https://gramps-project.org/wiki/index.php/Report_Generation) — depth on the docgen abstraction. +- [Simple Access API](https://gramps-project.org/wiki/index.php/Simple_Access_API) — the Quick View read surface. diff --git a/docs/addon-development/03-addon-kinds.md b/docs/addon-development/03-addon-kinds.md new file mode 100644 index 000000000..9abcfa056 --- /dev/null +++ b/docs/addon-development/03-addon-kinds.md @@ -0,0 +1,206 @@ +# Addon Kinds + +[← Previous](02-tutorials.md) · [Index](01-overview.md) · [Next →](04-fundamentals.md) + + + +## Overview + +Gramps doesn't have one "addon" shape — it has 14 of them, each registered with a different `register(KIND, …)` constant and each plugged in at a different extension point. This page is the index over all of them, with the registration constant, the UI location, the base class to subclass, and a pointer onward. Use it to answer the first question every prospective addon author asks: **which kind of thing am I writing?** + +Source of truth for the constants: [`gramps/gen/plug/_pluginreg.py`](https://github.com/gramps-project/gramps/blob/maintenance/gramps60/gramps/gen/plug/_pluginreg.py). + +![Fig. 1 — Where each addon kind plugs into the Gramps UI. Menu-anchored kinds (REPORT, TOOL, IMPORT/EXPORT) appear inline with the menu item that hosts them; panel-anchored kinds (SIDEBAR, VIEW, GRAMPLET, QUICKVIEW, MAPSERVICE, RULE) carry callouts to their surface. The six kinds with no direct UI surface — DOCGEN, DATABASE, RELCALC, THUMBNAILER, CITE, GENERAL — are listed separately. Schematic; relative positions match Gramps 6.0's default layout but are not pixel-accurate.](_media/addon-kinds-ui-map.svg) + +## Kinds at a glance + +| Constant | Where it shows up | Typical use | +|----------------|----------------------------------|------------------------------------------------------------------------------| +| `GRAMPLET` | Dashboard, sidebar, bottombar | Lightweight widget over the current selection | +| `VIEW` | Main view area | A full alternative way to browse the tree | +| `REPORT` | Reports menu | Text / graphical output (PDF, HTML, ODF, …) using the docgen interface | +| `TOOL` | Tools menu | Operates on the database, optionally writing inside a transaction | +| `IMPORT` | File → Import | Reads an external format into the tree | +| `EXPORT` | File → Export | Writes the tree to an external format | +| `DOCGEN` | Report output backends | Adds a new output format / paper backend used by reports | +| `QUICKVIEW` | Right-click context menus | Single-call short report on a selected object (formerly `QUICKREPORT`) | +| `SIDEBAR` | Sidebar navigator | Adds a new sidebar category | +| `MAPSERVICE` | Geography view | Adds a new map tile provider | +| `RELCALC` | Relationships view | Per-locale relationship calculator | +| `RULE` | Filter editor | Adds a new filter rule for an object type | +| `DATABASE` | New tree backend selection | Adds support for another database backend | +| `THUMBNAILER` | Media handling | Adds a thumbnail generator for an additional media format | +| `CITE` | Source citations | Adds a citation formatter style | +| `GENERAL` | (varies) | Catch-all for libraries / pluggable categories (`WEBSTUFF`, `Filters`, …) | + +`QUICKREPORT` is the legacy name for `QUICKVIEW`; the integer constant is identical (`gramps/gen/plug/_pluginreg.py` line 83). New addons use `QUICKVIEW`; existing ones continue to work. + +## Per-kind notes + +The notes below cover the kinds an addon author is likely to write. Kinds with deeper conventions get their own section; the rest are summarised in one paragraph each. For full attribute lists per kind, the authoritative reference is the `expand_*` functions in `_pluginreg.py`. + +### `GRAMPLET` + +**Where it shows up:** docked in the Dashboard, sidebar, or bottombar of any view; can be detached into a floating window. + +**Base class:** subclass `gramps.gen.plug.Gramplet`. Override `init()` (constructor hook, runs once), `main()` (re-run on update), `db_changed()` (called when the active database changes), and `active_changed()` (called when the active person / family / etc. changes). + +**Minimum-viable shape:** + +```python +from gramps.gen.plug import Gramplet + +class MyGramplet(Gramplet): + def init(self): + self.set_text(_("Hello")) +``` + +**Registration:** see [01-overview → Add the registration file](01-overview.md#2-add-the-registration-file) for the full call. Required Gramplet-specific fields are `gramplet` (the class name) and `gramplet_title` (the user-visible tab title). + +**Tutorial:** [02-tutorials → A live Gramplet](02-tutorials.md#a-live-gramplet). + +### `REPORT` + +**Where it shows up:** Reports menu, organised by category. + +**Base class:** subclass `gramps.gen.plug.report.Report`. Override `write_report()` to emit content. Pair with an options class that subclasses `gramps.gen.plug.report.MenuReportOptions` and overrides `add_menu_options()` (to define user-adjustable options) and `make_default_style()` (to define paragraph and font styles). + +**Categories** (`_pluginreg.py` L141–L149): `CATEGORY_TEXT`, `CATEGORY_DRAW`, `CATEGORY_CODE`, `CATEGORY_WEB`, `CATEGORY_BOOK`, `CATEGORY_GRAPHVIZ`, `CATEGORY_TREE`. Text and Draw reports go through the docgen abstraction, so the same report can emit PDF / HTML / ODF without per-format code. + +**Report modes** (`report_modes` field): `REPORT_MODE_GUI` (dialog-driven), `REPORT_MODE_BKI` (book item), `REPORT_MODE_CLI` (command line). Most addons combine GUI + CLI. + +**Tutorial:** [02-tutorials → A text Report](02-tutorials.md#a-text-report). + +### `TOOL` + +**Where it shows up:** Tools menu, optionally categorised. + +**Base class:** subclass a class from `gramps.gui.plug.tool` (typically `Tool` or `BatchTool`). Override the constructor — Gramps passes `(dbstate, user, options_class, name, callback=None)`. Tools that mutate the database **must** do so inside a `DbTxn`. + +**Categories** (`_pluginreg.py` L154–L159): `TOOL_DEBUG`, `TOOL_ANAL`, `TOOL_DBPROC`, `TOOL_DBFIX`, `TOOL_REVCTL`, `TOOL_UTILS`. Choose the one that matches what the tool actually does — `TOOL_DBFIX` for repairs, `TOOL_ANAL` for read-only analysis, `TOOL_UTILS` for generic utilities. + +**Tool modes** (`tool_modes` field, `_pluginreg.py` L183–L184): `TOOL_MODE_GUI` and `TOOL_MODE_CLI`. A pure-data tool should support both so a power user can scriptit. + +**Tutorial:** [02-tutorials → A simple Tool](02-tutorials.md#a-simple-tool). + +### `QUICKVIEW` + +**Where it shows up:** right-click context menus on the selected object in views and editors. + +**Entry point:** a `run(database, document, person_or_family_or_…)` function declared in the implementation module and pointed to by the `runfunc` field. No class subclassing required. + +**Categories** (`_pluginreg.py` L163–L174): `CATEGORY_QR_PERSON`, `CATEGORY_QR_FAMILY`, `CATEGORY_QR_EVENT`, `CATEGORY_QR_SOURCE`, `CATEGORY_QR_PLACE`, `CATEGORY_QR_REPOSITORY`, `CATEGORY_QR_NOTE`, `CATEGORY_QR_DATE`, `CATEGORY_QR_MEDIA`, `CATEGORY_QR_CITATION`, `CATEGORY_QR_SOURCE_OR_CITATION`, `CATEGORY_QR_MISC`. The category determines which context menu the entry appears in. + +Quick Views are deliberately the shortest path to a usable report — written against the `gramps.gen.simple` API (`SimpleAccess`, `SimpleDoc`), they hide most of the docgen complexity. Reach for a full `REPORT` only when you need styles, paragraph layout, or multiple output formats. + +**Tutorial:** [02-tutorials → A Quick View](02-tutorials.md#a-quick-view). + +### `RULE` + +**Where it shows up:** the Add Rule dialog when the user composes a custom filter from the Filter Editor; available wherever filters are. + +**Base class:** subclass the right rule base from `gramps.gen.filters.rules` — pick the namespace-specific base (`gramps.gen.filters.rules.person.Rule`, `…family.Rule`, etc.) that matches the object type your rule applies to. Set the class attributes `name`, `description`, `category`, and `labels` (the user-prompted arguments); implement `apply(db, obj)` to return `True` / `False`. + +**Tutorial:** [02-tutorials → A custom filter Rule](02-tutorials.md#a-custom-filter-rule). + +### `VIEW` + +**Where it shows up:** the main view area; available from the navigator once registered. + +**Base class:** subclass an appropriate view from `gramps.gui.views` (`NavigationView`, `ListView`, `PageView`). Views are the heaviest addon kind — they own the entire display surface and the keyboard / mouse interaction. Most addons should reach for `GRAMPLET` instead and only graduate to `VIEW` when the gramplet outgrows its container. + +**Live examples:** `CombinedView`, `LifeLineChartView`, `QuiltView` — read one before writing your own. + +### `IMPORT` / `EXPORT` + +**Where they show up:** File → Import / Export, with the new format appearing in the format dropdown. + +**Entry point:** a module-level function. Importers receive `(database, filename, user)`; exporters receive `(database, filename, error_dialog, option_box, callback)` (signatures vary slightly by Gramps minor; the safest move is to read a live importer/exporter and copy the shape). + +**Live examples:** the GEDCOM (`gramps/plugins/importer/importgedcom.py`, `…/exporter/exportgedcom.py`) and JSON importers/exporters in core are the canonical references. + +### `DOCGEN` + +**Where it shows up:** as a new output format in any Report's options dialog; not user-launched on its own. + +**Base class:** subclass `gramps.gen.plug.docgen.BaseDoc` (or the text/draw subclasses depending on what kind of output you generate). A DocGen implements the *primitives* — paragraphs, tables, drawing commands — that the abstract Report classes call into. Authors usually only write a new DocGen to add a new output format (e.g. a new word-processor file type); it's a relatively rare addon kind. + +### `SIDEBAR` + +**Where it shows up:** the navigator on the left of the main window; each `SIDEBAR` plugin adds one category. + +**Base class:** subclass `gramps.gui.sidebar.Sidebar`. Core categories (People, Families, Events, …) are themselves implemented this way, so the canonical examples ship in core under `gramps/gui/sidebar/`. + +### `MAPSERVICE` + +**Where it shows up:** the Geography views' map-source dropdown. + +**Base class:** subclass `gramps.plugins.lib.maps.osmgps.MapService` and implement the URL / tile-fetch protocol for your provider. Pure tile adapters — no UI changes — so most are very small. + +### `RELCALC` + +**Where it shows up:** wherever Gramps computes a relationship string (Relationships view, person editor, reports). One `RELCALC` plugin per locale. + +**Base class:** subclass `gramps.gen.relationship.RelationshipCalculator`. The base class supplies all the English-language logic; subclasses override the localised strings and any kinship rules specific to the culture being modelled. + +### `DATABASE` + +**Where it shows up:** the database-backend dropdown in tree creation. + +Adds a fully alternative storage backend implementing the `DbReadBase` / `DbWriteBase` interfaces. By far the heaviest kind — the only current in-tree examples are the BSDDB and SQLite backends themselves. Treat the existence of this kind as "yes, it is possible," not "you should consider writing one." + +### `THUMBNAILER` + +**Where it shows up:** wherever Gramps generates a media thumbnail. + +Adds a generator for one additional media format. Pure-function shape: input file → thumbnail image. Use this when a media type Gramps recognises doesn't have a working thumbnailer in your environment. + +### `CITE` + +**Where it shows up:** the citation style chooser in source / citation editors and reports. + +Adds an alternative citation formatter (Chicago, MLA, Evidence Explained, …). Implements the formatting protocol expected by the source / citation code; cite an existing core formatter (`gramps/plugins/cite/`) for the exact shape on the branch you're targeting. + +### `GENERAL` + +**Where it shows up:** nowhere directly — `GENERAL` is the escape hatch for plugin code that doesn't fit any other kind. Two main uses: + +- **Shared libraries** — code reused across multiple addons. Set `load_on_reg=True` and the file gets imported at startup; everything in it becomes importable to other plugins as `import `. The `libwebconnect` addon, depended on by every Web Connect Pack, is the archetype. +- **Pluggable categories** — `GENERAL` plugins can declare a `category` string; other code can then ask the plugin manager for all `GENERAL` plugins of category `WEBSTUFF` (CSS stylesheets for the narrative website report) or `Filters` (filter-rule providers). New categories are rare; the published ones are documented in [addons-development](https://gramps-project.org/wiki/index.php/Addons_development#Registered_GENERAL_Categories). + +The category `WEBSTUFF` is the one most addon authors meet: addons that ship a stylesheet for the narrative website register as `GENERAL, category="WEBSTUFF"` and the website report picks them up automatically. + +**The plugin-data API.** Three registration fields drive the category machinery. A plugin contributes data either statically (`data = [...]` right in the `.gpr.py`) or dynamically — if the implementation module defines a function named `load_on_reg(dbstate, uistate, plugin)`, Gramps calls it at registration and its return value becomes the plugin's data. A `process = "function_name"` field names a function applied over the accumulated data when a consumer asks for it. Consumers query by category through the plugin manager: + +```python +from gramps.gui.pluginmanager import GuiPluginManager + +plugman = GuiPluginManager.get_instance() +plugman.get_plugin_data("WEBSTUFF") # all data from WEBSTUFF plugins +plugman.process_plugin_data("WEBSTUFF") # same, run through the process function +``` + +Note there is **no automatic loading** of `GENERAL` plugins beyond this: without `load_on_reg=True` the module sits unimported until something imports it explicitly. + +## Multiple kinds in one addon + +A single `.gpr.py` can call `register(...)` more than once. The classic case is a report that also registers a Quick View entry for the same underlying logic (`gramps/plugins/quickview/all_events.py` does this for events). Each `register()` call is independent; only the addon folder / `id` and the implementation file(s) are shared. + +## See also + +- [01-overview](01-overview.md) — what an addon is, file roles, first Gramplet end-to-end. +- [02-tutorials](02-tutorials.md) — per-kind walkthroughs. +- [04-fundamentals](04-fundamentals.md) — the cross-cutting concepts every kind relies on, including [the provided environment](04-fundamentals.md#the-provided-environment) every kind inherits from Gramps' startup. +- [`gramps/gen/plug/_pluginreg.py`](https://github.com/gramps-project/gramps/blob/maintenance/gramps60/gramps/gen/plug/_pluginreg.py) — the authoritative definition of all the constants and `expand_*` attribute lists per kind. +- [6.0 Addons](https://gramps-project.org/wiki/index.php/6.0_Addons) — the canonical catalogue of what already exists per kind; reading a similar addon's source is your fastest second tutorial. diff --git a/docs/addon-development/04-fundamentals.md b/docs/addon-development/04-fundamentals.md new file mode 100644 index 000000000..74639de4f --- /dev/null +++ b/docs/addon-development/04-fundamentals.md @@ -0,0 +1,384 @@ +# Fundamentals + +[← Previous](03-addon-kinds.md) · [Index](01-overview.md) · [Next →](05-data-access.md) + + + +## Overview + +The cross-cutting concerns every addon author hits regardless of which kind they're building. If something in a kind-specific page assumes a piece of background, it's described here. + +![Fig. 1 — Plugin discovery and load sequence. Gramps scans the plugin directory at startup, executes each `register()` call into a metadata-only catalog, and loads the implementation module lazily when the user first invokes the addon.](_media/plugin-discovery.svg) + +Note that the catalog → invoke arrow is dashed: addon implementation modules are *not* loaded at startup. The `.gpr.py` is what runs during discovery; the `fname` module only loads on first use. This is why a registration-time error blocks the whole addon from appearing, but a runtime error in the implementation only surfaces when the user triggers it. + +## The `.gpr.py` registration file + +Every addon ships exactly one `.gpr.py` per folder, executed at startup by Gramps' plugin scanner. Its single job is to call `register(...)` one or more times, declaring the addon's *metadata* — what kind it is, what version of Gramps it targets, which implementation module to load on demand. + +The general shape: + +```python +register( + GRAMPLET, # kind (see 03-addon-kinds) + id="HelloGramplet", # stable identifier — folder name + name=_("Hello Gramplet"), # user-visible label + description=_("A minimal example"), + version="1.0.0", # addon version, X.Y.Z + gramps_target_version="6.0", # which Gramps minor this targets + status=STABLE, # STABLE / BETA / EXPERIMENTAL / UNSTABLE + fname="hellogramplet.py", # implementation module + # kind-specific fields go here + gramplet="HelloGramplet", + gramplet_title=_("Hello"), +) +``` + +### Fields every kind needs + +| Field | Meaning | +|-------------------------|----------------------------------------------------------------------------------| +| `id` | Stable plugin key, unique across addons; need **not** match the folder name | +| `name` | User-visible label, translatable | +| `version` | Addon version, dotted `X.Y.Z` | +| `gramps_target_version` | The Gramps minor this targets, e.g. `"6.0"` | +| `status` | `STABLE`, `BETA`, `EXPERIMENTAL`, or `UNSTABLE` | +| `fname` | The implementation module Gramps loads on first use | + +### Fields most kinds want + +- `description` — shown in the Plugin Manager tooltip. +- `authors`, `authors_email` — credit and contact, both lists. +- `maintainers`, `maintainers_email` — only set if different from authors. +- `help_url` — wiki page name; Gramps prepends the base URL and may add a language extension. Don't wrap in `_()` unless you actually want per-language wiki pages. +- `audience` — `EVERYONE` (default), `EXPERT`, or `DEVELOPER`; filters visibility in the Plugin Manager. The constants live at `_pluginreg.py:75-77` — note `EVERYONE`, not `ALL` (an outdated wiki page documents `ALL`; the code has only ever used `EVERYONE`). + +### Kind-specific fields + +Every kind adds its own. A few examples: + +- `GRAMPLET` adds `gramplet` (class or function name), `gramplet_title`, `height`, `expand`, `navtypes`, `force_update`. +- `REPORT` adds `reportclass`, `optionclass`, `category`, `report_modes`, `require_active`. +- `TOOL` adds `toolclass`, `optionclass`, `category`, `tool_modes`. +- `QUICKVIEW` adds `runfunc`, `category`. + +[03-addon-kinds](03-addon-kinds.md) lists the kind-specific fields per kind. The authoritative reference is the `expand_*` helpers in [`_pluginreg.py`](https://github.com/gramps-project/gramps/blob/maintenance/gramps60/gramps/gen/plug/_pluginreg.py). + +### Multiple registrations per file + +A single `.gpr.py` may call `register(...)` more than once — for example a report that also exposes a quick view, or two related gramplets sharing one implementation module. Each call is independent metadata. + +## Plugin discovery + +Gramps walks the plugin path at startup, executes every `.gpr.py` it finds, and builds an in-memory catalog from each `register()` call. The implementation modules pointed to by `fname` are **not** loaded at this point — they're imported lazily on first invocation. This split matters for diagnostics: + +- A `SyntaxError` or import failure in `.gpr.py` makes the addon disappear entirely from menus — the catalog never got an entry for it. +- A failure inside the implementation module surfaces only when the user triggers the addon, with a traceback in the Plugin Manager and the log window. + +### The plugin path + +Plugin folders are searched under each path Gramps was configured to scan — typically the system-wide plugin dir plus the per-user plugin dir. The per-user dir is the safe one to develop in; system locations generally need elevated permissions and shouldn't be edited directly. The exact paths are platform-specific; [the Addons page](https://gramps-project.org/wiki/index.php/6.0_Addons) lists them. + +### Symlinks + +Plugin discovery's symlink handling changed between 6.0 and 6.1: + +- **Gramps 6.0** — symlinks are **not** followed. An addon symlinked in is invisible. Development loop: copy/`rsync` from working tree on save. +- **Gramps 6.1+** — symlinks **are** followed, with realpath-based dedup so cycles terminate. Symlinking the working tree into the user plugin dir works in place. (Gramps commit [`9443dcbb30`](https://github.com/gramps-project/gramps/commit/9443dcbb30) on `maintenance/gramps61`.) The symlink test is skipped on Windows because the platform's symlink behaviour is inconsistent without elevated privileges; on Windows, a physical copy remains the safe approach even on 6.1+. + +Concrete sync recipes live in [01-overview → Where addons live](01-overview.md#where-addons-live). + +## Names Gramps injects into `.gpr.py` + +The `.gpr.py` runs in a scope where several names are *pre-populated* by the plugin loader. You **must not import** them; Gramps puts them there and an `import` masks them with stale bindings. + +| Injected name | Source | +|------------------------------------------------------------------|-------------------------------------| +| `register` | the loader itself | +| `_` (and `ngettext`) | the addon's local translation | +| Kind constants — `GRAMPLET`, `REPORT`, `TOOL`, … | `gramps.gen.plug._pluginreg` | +| Status constants — `STABLE`, `BETA`, `EXPERIMENTAL`, `UNSTABLE` | `_pluginreg.py:62-65` | +| Audience constants — `EVERYONE`, `EXPERT`, `DEVELOPER` | `_pluginreg.py:75-77` | +| Report category constants — `CATEGORY_TEXT`, `CATEGORY_DRAW`, … | `_pluginreg.py:141-149` | +| Tool category constants — `TOOL_DBPROC`, `TOOL_DBFIX`, … | `_pluginreg.py:154-159` | +| Quick View category constants — `CATEGORY_QR_PERSON`, … | `_pluginreg.py:163-174` | +| Report mode constants — `REPORT_MODE_GUI`, `REPORT_MODE_BKI`, … | `_pluginreg.py` | + +In the implementation module, none of these are injected — the rules are normal Python. Import what you need from `gramps.gen.*` there. + +## The provided environment + +The injected names are one half of what Gramps hands an addon; the other half is process-global. An addon — whatever its kind — is a **guest in Gramps' process**: before the first plugin loads, Gramps' startup (`gramps/grampsapp.py`, with `gramps/gen/utils/grampslocale.py` and `gramps/gen/plug/_manager.py`) has already configured the state the addon runs inside. Each item below is a real temptation, because setting it up yourself is exactly what makes a module work *standalone* — and each one either collides with, or silently hijacks, the running application. The rule is uniform: **the app provides this state at runtime; the test root provides it under test; addon modules touch none of it.** + +| Gramps sets up at startup | The tempting mistake | An addon instead | Documented in | +|---------------------------|----------------------|------------------|---------------| +| **GI version pins** — `gi.require_version("Gtk", "3.0")` / `("Gdk", "3.0")` before any plugin loads | Pinning in the addon module or a test file so bare `unittest` imports work | Never pin; addons-source's repo-root `tests/__init__.py` carries the pins (PR 950) | [07-testing → The GTK-pin contract](07-testing.md#the-gtk-pin-contract) | +| **Locale & translation** — `locale.setlocale(LC_ALL, "")`, gettext domain binding, ICU collators | `gettext.install()` (overwrites the builtin `_` app-wide), `locale.setlocale` for date/number formatting, `locale.strcoll` for sorting | Use the injected `_` in `.gpr.py`; `glocale.get_addon_translator(__file__)` in modules; `glocale.sort_key` for collation | [Translation](#translation) below, [11-internationalization](11-internationalization.md) | +| **Root logger & error reporting** — WARNING-level root logger with stderr/file handlers; in the GUI, `GtkHandler` turns ERROR into the error-report dialog | `logging.basicConfig(...)` or `getLogger().setLevel(...)` in a module or test to "see output" — duplicates handlers and reroutes the error dialog for the whole app | A named module-level logger only: `LOG = logging.getLogger(".MyAddon")` | [Logging](#logging) below, [08-debug → Default log levels](08-debug.md#default-log-levels) | +| **`sys.path`** — the plugin manager adds your addon dir *transiently* at import and pops it after | `sys.path.insert(0, os.path.dirname(__file__))` at module level so sibling/vendored imports resolve standalone — inside Gramps the entry is permanent and global, and a generic `utils.py` shadows every other addon's | Rely on the loader's import semantics; under test, the invocation through the package root provides the path | [09-troubleshoot → Imports and namespace traps](09-troubleshoot.md#imports-and-python-namespace-traps) | +| **The GTK main loop & global GTK state** — one `Gtk.main()` loop, the icon theme, screen-wide CSS, `Gtk.Settings` | `Gtk.main()` / `Gtk.main_quit()` around your own dialog (the standalone-script habit); installing app-wide CSS providers or retheming globally | Use Gramps' dialog and windowing machinery; style your own widgets, never the screen | [16-guidelines → Runtime](16-guidelines.md#runtime) | +| **`sys.excepthook`** — logs unhandled exceptions and, on a `HandleError`, flags the DB for check-and-repair at next start | Installing your own hook for "nicer" error handling — disables crash reporting *and* the DB-repair flag app-wide | Let exceptions propagate; log expected failures through your module logger | [08-debug](08-debug.md) | +| **Environment & user paths** — `GRAMPS_RESOURCES`, `PANGOCAIRO_BACKEND` (Windows), the user config/plugin directories | `os.environ[...] = ...` in module or test-module code; computing Gramps paths from `__file__` | Paths come from `gramps.gen.const`; environment setup belongs to the harness (test root, CI) | [07-testing → Running tests locally](07-testing.md#running-tests-locally) | + +The test-side mirror of this table — the repository-root `tests/__init__.py` reproducing the slice of this environment modules under test need, with test runs going through the repo root so it loads — is the contract in [07-testing → The GTK-pin contract](07-testing.md#the-gtk-pin-contract). + +## Translation + +Every user-visible string in the `.gpr.py` and in the implementation goes through `_()`. The function is set up differently in the two files because the `.gpr.py` runs in the injected-name scope. + +**In `.gpr.py`**: just use `_()`. The loader has already wired it. + +```python +register( + GRAMPLET, + id="HelloGramplet", + name=_("Hello"), + description=_("A minimal example"), + ... +) +``` + +**In the implementation module**: opt into the addon's own translation catalog at the top of the file, then use `_()` normally. + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale + +_ = glocale.get_addon_translator(__file__).gettext +``` + +This binds `_` to translations stored in the addon's own `po/` folder rather than Gramps' core catalog. Without this line, `_()` falls back to the core catalog and your addon-specific strings stay in English regardless of UI language. + +### Plurals + +Use `ngettext(singular, plural, n)` whenever a number is being formatted into a string. Languages with non-trivial plural rules (Russian, Polish, …) need both forms to render correctly. + +```python +msg = ngettext("{n} match", "{n} matches", n).format(n=n) +``` + +### Disambiguating contexts + +When the same English word translates differently in different contexts, add a context hint. Gramps' `_()` accepts `_(msg, context)`; the older `pgettext(context, msg)` form also works but the comma form is preferred because the source remains readable as plain English. + +```python +_("Source", "citation") # vs. _("Source", "person attribute") +``` + +## Logging + +Use a module-level logger; never use `print()` for diagnostics. + +```python +import logging + +LOG = logging.getLogger(".".join(__name__.split(".")[-2:])) +# or simply: +LOG = logging.getLogger(__name__) + +LOG.debug("Reached the interesting branch with n=%d", n) +LOG.warning("Skipping malformed event %s", event.gramps_id) +``` + +Log output flows into: + +- **The Gramps log window** (Help → Log) — visible to the user. +- **stderr** when Gramps is launched with `--debug` or with `GRAMPS_DEBUG=1` set. + +See [08-debug](08-debug.md) for how to enable debug levels per logger. + +## Lifecycle hooks + +Every kind has its own entry points; the shape varies, but the pattern is consistent: a small number of named methods that Gramps calls at specific moments, and you override the ones you need. + +### Gramplets + +Subclass `gramps.gen.plug.Gramplet`. The hooks Gramps calls: + +| Method | When | +|----------------------|-----------------------------------------------------------------------------------| +| `init(self)` | Once, on first show. Build the UI here. Don't read the DB yet — it may not be open. | +| `db_changed(self)` | When the active database changes. Reconnect any signals you wired on the old DB. | +| `active_changed(self, handle)` | When the active person / family / etc. changes. Default is to call `update()`. | +| `main(self)` | The work itself. May be a generator — `yield True` to keep going, `yield False` to stop. | +| `update(self)` | Don't override. Calls `main()` for you; you call `update()` to schedule a redraw. | +| `on_load(self)` / `on_save(self)` | When the gramplet's persistent data is loaded / saved. | + +Inside the class, `self.dbstate.db` is your live database, `self.uistate` is the GUI state. See [05-data-access](05-data-access.md) for what you can do with `self.dbstate.db`. + +### Reports + +Subclass `gramps.gen.plug.report.Report`. The constructor receives `(database, options_class, user)`. Override `write_report()` — that's the single hook Gramps calls. Everything else is plumbing you initialise in `__init__`. + +### Tools + +Subclass from `gramps.gui.plug.tool`. The constructor receives `(dbstate, user, options_class, name, callback=None)` and does the work inline (there's no separate `run()` for non-CLI tools). For CLI mode, `tool_modes=[TOOL_MODE_CLI]` triggers a different entry path. + +### Quick Views + +Plain function: `run(database, document, person_or_family_or_…)`. No class to subclass. Point `runfunc` at it in the registration. + +### Importers / Exporters + +Plain function pointed to by `fname` + the kind's entry-point field. Signature varies by kind and minor; reading a live importer/exporter is the most reliable way to lock down the exact shape on your target branch. + +## Signals: addons reacting to changes + +Gramps' database and UI emit *signals* when state changes. Addons that need to stay in sync — gramplets that refresh on data changes, views that follow the selection — `connect()` to those signals. + +### The minimal pattern + +```python +key = self.dbstate.db.connect("person-update", self.cb_person_changed) +# … later, in teardown … +self.dbstate.db.disconnect(key) +``` + +`connect()` returns an opaque key; pass it to `disconnect()` when the addon shuts down or the database changes. Forgetting to disconnect leaves stale callbacks pointing into freed objects and crashes Gramps sooner or later. + +### The signals that matter most + +| Source | Signal | When | +|------------------------|--------------------------------------------------|--------------------------------------------------------------------| +| `dbstate.db` | `person-add`, `family-add`, `event-add`, … | One object added. Arg: list of handles. | +| `dbstate.db` | `person-update`, `family-update`, … | One object updated. Arg: list of handles. | +| `dbstate.db` | `person-delete`, `family-delete`, … | One object deleted. Arg: list of handles. | +| `dbstate.db` | `person-rebuild`, `family-rebuild`, … | Mass change (import, db repair). No args. | +| `dbstate.db` | `home-person-changed` | Home person changed. No args. | +| `dbstate` | `database-changed` | Active database swapped. Arg: the new db. | +| `dbstate` | `no-database` | No db is open. | +| `uistate` | `nameformat-changed`, `filter-name-changed`, … | Various UI preferences. | +| view's history | `active-changed` | Selected object changed. Arg: the new handle. | + +Pattern: `person-update` / `family-update` / etc. fire one *after* a transaction commits, with a *list* of affected handles. They never fire mid-transaction, so callbacks can safely re-read the DB. + +### Subscribing to "anything changed" + +A common gramplet pattern is "redraw on any structural change to the tree", typically done by wiring `db_changed`: + +```python +def db_changed(self): + self.dbstate.db.connect("person-add", self.update) + self.dbstate.db.connect("person-delete", self.update) + self.dbstate.db.connect("person-update", self.update) + self.dbstate.db.connect("family-add", self.update) + self.dbstate.db.connect("family-delete", self.update) + self.dbstate.db.connect("family-update", self.update) +``` + +For complex subscriptions across many object types, the `CallbackManager` in `gramps.gen.utils.callman` is a higher-level filter that lets you register dictionaries of `{signal: handler}` and tracks keys for `disconnect_all()` on teardown. See [Signals and callbacks](https://gramps-project.org/wiki/index.php/Signals_and_Callbacks) for the full inventory. + +### Signal ordering + +Signals are deferred until a transaction commits and are emitted in a specific order: deletes first, then adds, then updates; within each phase, by object type in the order persons → families → sources → events → media → places → repositories → notes → tags → citations. This deterministic order matters when a single transaction touches related objects (a family merge deletes one family and updates another plus its members); a handler that re-reads the DB on `person-delete` will see a consistent state. + +## Reading and writing the database + +The DB API is covered in depth in [05-data-access](05-data-access.md). The rule worth stating here, where every addon meets it: + +- **Reading** is unrestricted. Any addon may read freely from `self.dbstate.db`. +- **Writing** goes through a transaction. Always: + + ```python + with DbTxn(_("Description for Undo history"), db) as trans: + person = db.get_person_from_handle(handle) + person.set_privacy(True) + db.commit_person(person, trans) + ``` + +The transaction message is user-visible in the Undo history; translate it. + +## Declaring dependencies + +Addons may need Python packages or system tools that aren't part of Gramps' core dependencies. Declare these in the registration so the plugin manager can surface a clear "missing X" message instead of a generic import failure. + +### `requires_mod` — Python modules + +```python +requires_mod = ["PIL", "lxml"] +``` + +Uses the **importable** module name (what you `import`), **not** the PyPI distribution name. PIL not Pillow, lxml fine either way (matches), yaml not PyYAML. Verify before you push: + +```python +from importlib.util import find_spec +assert find_spec("PIL") is not None +``` + +A mismatch shows up the first time the addon's tests run against a clean install — the import fails. Always verify the name with `find_spec` before publishing. + +### `requires_gi` — GObject Introspection bindings + +```python +requires_gi = [("GExiv2", "0.10")] +``` + +A list of `(namespace, version)` tuples. The user has to install these through their OS package manager; Gramps cannot install GI bindings. The version pin must match what your code actually imports — and on gramps61 the version handling for GExiv2 was rewritten (addons-source PR 829), so a `requires_gi` pinned for one branch isn't guaranteed correct on the other. Verify against the target branch's related code before assuming a cherry-pick is correct. + +### `requires_exe` — Executables on PATH + +```python +requires_exe = ["graphviz", "dot"] +``` + +External binaries the user must have installed. Gramps checks PATH for them and surfaces a missing-dependency message. + +### `depends_on` — Other addons + +```python +depends_on = ["libwebconnect"] +``` + +Other addons that must load first. The plugin manager resolves these automatically when the user installs your addon. Circular dependencies break the load and disable the addon — the loader chooses safety over guessing. + +## Configuration and persistent settings + +For settings that should survive between sessions, Gramps' configuration manager handles the file I/O and migration; you only declare the keys. + +```python +from gramps.gen.config import config as configman + +config = configman.register_manager("my_addon") +config.register("section.key1", default_value) +config.register("section.key2", another_default) +config.load() # read existing settings file, if any +config.save() # write defaults out if the file didn't exist +``` + +`config.get("section.key1")` and `config.set("section.key1", value)` read and write at runtime. Gramplets persist via the lifecycle hook: + +```python +def on_save(self): + config.save() +``` + +The settings file lives in the addon's plugin folder by default. For a system-wide config (rare): + +```python +config = configman.register_manager("my_addon", use_config_path=True) +``` + +Other code — another addon, a repro script — can read an addon's settings without re-registering the keys, via `get_manager`: + +```python +from gramps.gen.config import config as configman + +config = configman.get_manager("my_addon") +value = config.get("section.key1") +``` + +## See also + +- [01-overview → Your first addon](01-overview.md#your-first-addon-a-minimal-gramplet) — the first end-to-end Gramplet putting these concepts together. +- [03-addon-kinds](03-addon-kinds.md) — what each kind adds to the registration shape described here. +- [05-data-access](05-data-access.md) — the DB API surface. +- [06-api-reference](06-api-reference.md) — the curated `gramps.gen.*` surface that addons may import. +- [09-troubleshoot](09-troubleshoot.md) — what failure modes look like when one of these conventions is off. +- [Signals and Callbacks](https://gramps-project.org/wiki/index.php/Signals_and_Callbacks) — the standalone wiki page covering signals and the `CallbackManager` in more depth. diff --git a/docs/addon-development/05-data-access.md b/docs/addon-development/05-data-access.md new file mode 100644 index 000000000..3697c9439 --- /dev/null +++ b/docs/addon-development/05-data-access.md @@ -0,0 +1,229 @@ +# Data access + +[← Previous](04-fundamentals.md) · [Index](01-overview.md) · [Next →](06-api-reference.md) + + + +## Overview + +Every addon that does anything useful with a family tree reads or writes through the **database API** — the `DbReadBase` / `DbWriteBase` interface implemented by Gramps' database backends (BSDDB historically, SQLite from 6.0 onward). + +You don't instantiate a database yourself. The plugin loader hands you a `DbState` object; the live database is `dbstate.db`. Everything below is methods on that handle. + +```python +db = dbstate.db # this is your entry point +``` + +The same `db` works for read-only addons (reports, gramplets, quick views) and for tools that mutate data. Mutation goes through transactions; see [Mutating data](#mutating-data) below. + +## Identifying objects: handles vs Gramps IDs + +Every primary object (Person, Family, Event, Place, Source, Citation, Repository, Media, Note, Tag) has **two identifiers**: + +| Identifier | Stable | Format | Used for | +|------------|--------|--------|----------| +| **Handle** | Yes (internal, never reused) | 32-char hex string | Cross-references in the database | +| **Gramps ID** | User-renameable | `I0001`, `F0001`, `E0001`, ... | User-visible labels and external interop | + +**Rule of thumb:** use handles inside your code; show Gramps IDs to the user. Handles never change; Gramps IDs do (the user can edit them, the "Reorder Gramps IDs" tool can rewrite them in bulk). + +```python +# Right: traverse by handle +person = db.get_person_from_handle(handle) + +# Right: show the user a Gramps ID +print(f"Working on {person.gramps_id}") + +# Wrong: traverse by Gramps ID (works, but slower and breaks under reorder) +person = db.get_person_from_gramps_id("I0001") +``` + +Each object class has both lookup methods (`get__from_handle` and `get__from_gramps_id`); see [06-api-reference](06-api-reference.md) for the full list. + +## Reading: one object at a time + +The fastest pattern, when you have a handle in hand: + +```python +person = db.get_person_from_handle(person_handle) +family = db.get_family_from_handle(family_handle) +event = db.get_event_from_handle(event_handle) +``` + +Each returns `None` if the handle isn't in the database (deleted, broken reference). Always guard: + +```python +person = db.get_person_from_handle(handle) +if person is None: + return # silently skip, or raise a HandleError if the caller expects one +``` + +For `HandleError` and friends, import from `gramps.gen.errors`. + +## Reading: iterating all objects + +For reports and surveys you'll want every object of a given type. The database exposes one generator per object class: + +```python +for person in db.iter_people(): + ... + +for family in db.iter_families(): + ... +``` + +These are **generators**, not lists — they stream through the database without loading everything into memory. Don't call `list(db.iter_people())` on a 50,000-person tree unless you have a reason. + +To iterate just the handles (cheaper when you only need to count or filter): + +```python +for handle in db.iter_person_handles(): + ... +``` + +Counts come without iteration: + +```python +db.get_number_of_people() +db.get_number_of_families() +db.get_number_of_events() +``` + +## Following references + +![Fig. 1 — Gramps primary objects and the most-traversed relationships. Edges labelled `Ref` (e.g. `EventRef`, `CitationRef`, `MediaRef`) go through a ref object that carries metadata such as the role or relationship; bare-labelled edges are direct handle references. Notes and Tags can be attached to any primary object and are omitted to keep arrows readable. Reverse traversals — "who refers to this object?" — go through `db.find_backlink_handles()` instead of these forward links; see Backlinks below.](_media/data-model.svg) + +Most addons don't visit objects in isolation — they follow the relationships between them. Gramps' object model exposes references as **handle lists** on the parent object. + +Person → families they're a parent in: + +```python +for family_handle in person.get_family_handle_list(): + family = db.get_family_from_handle(family_handle) + ... +``` + +Person → events: + +```python +for ref in person.get_event_ref_list(): + event = db.get_event_from_handle(ref.ref) + role = ref.get_role() + ... +``` + +`event_ref` carries more than the handle — also the role (Primary, Witness, etc.) and any private flag. Read the ref, then dereference if you need the event itself. + +Family → children: + +```python +for child_ref in family.get_child_ref_list(): + child = db.get_person_from_handle(child_ref.ref) + ... +``` + +The full handle-list / ref-list inventory per object class lives in the [Gramps API docs](https://gramps-project.org/wiki/index.php/Gramps_6.0_Developer_Reference); see also [06-api-reference](06-api-reference.md) for the addon-facing subset. + +## Backlinks: who refers to this object? + +The forward direction (person → events they participated in) lives on the object. The reverse direction (event → people who participated in it) lives on the database: + +```python +for (obj_type, obj_handle) in db.find_backlink_handles(event.handle): + if obj_type == "Person": + person = db.get_person_from_handle(obj_handle) + ... +``` + +`find_backlink_handles` returns `(class_name, handle)` tuples for every primary object that references the given handle. Use it for: + +- Finding all sources that cite a given place +- Finding all people present at a given event +- Detecting orphaned objects (no backlinks → unreferenced) + +Note that `obj_type` is the **class name as a string** (`"Person"`, `"Family"`, ...), not the Python class itself. + +## Filters + +For non-trivial selection (e.g. "all people born in Hamburg between 1850 and 1900"), use Gramps' filter framework rather than hand-rolling predicates: + +```python +from gramps.gen.filters import GenericFilterFactory + +GenericFilter = GenericFilterFactory("Person") +filt = GenericFilter() +filt.add_rule(SomeRule([arg1, arg2])) +handles = filt.apply(db, db.iter_person_handles()) +``` + +Filters compose, cache, and integrate with the GUI's filter sidebar — a report that defines its own filter gets it as a sidebar option for free. The rule catalogue lives under `gramps.gen.filters.rules`; the user-facing counterpart is documented in [Filters](https://gramps-project.org/wiki/index.php/Gramps_6.0_Wiki_Manual_-_Filters). + +## Mutating data + +Write addons (mainly tools) modify data through **transactions**. The pattern is always the same: + +```python +with DbTxn(_("Tool name: what it did"), db) as trans: + person = db.get_person_from_handle(handle) + person.set_privacy(True) + db.commit_person(person, trans) +``` + +Three things matter: + +1. **The transaction message is user-visible** in the Undo History. Make it descriptive and translated. +2. **Always `db.commit_(obj, trans)`** after mutating — the object is a copy; commit writes it back. +3. **Group related changes** in one transaction so the user can undo as a single step. + +Creating a new object follows the same shape: + +```python +from gramps.gen.lib import Person, Name + +with DbTxn(_("Add unknown spouse"), db) as trans: + person = Person() + name = Name() + name.set_surname(surname) + person.set_primary_name(name) + person.gramps_id = db.find_next_person_gramps_id() + db.add_person(person, trans) +``` + +`find_next__gramps_id()` allocates an unused ID; `add_()` inserts and assigns the handle. + +## Testing data access + +Two complementary approaches: + +- **Real-data tests** — load `example.gramps` (shipped with Gramps, canonical test fixture) and exercise your code against it. Best for catching real-world data quirks (cross-typed backlinks, ID normalisation, unusual character sets). See [07-testing](07-testing.md). +- **Mocked tests** — substitute the database with a stub that returns fixed objects. Best for tight unit-test loops that don't need a database on disk. + +The lesson, learned the hard way: mocked DB tests can pass while the real-DB code is broken, because the mock doesn't reproduce the cross-typed backlinks and ID quirks of a populated tree. Prefer example.gramps for anything that traverses the DB; reserve mocks for pure helpers. + +## Performance notes + +The database API is fast enough that most addons don't need to think about performance. When you do: + +- Iterating **handles** is cheaper than iterating **objects** — only dereference when you need the object's contents. +- `get_number_of_()` is O(1); `len(list(db.iter_()))` is O(n). +- Backlinks aren't free — they read an index but still scan it. Don't call `find_backlink_handles` in a tight inner loop. +- The 5.x → 6.0 SQLite backend is roughly comparable to BSDDB for reads, faster for writes. Avoid backend-specific assumptions; addons should work on either. + +## See also + +- [04-fundamentals](04-fundamentals.md) — the plugin lifecycle that wraps this DB access in +- [06-api-reference](06-api-reference.md) — the addon-facing API surface +- [07-testing](07-testing.md) — testing strategies, real-data vs mocks +- [Using database API](https://gramps-project.org/wiki/index.php/Using_database_API) — the standalone wiki reference, covers backends and internals in more depth +- [Gramps Developer Reference](https://gramps-project.org/wiki/index.php/Gramps_6.0_Developer_Reference) — the full API docs diff --git a/docs/addon-development/06-api-reference.md b/docs/addon-development/06-api-reference.md new file mode 100644 index 000000000..fec5d9b04 --- /dev/null +++ b/docs/addon-development/06-api-reference.md @@ -0,0 +1,209 @@ +# API Reference + +[← Previous](05-data-access.md) · [Index](01-overview.md) · [Next →](07-testing.md) + + + +## Overview + +The curated `gramps.gen.*` surface addons are allowed to import. `gen` is the self-contained core submodule (it must not import from `gui` or `plugins`); importing only from `gen` keeps an addon portable across UI variants and testable without a display. + +This page is a navigator, not a generated API dump. For exhaustive signatures, read the source of the module referenced — the [upstream Sphinx docs](https://gramps-project.org/docs/) carry the same information formatted for browsing. + +## Allowed surface + +### Database + +| Module / class | Notes | +|-------------------------------------------|--------------------------------------------------------------------| +| `gramps.gen.db.base.DbReadBase` | Read-only DB interface — what addon code typically receives | +| `gramps.gen.db.base.DbWriteBase` | Mutation interface; reach via `db` after `with DbTxn(...) as trans`| +| `gramps.gen.db.txn.DbTxn` | Transaction context manager — required for every write | +| `gramps.gen.db.utils.open_database` | Open a tree by path; used in repro scripts and tests | +| `gramps.gen.db.exceptions` | DB-layer exception hierarchy | + +See [05-data-access](05-data-access.md) for the addon-facing patterns that use this surface. + +### Object model + +| Module | Notes | +|-------------------------------------|------------------------------------------------------------------------| +| `gramps.gen.lib` | Every primary class: `Person`, `Family`, `Event`, `Place`, `Source`, `Citation`, `Repository`, `Media`, `Note`, `Tag`. Plus value classes (`Name`, `Date`, `Address`, `Surname`, `EventRef`, …). | +| `gramps.gen.lib.person.Person` | The gender constants (`Person.MALE`, `Person.FEMALE`, `Person.UNKNOWN`) are class attributes | + +The full inventory is large; the cheapest reference is the source under [`gramps/gen/lib/`](https://github.com/gramps-project/gramps/tree/maintenance/gramps60/gramps/gen/lib). Every primary class has matching `get_*` / `set_*` accessors; relationships are exposed as handle lists (`get_family_handle_list`) or ref lists (`get_event_ref_list`, `get_child_ref_list`). + +### Types and IDs + +| Module | Notes | +|-----------------------|---------------------------------------------------------------------------------------------| +| `gramps.gen.types` | `PersonHandle`, `FamilyHandle`, …, `PersonGrampsID`, `FamilyGrampsID`, … | + +Prefer these over bare `str` in addon code that handles either kind of identifier. It documents intent for the next reader and makes mistakes (handle vs ID) catchable with `mypy`. See [16-guidelines → Coding style](16-guidelines.md#coding-style). + +### Errors + +| Module | Use | +|-------------------------------------|----------------------------------------------------------------------| +| `gramps.gen.errors` | Raise existing exceptions here before inventing new classes | +| `gramps.gen.errors.HandleError` | Invalid or missing handles | +| `gramps.gen.db.exceptions` | DB-layer-specific exceptions | + +### Plugin base classes + +| Class / module | Used by | +|---------------------------------------------------------|------------------------------------| +| `gramps.gen.plug.Gramplet` | `GRAMPLET` addons | +| `gramps.gen.plug.report.Report` | `REPORT` addons | +| `gramps.gen.plug.report.MenuReportOptions` | Options form for report addons | +| `gramps.gen.plug.report.stdoptions` | Pre-built options like locale chooser | +| `gramps.gen.plug.docgen.BaseDoc` | `DOCGEN` addons (base) | +| `gramps.gen.plug.docgen.TextDoc` | Text reports | +| `gramps.gen.plug.docgen.DrawDoc` | Graphical (drawing) reports | +| `gramps.gen.plug.docgen.GVDoc` | Graphviz-based reports | +| `gramps.gen.plug.docgen.FontStyle`, `ParagraphStyle` | Style definitions for text reports | +| `gramps.gen.plug.docgen.PaperStyle`, `PaperSize` | Page geometry for graphical reports | +| `gramps.gen.plug.menu` | Options-form widgets | +| `gramps.gen.filters.rules.Rule` (and namespace bases) | `RULE` addons | +| `gramps.gen.simple.SimpleAccess`, `SimpleDoc` | Quick Views | + +Most `gramps.gui.*` classes are *internal*; addons that import from there will break across Gramps versions. The exceptions used in this manual's tutorials — `gramps.gui.plug.tool.Tool`, `gramps.gui.plug.quick.QuickTable`, `gramps.gui.dialog.OkDialog` — are documented because every existing Tool / Quick View in core uses them, but they are nevertheless GUI-coupled. Pure logic factored out into modules that import only from `gen` stays unit-testable without a display. + +### Report categories + +For `REPORT` addons, register with one of these category constants (see [`_pluginreg.py:141-149`](https://github.com/gramps-project/gramps/blob/maintenance/gramps60/gramps/gen/plug/_pluginreg.py)): + +| Category | Docgen interface | Notes | +|------------------------|------------------------|--------------------------------------------------------| +| `CATEGORY_TEXT` | `TextDoc` | Text reports — PDF, HTML, ODF, plain text | +| `CATEGORY_DRAW` | `DrawDoc` | Graphical reports drawn at exact coordinates | +| `CATEGORY_GRAPHVIZ` | `GVDoc` | Graphviz / DOT input — laid out by graphviz | +| `CATEGORY_WEB` | (direct file I/O) | Narrative website — writes HTML/CSS directly to files | +| `CATEGORY_BOOK` | `TextDoc` + `DrawDoc` | A composition of Text and Draw reports | +| `CATEGORY_TREE` | `DrawDoc` | Genealogical tree-chart layouts | +| `CATEGORY_CODE` | (none) | Catch-all for reports that don't fit elsewhere | + +Only `CATEGORY_TEXT` and `CATEGORY_DRAW` participate in `CATEGORY_BOOK`. + +### Document API: structure at a glance + +The three docgen interfaces have distinct hierarchies. Knowing which container nests what saves a long trip through the source. + +**`TextDoc` — sequential text layout, paginated by the backend.** + +``` +Document +├── Paragraph +├── Pagebreak +├── Table +│ └── Row +│ └── Cell +│ ├── Paragraph +│ └── Image +└── Image +``` + +Paragraph styles drive titles, body text, list entries. The backend or external viewer handles pagination, except where a manual `Pagebreak` is inserted. Index marks attach to text within a paragraph (`gramps.gen.plug.docgen.IndexMark`), feeding the table of contents in Book reports. + +**`DrawDoc` — exact-coordinate graphics on a frame.** + +``` +Document +└── Frame + ├── Line + ├── Polygon + ├── Box + └── Text +``` + +The frame is the drawing surface; elements get placed by coordinates supplied by the report. The origin is the top-left of the usable area (page minus margins). Graphical reports need to honour `PaperStyle.get_usable_width()` / `…_height()` — drawing into the margins is a contract violation. + +**`GVDoc` — graphviz model.** + +``` +Document +└── Subgraph + ├── Node + ├── Link + └── Comment +``` + +The report defines nodes, links, and comments; layout is the external graphviz binary's job. This is why `requires_exe=["dot"]` appears on Graphviz-based addons. + +### Paper geometry (Draw / Tree only) + +`gramps.gen.plug.docgen.PaperStyle` holds: + +- the paper size (a `PaperSize` instance), +- margins, +- orientation (portrait / landscape). + +Convenience accessors `get_usable_width()` and `get_usable_height()` return the drawing-area dimensions (paper size minus margins, in orientation order — width is always horizontal). Text reports don't need to read these; the backend paginates around them. + +### Locale and translation + +| Module / class | Use | +|-------------------------------------------------------------|------------------------------------------------------------| +| `gramps.gen.const.GRAMPS_LOCALE` (alias `glocale`) | The live locale; entry point for `_()` injection | +| `glocale.get_addon_translator(__file__).gettext` | Bind `_` to the addon's own `po/` catalog | +| `gramps.gen.utils.grampslocale.GrampsLocale` | Instantiate directly to pin a locale in repro scripts | +| `glocale.translation.ngettext` | Plural-aware translation | +| `glocale.translation.sgettext` | Strip translator-hint prefix; used with `"hint | msg"` form| + +See [04-fundamentals → Translation](04-fundamentals.md#translation) for the addon-side opt-in, and [08-debug → Reproduction scripts that bypass the GUI](08-debug.md#reproduction-scripts-that-bypass-the-gui) for the `GrampsLocale(localedir, languages)` pattern in repros. + +### Filters and selection + +| Module / class | Use | +|---------------------------------------------------------|------------------------------------------------| +| `gramps.gen.filters.GenericFilterFactory` | Construct a filter for a namespace | +| `gramps.gen.filters.rules` | The rule catalogue (one subpackage per namespace) | +| `gramps.gen.filters.rules..Rule` | Base class to subclass when writing a custom rule (see [02-tutorials](02-tutorials.md#a-custom-filter-rule)) | + +The modern rule entry point is `apply_to_one(db, obj)` (see [`_rule.py:162`](https://github.com/gramps-project/gramps/blob/maintenance/gramps60/gramps/gen/filters/rules/_rule.py#L162)). Older code used `apply()`. + +### Logging + +| Module / class | Use | +|---------------------------|----------------------------------------------------| +| `logging.getLogger(__name__)` | Module-level logger; see [04-fundamentals → Logging](04-fundamentals.md#logging) | + +There's nothing addon-specific to import here; addons use stdlib `logging` exactly like Gramps' own modules do. + +### Simple Access (Quick Views) + +| Class | Use | +|---------------------------------------------|----------------------------------------------------------------| +| `gramps.gen.simple.SimpleAccess` | High-level DB read interface — hides handles and refs | +| `gramps.gen.simple.SimpleDoc` | Matching write interface — `title`, `paragraph`, `header1`, … | +| `gramps.gui.plug.quick.QuickTable` | Clickable result table (GUI-coupled; QuickView-only) | + +See [02-tutorials → A Quick View](02-tutorials.md#a-quick-view) for the standard pattern. + +## What's NOT API + +Anything under `gramps.gui.*` or `gramps.plugins.*` is internal to the shipped distribution; addons that import from there break across Gramps versions. The exceptions (Tool / Quick View / Dialog) are documented above and unavoidable for those addon kinds, but pure logic should be factored out behind a `gen.*`-only boundary so it stays unit-testable without a display. + +If you find yourself reaching into `gramps.gui.*` or `gramps.plugins.*` for something that *isn't* tied to GUI display, the right move is usually to ask upstream to promote what you need into `gen`. The [committing policies wiki page](https://www.gramps-project.org/wiki/index.php/Committing_policies) and the gramps-devel mailing list are the channels. + +## See also + +- [03-addon-kinds](03-addon-kinds.md) — which kinds use which base classes. +- [04-fundamentals](04-fundamentals.md) — the cross-cutting concepts (logging, translation, signals) backed by this surface. +- [05-data-access](05-data-access.md) — patterns over the DB API. +- [14-compatibility](14-compatibility.md) — what changes across Gramps versions in this surface. +- [Report API](https://gramps-project.org/wiki/index.php/Report_API), [Report Generation](https://gramps-project.org/wiki/index.php/Report_Generation) — standalone wiki references for the docgen subsystem. +- [Simple Access API](https://gramps-project.org/wiki/index.php/Simple_Access_API) — the standalone wiki page for `SimpleAccess` / `SimpleDoc`. +- [Gramps Developer Reference](https://gramps-project.org/docs/) — upstream Sphinx-generated API docs. diff --git a/docs/addon-development/07-testing.md b/docs/addon-development/07-testing.md new file mode 100644 index 000000000..907eb69ef --- /dev/null +++ b/docs/addon-development/07-testing.md @@ -0,0 +1,302 @@ +# Testing + +[← Previous](06-api-reference.md) · [Index](01-overview.md) · [Next →](08-debug.md) + + + +## Overview + +How to test an addon without launching the GUI on every iteration — the test framework, the layout conventions, the fixtures that work, and the platform-aware rules that keep tests portable across Linux, Windows, and Mac. + +A working test suite is what makes an addon **maintainable across Gramps releases**. The matrix of (Gramps version × OS) makes manual testing impossible at scale; the per-OS prefix conventions below let a single CI matrix verify your addon against every supported combination automatically. + +## Framework: stdlib `unittest` + +Use stdlib `unittest`. Don't use pytest. + +Gramps itself standardises on `unittest` (subclasses of `unittest.TestCase`), which keeps addon tests contributable upstream without a framework-conversion step. Mixing pytest features (fixtures, parametrise, plugins) breaks contribution upstream where pytest isn't installed. + +```python +import unittest + + +class MyAddonTests(unittest.TestCase): + def test_handles_empty_input(self): + # ... + self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main() +``` + +### Class header convention + +The "class header navigation comment" rule from gramps' AGENTS.md is unconditional — it applies to `unittest.TestCase` subclasses too. PR 2326 round 2 caught the omission: + +```python +# ------------------------------------------------------------ +# +# MyAddonTests +# +# ------------------------------------------------------------ +class MyAddonTests(unittest.TestCase): + ... +``` + +## Layout + +Each addon ships its tests in a `tests/` subpackage: + +``` +MyAddon/ +├── MyAddon.gpr.py +├── MyAddon.py +└── tests/ + ├── __init__.py # marker — see below + └── test_myaddon.py +``` + +### Why `tests/__init__.py` exists + +The marker is **hygiene, not a bug fix**. Python 3.3+'s implicit namespace packages (PEP 420) mean a directory without `__init__.py` is still importable; dotted-path loading (`python3 -m unittest MyAddon.tests.test_myaddon`) works either way. But: + +1. **Explicit beats implicit.** "It works" is currently true by accident of invocation. The same code breaks the moment something uses `discover` or assumes regular packages. +2. **Explicit — and empty.** Suite-wide test setup (the GI version pins, warning filters) lives at the *repository* root's `tests/__init__.py`, not per addon — see the next section. The per-addon marker stays empty; it is packaging hygiene, and a home for genuinely addon-local setup only if one ever appears. + +The convention crystallises as: every addon's `tests/` **should** have an `__init__.py`; the addon directory itself **should not**. + +The asymmetry matters. The addon directory must remain a plain namespace dir — Gramps' plugin loader puts the addon dir on `sys.path` and imports `.py` by name. Making the addon dir a regular package can disturb plugin loading (and the [Mantis 12691](https://gramps-project.org/bugs/view.php?id=12691) namespace trap lives in exactly this area). The `tests/` subfolder has no such constraint, so making it an explicit package is free. + +This is what [addons-source PR 930](https://github.com/gramps-project/addons-source/pull/930) (Gary Griffin) is moving toward. + +## The GTK-pin contract + +Gramps establishes the GObject-introspection environment **once, at startup, before any plugin loads**: `gi.require_version("Gtk", "3.0")` and `gi.require_version("Gdk", "3.0")` run in `gramps/grampsapp.py` and `gramps/gen/constfunc.py`. Every addon module therefore does `from gi.repository import Gtk` into an already-pinned namespace — inside Gramps, the pin is never the addon's job. + +**The trap.** Run that module under bare `unittest` and the import warns (or resolves a different GTK) because nothing has pinned yet. The tempting fix is to copy the `gi.require_version` call into the addon module or the test file. Tests now pass — but the pin also executes inside Gramps, where it is redundant at best and a hard failure the moment the hardcoded pin and the version Gramps runs diverge: `gi.require_version` raises `ValueError` once the namespace is already loaded at a different version. That is the *works-in-tests, breaks-in-Gramps* failure mode, and it is invisible to CI because CI only runs the tests. + +**The contract**, in two halves: + +1. **Modules never pin.** No `gi.require_version` in the addon module or in any test file — the environment is provided *to* them, in both contexts. Redundant pins are safe to remove from files you are already touching (the Themes addon's `tests/__init__.py` cleanup is the precedent), but don't churn files you aren't otherwise changing. +2. **The repository root provides what Gramps provides.** addons-source carries the pins **once**, in the repo-root `tests/__init__.py` (addons-source PR [950](https://github.com/gramps-project/addons-source/pull/950)): the repo-root suite run and the CI runners import that package before any test module, pinning the whole suite to the GTK 3 / GDK 3 stack a real Gramps session uses (it also silences the locale warnings that uncompiled source-tree addons legitimately emit). The per-addon `MyAddon/tests/__init__.py` stays **empty** — see the previous section. + +The one thing a GUI-touching test module may still need is a presence guard for hosts with no PyGObject at all: + +```python +try: + import gi +except ImportError as err: + raise unittest.SkipTest("PyGObject not available: %s" % err) +``` + +**The corollary: run from the repository root.** The pins execute when the root `tests` package loads — the repo-root suite run and CI's per-addon runners do that. Run your own invocations from the addons-source root too (the dotted-path form below), and never run a test file by filesystem path (`python3 MyAddon/tests/test_myaddon.py`) — the shortcut that pushes pins back into the modules, and that bypasses the namespace-package semantics the loading section below relies on. + +The GI pins are one instance of a wider rule: everything process-global that Gramps' startup owns — locale, the root logger, `sys.path`, the GTK main loop, `sys.excepthook`, environment variables — follows the same contract. The full startup surface, with the per-item temptations and alternatives, is tabulated in [04-fundamentals → The provided environment](04-fundamentals.md#the-provided-environment). + +## Filename conventions (addons-source CI) + +addons-source's CI workflow filters tests by **filename prefix** to scope them per platform: + +| Prefix | Where it runs | +|-------------------------|------------------------------------------------| +| `test_*.py` | All platforms (Linux + Windows) | +| `test_linux_*.py` | Linux only | +| `test_windows_*.py` | Windows only | +| `test_integration_*.py` | Linux only — full-pipeline / DB-backed | + +The Ubuntu runner skips `test_windows_*`; the Windows runner skips both `test_linux_*` and `test_integration_*`. Both runners include the platform-neutral `test_*.py` files. + +**Pick the prefix that matches the test's portability**, not the platform you happen to be developing on. A test that exercises POSIX file paths goes under `test_linux_*`; a test that exercises win32 locale handling goes under `test_windows_*`; everything else, the plain `test_*.py` prefix. + +CI's workflow file is authoritative: [addons-source/.github/workflows/ci.yml](https://github.com/gramps-project/addons-source/blob/maintenance/gramps60/.github/workflows/ci.yml). + +## Loading: dotted path, not `discover` + +Upstream CI loads tests by **dotted path**: + +```bash +python3 -m unittest MyAddon.tests.test_myaddon +``` + +Not by `discover` from inside an addon's `tests/` directory, and never by filesystem path. Dotted-path loading from the repo root surfaces the namespace-package trap. Bug 12691 — `from import ` binding the submodule instead of the class — only shows up under dotted-path loading. `discover`-based loading walks files by *filename*, hiding the import-resolution issue. Mirroring CI's invocation locally catches what CI catches. + +Locally, from the `addons-source` root, the same invocation works: + +```bash +# Run one test module +python3 -m unittest MyAddon.tests.test_myaddon + +# Run every test in the addon's tests/ package +python3 -m unittest discover -s MyAddon/tests -t . +``` + +The discover form here works because the addon directory is the import root — the namespace-package trap shows up only when an *individual addon module* mis-imports itself. + +## Mocked vs `example.gramps`-backed tests + +Two complementary strategies. They're not alternatives. + +### Mocked unit tests + +Fast, no DB on disk, suitable for tight branch-coverage of pure logic. Substitute the database with a stub that returns fixed objects: + +```python +import unittest +from unittest.mock import MagicMock + + +class HappyPathTests(unittest.TestCase): + def test_skips_people_without_birth(self): + person = MagicMock() + person.get_birth_ref.return_value = None + + result = pure_logic(person) + + self.assertEqual(result, expected) +``` + +The MagicMock approach has a built-in failure mode: it returns something for *every* method call, so a typo'd method name appears to work. Real DB code that fails on the next call will pass the mocked test. This is the bug the next strategy catches. + +### `example.gramps`-backed tests + +`example.gramps` ships with the Gramps source under `example/gramps/example.gramps`. It's the canonical fixture triage and developers reproduce against; loading it produces a real populated database with the cross-typed backlinks, ID normalisations, and absent optional fields that real users hit. + +```python +import os +import unittest +from gramps.gen.db.utils import open_database + + +class IntegrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.db = open_database( + os.path.expanduser("~/path/to/gramps/example/gramps/example.gramps") + ) + + def test_handles_real_data(self): + result = code_under_test(self.db) + self.assertGreater(len(result), 0) +``` + +Name these `test_integration_*.py` so CI scopes them to Linux only (loading a real DB is heavier, and Windows CI's Gramps setup is separately constrained — see [14-compatibility → Windows toolchain migrated to UCRT64](14-compatibility.md#windows-toolchain-migrated-to-ucrt64)). + +### Choosing between them + +| Use the mock when | Use `example.gramps` when | +|---------------------------------------------------|-----------------------------------------------------------| +| The function under test takes pure inputs | The function traverses the DB | +| You're covering many input shapes (loop / branch) | You're verifying *one* real-world scenario | +| You need sub-millisecond turnaround | You need real-data shape (backlinks, IDs, optional refs) | + +The lesson, learned the hard way: mocked tests can pass while real-DB tests fail, because the mock doesn't model what production data looks like. + +## Tests must run without `requires_mod` deps + +A hard constraint, set by Gary Griffin (2026-05-16): addon tests must run cleanly without the addon's `requires_mod` dependencies installed in the Python that runs them. Mac contributors can't easily install addon deps into the Gramps Python on macOS, and there's no Gramps debug-mode equivalent on Mac to work around it. + +Two ways to honour this: + +### Mock at the import boundary + +```python +import sys +from unittest.mock import MagicMock + +# Stand in for an optional dep before importing the addon. +sys.modules.setdefault("PIL", MagicMock()) +sys.modules.setdefault("PIL.Image", MagicMock()) + +from MyAddon.MyAddon import code_under_test +``` + +Cleaner than try/except, and the test asserts the addon's behaviour **with the dep present** — what almost every real user sees. + +### Skip cleanly + +When mocking is impractical (e.g. the dep is core to the function under test), skip without erroring: + +```python +import unittest +from importlib.util import find_spec + + +@unittest.skipUnless(find_spec("PIL"), "Pillow not installed") +class PhotoTaggingTests(unittest.TestCase): + def test_loads_jpeg(self): + ... +``` + +A failed import at module load — instead of a `skipUnless` — turns into a test error on the Mac runner, blocking the CI suite. + +## What to test + +Mandatory: + +- **The bug a fix closes.** Every bug fix ships with a test that fails pre-fix and passes post-fix. At PR level this is a [16-guidelines MUST](16-guidelines.md#contributor-workflow): the regression test, or an explicit "no test because X" rationale plus a manual repro — "add the test later" is not an option. Doc-only PRs are the only exception. + +Strongly recommended: + +- **One happy-path call** through the addon's main entry point. The smoke test that catches the next breakage. +- **One real-data scenario** against `example.gramps` for any DB-traversal code. + +Optional but valuable: + +- **Edge cases** the function explicitly handles: empty DB, missing optional fields, IDs at the boundaries of normalisation. + +What *not* to test: + +- The Gramps API itself. If `db.get_person_from_handle(h)` returns `None` for a missing handle, that's Gramps' contract; your test exercises that **your code handles `None`**, not that Gramps returns it. + +## What the test catches that the GUI doesn't + +A test surfaces failure modes the GUI cycle hides: + +- **The namespace-package trap** (bug 12691) — surfaces under dotted-path loading. +- **`requires_mod` typos** — `from import …` would fail import; surfaces immediately at test load. +- **DB-shape assumptions** — the cross-typed-backlinks / ID-norm issues that mocked tests miss. +- **Per-OS regressions** — running on both runners. + +See [09-troubleshoot](09-troubleshoot.md) for the symptoms-to-cause mapping for these classes of failure. + +## Running tests locally + +From the `addons-source` checkout root: + +```bash +# Run one addon's tests +python3 -m unittest discover -s MyAddon/tests -t . + +# Or invoke a single test module by dotted path (mirrors CI's invocation) +python3 -m unittest MyAddon.tests.test_myaddon +``` + +Run from the addons-source root, and never invoke a test file by filesystem path — see [the GTK-pin contract](#the-gtk-pin-contract). + +The Python that runs the tests needs `gramps` importable. The simplest setup is `PYTHONPATH=/path/to/gramps python3 -m unittest …`; if Gramps is installed system-wide, the import resolves without `PYTHONPATH`. + +On Windows, run from the MSYS2 UCRT64 shell against a UCRT64-installed Gramps — the AIO build for Gramps 6.1+ targets UCRT64; Gramps 6.0 isn't Windows-tested upstream. See [14-compatibility → Windows toolchain migrated to UCRT64](14-compatibility.md#windows-toolchain-migrated-to-ucrt64). + +## See also + +- [04-fundamentals → Logging](04-fundamentals.md#logging) — `LOG` setup that tests assert against. +- [05-data-access → Testing data access](05-data-access.md#testing-data-access) — DB-API patterns to exercise. +- [08-debug](08-debug.md) — turning a repro script into a test. +- [09-troubleshoot](09-troubleshoot.md) — the symptoms these tests catch in CI rather than production. +- [10-code-analysis](10-code-analysis.md) — what the static checkers verify before tests run. +- [16-guidelines → Testing](16-guidelines.md#testing) — normative rules. +- [Mantis 12691](https://gramps-project.org/bugs/view.php?id=12691) — the canonical namespace-package trap that motivates dotted-path loading. +- [addons-source PR 930](https://github.com/gramps-project/addons-source/pull/930) — `tests/__init__.py` convention. diff --git a/docs/addon-development/08-debug.md b/docs/addon-development/08-debug.md new file mode 100644 index 000000000..b12f46970 --- /dev/null +++ b/docs/addon-development/08-debug.md @@ -0,0 +1,184 @@ +# Debug + +[← Previous](07-testing.md) · [Index](01-overview.md) · [Next →](09-troubleshoot.md) + + + +## Overview + +How to see what an addon is actually doing — where it logs, how to enable verbose output, and the patterns for reproducing a problem without sitting through a full Gramps launch cycle each time. + +Most addon bugs are reachable through three escalating tools, in order: read the log window, enable per-logger debug output, or write a tight repro script that bypasses the GUI entirely. The heavier tools (pdb, gdb) are documented at the bottom for the cases where the lighter ones don't suffice. + +## Where addon output goes + +Two surfaces, both populated by the same logging calls: + +| Surface | When you see it | +|------------------------|------------------------------------------------------------------------------------------------| +| **Gramps log window** | Help → Log. Always populated. Visible to the user. | +| **stderr / terminal** | Whatever shell launched Gramps. Populated only when Gramps is run from a terminal. | + +The logging module that backs both is the stdlib `logging`; an addon's module-level logger feeds in like any other: + +```python +import logging +LOG = logging.getLogger(__name__) + +LOG.debug("Computed candidate set: %s", candidates) +LOG.info("Processed %d people", n) +LOG.warning("Skipping malformed event %s", event.gramps_id) +LOG.error("Could not parse %s", filename) +``` + +`__name__` for an addon resolves to the addon's `id` (e.g. `"MyAddon.myaddon"`), so the logger inherits the addon's name naturally — useful for per-logger filtering below. + +**Don't use `print()`.** It bypasses both surfaces and breaks under windowed launches that have no terminal attached. The [16-guidelines](16-guidelines.md#runtime) page makes this a hard rule. + +## Default log levels + +Gramps configures the root logger at `WARNING` by default; `DEBUG` and `INFO` are filtered. Two ways to lower the bar: + +### `--debug=` + +Launch Gramps with the `--debug` flag to enable `DEBUG` for one named logger: + +```bash +gramps --debug=MyAddon +gramps --debug=MyAddon.myaddon # narrower +gramps --debug=gramps.gen.db # for DB internals +``` + +Pass it more than once to enable several loggers. The flag is strictly opt-in per logger — that's why you set it on the launch command, not in code. Other loggers stay quiet, so you're not swimming in noise. + +### Module-level override (development only) + +When iterating tightly, drop a one-line override at the top of the implementation module: + +```python +import logging +logging.getLogger(__name__).setLevel(logging.DEBUG) +``` + +Remove before committing — published addons should rely on `--debug=…` so users aren't forced into verbose output. + +## Reproduction scripts that bypass the GUI + +Restarting Gramps to test a one-line change burns minutes. For anything that *can* be tested without the GUI, write a tight repro script that instantiates the addon's testable pieces directly. + +The pattern looks like this: + +```python +# repro_.py — run with `python3 repro_.py`. +import os, sys +sys.path.insert(0, os.path.expanduser("~/path/to/gramps")) + +from gramps.gen.const import GRAMPS_LOCALE +from gramps.gen.utils.grampslocale import GrampsLocale +from gramps.gen.db.utils import open_database + +# Pin the locale without touching system locale config. +glocale = GrampsLocale( + localedir=os.path.expanduser("~/path/to/gramps/po"), + languages=["fi"], # the language under test +) + +db = open_database("example.gramps") +# … exercise the buggy code path … +``` + +`GrampsLocale(localedir, languages)` is the key escape hatch — it bypasses both `LANGUAGE` env-var setup and `locale-gen`-style OS config, neither of which is needed for an in-process test. The pattern came out of triaging [Mantis 14100](https://gramps-project.org/bugs/view.php?id=14100) (Finnish month-inflection crash). + +For DB-traversal code, the canonical fixture is `example.gramps` shipped with Gramps source. Real-data tests against `example.gramps` catch bugs mocked DBs miss — see [05-data-access → Testing data access](05-data-access.md#testing-data-access). + +The same pattern, formalised as a `unittest.TestCase`, becomes a regression test. See [07-testing](07-testing.md). + +## In-app diagnostics: `PrerequisitesCheckerGramplet` + +When a user reports an addon misbehaving, the first triage step is *"what's the running environment?"* The PrerequisitesCheckerGramplet (an addon itself) lists every optional dependency Gramps detects and the version it found. Asking a reporter to install it, run it, and paste the output is the fastest baseline. + +It also surfaces missing GI bindings, which addons typically declare via `requires_gi` — a `requires_gi=[("GExiv2", "0.10")]` that fails silently is almost always something the PrerequisitesCheckerGramplet output would have surfaced. + +[Mantis 13966](https://gramps-project.org/bugs/view.php?id=13966) (active_page None on tree close) was a teardown-order bug *in* this gramplet; the fix lives in addons-source PR 913. + +## Platform notes + +**Linux** — the standard environment. `gramps --debug=…` works as documented; logging surfaces in the terminal that launched Gramps, plus the in-app log window. + +**Windows** — debug flags work the same way, but the launcher is typically a `.bat` or `.exe` shortcut rather than a terminal command. Launch from MSYS2 UCRT64 (`/ucrt64/bin/gramps`) to get a terminal attached for stderr. Some bugs reproduce only on Windows; when reporting one, include the Gramps version, the MSYS2 UCRT64 toolchain version, and the exact reproduction steps in the Mantis ticket so a Windows-equipped maintainer can confirm. + +**macOS** — there is **no Gramps debug mode equivalent on Mac**, and contributors typically can't install addon dependencies into the Gramps Python (Gary Griffin, 2026-05-16). This shapes two testing-side decisions: tests must run cleanly without `requires_mod` deps installed (see [07-testing](07-testing.md)), and a Mac repro that needs GUI inspection usually requires triaging via screenshots the reporter pastes into the Mantis ticket. + +## Heavier tools + +When the lighter approaches aren't enough. + +### `pdb` (Python debugger) + +Drop a breakpoint at the line of interest: + +```python +breakpoint() # Python 3.7+; same as `import pdb; pdb.set_trace()` +``` + +Launch Gramps from a terminal; when execution reaches the breakpoint, Gramps freezes and you get an interactive `(Pdb)` prompt. Commands: `n` (next line), `s` (step into), `c` (continue), `l` (list source), `p expr` (print). Full reference: [Python pdb docs](https://docs.python.org/3/library/pdb.html). + +### `python -m trace` + +For "where on earth does the crash come from?": + +```bash +python3 -m trace -t /path/to/Gramps.py >/tmp/trace.out +``` + +Produces every executed line of Python in the file. Huge, but `grep` of the last hundred lines often pins the crash site. + +### `gdb` (C debugger) + +For segfaults coming from C libraries (GTK, GObject Introspection): + +```bash +gdb python3 +(gdb) run /path/to/Gramps.py +# … reproduce the crash … +(gdb) bt # Python+C backtrace; the C frames pin the C-side cause +``` + +To trap GTK warnings as hard errors: + +```bash +G_DEBUG=fatal-warnings gdb python3 +``` + +Then `r /path/to/Gramps.py`; any GTK warning aborts with a backtrace showing the originating call. + +Addons rarely need `gdb` — segfaults that bubble up from C are usually GI binding issues (wrong typelib version, missing `gir1.2-*` package). When you do hit one, the C backtrace plus the user's `PrerequisitesCheckerGramplet` output typically point at the missing package. + +### Profiling + +`gramps.gen.utils.debug.profile` is a convenience wrapper around `cProfile`. Replace the call you want to profile: + +```python +from gramps.gen.utils.debug import profile + +def cb_save(self, *obj): + profile(self.save, *obj) +``` + +On the next save, a profile report goes to stdout: per-function call counts and cumulative time. Useful when a gramplet's `main()` is unexpectedly slow. + +## See also + +- [04-fundamentals → Logging](04-fundamentals.md#logging) — the conventions for setting up the logger in the first place. +- [07-testing](07-testing.md) — formalising a repro script into a regression test. +- [09-troubleshoot](09-troubleshoot.md) — symptom-first guide to the failure modes these tools surface. +- [16-guidelines](16-guidelines.md) — the rules around logging and diagnostics (logger over `print`, etc.). +- [Debugging Gramps](https://gramps-project.org/wiki/index.php/Debugging_Gramps) — the standalone wiki page; primary scraped source. +- [Logging system](https://gramps-project.org/wiki/index.php/Logging_system) — the deeper reference for Gramps' logging configuration. diff --git a/docs/addon-development/09-troubleshoot.md b/docs/addon-development/09-troubleshoot.md new file mode 100644 index 000000000..19bb7d9a0 --- /dev/null +++ b/docs/addon-development/09-troubleshoot.md @@ -0,0 +1,213 @@ +# Troubleshoot + +[← Previous](08-debug.md) · [Index](01-overview.md) · [Next →](10-code-analysis.md) + + + +## Overview + +The failure modes that bite first-time addon authors, organised by symptom. Each entry is "what you see → why → what to do." Read this sideways: jump to the symptom that matches what you're seeing, follow the link out to the relevant chapter for the fix in depth. + +For technique-level coverage (pdb, gdb, profilers), see [08-debug](08-debug.md). For the normative rules an addon must satisfy, see [16-guidelines](16-guidelines.md). + +## Loading and discovery + +### "My addon doesn't appear in any menu." + +The addon failed to register. Three usual causes, in order of likelihood: + +1. **`.gpr.py` raised at import.** Plugin discovery executes every `.gpr.py` at startup; a `SyntaxError` or import failure there silently drops the addon from the catalog. Launch from a terminal to see the traceback on stderr, or check the Gramps log window (Help → Log) for the failure entry. + +2. **`gramps_target_version` mismatch.** A `6.0` addon won't load in 6.1, and vice versa. Plugin discovery silently skips the registration entry. See [14-compatibility → `gramps_target_version` semantics](14-compatibility.md#gramps_target_version-semantics). + +3. **`id` doesn't match the folder name.** The addon's folder name and the `id` argument to `register(...)` must be identical. Gramps does not match by content — it matches by folder name and verifies against `id`. A mismatch silently drops the entry. + +The fastest check: in a Python REPL with `gramps` on `sys.path`, `exec(open("MyAddon/MyAddon.gpr.py").read())`. If it raises, you have your cause; if it returns silently and there's no entry, your `register()` call is being filtered out. + +### "My edits to the plugin file disappeared on restart." + +The user plugin directory (`~/.local/share/gramps/gramps60/plugins/…`) is the auto-sync **target**. Edits there are silently overwritten on the next save from `addons-source/`. + +**The fix.** Edit in `addons-source//` and let the sync flow do its job — see [12-packaging → Editing `addons-source/`, not the live plugin directory](12-packaging.md#editing-addons-source-not-the-live-plugin-directory). + +On Gramps 6.1+ Linux/macOS, symlinking the working tree into the user plugin directory once eliminates the copy step (commit `9443dcbb30`); on Gramps 6.0 and on Windows generally, the copy / `rsync` loop remains. + +### "The addon's folder is there but Gramps doesn't load it." + +The most common variants of the previous symptom, when ruled out: + +- **6.0 only**: the folder is reached via a symlink. Gramps 6.0 plugin discovery does **not** follow symlinks; use a physical copy or upgrade to 6.1+. (See [14-compatibility → Plugin discovery follows symlinks](14-compatibility.md#plugin-discovery-follows-symlinks).) +- **Windows, any version**: same as above — the 6.1 symlink test is skipped on Windows because the platform's symlink behaviour is inconsistent without elevated privileges. Physical copy. +- **`.gpr.py` not at top level of folder**: the registration file has to be `/.gpr.py`, not in a subfolder. + +## Imports and Python namespace traps + +### "`from import ` binds the submodule, not the class." + +The classic Gramps namespace-package trap. + +The addon folder is a *namespace package* (PEP 420), so importing `` gives you the package, not the class inside the like-named module. Code that worked under `discover`-based test loading breaks under dotted-path loading because the resolution path is different. + +**The fix.** Use the explicit submodule form: + +```python +# Wrong: binds the package (silently — until you try to use the class) +from MyAddon import MyAddon + +# Right: binds the class inside the module +from MyAddon.MyAddon import MyAddon +``` + +Mantis bug [12691](https://gramps-project.org/bugs/view.php?id=12691) is the canonical case. Upstream CI loads addon tests by dotted path rather than `discover` exactly to surface this trap; see [07-testing](07-testing.md). + +### "`requires_mod` declares `Pillow` but Gramps says it's missing." + +`requires_mod` takes the **importable** module name, not the PyPI distribution name: + +| PyPI name | Importable name | +|----------------|-----------------| +| `Pillow` | `PIL` | +| `PyYAML` | `yaml` | +| `lxml` | `lxml` | +| `python-dateutil` | `dateutil` | +| `Beautifulsoup4` | `bs4` | + +**The check.** Before pushing, verify on a system with the package installed: + +```python +from importlib.util import find_spec +assert find_spec("PIL") is not None +``` + +If `find_spec` returns `None`, the name in `requires_mod` is wrong. + +### "`requires_gi` declaration is fine on 6.0, broken on 6.1." + +GExiv2's version handling was rewritten on `maintenance/gramps61` only (addons-source PR [829](https://github.com/gramps-project/addons-source/pull/829)). An addon with `requires_gi=[("GExiv2", "0.10")]` that works on 6.0 may need a different pin on 6.1. + +**The fix.** Read the EditExifMetadata addon's GExiv2 code on the target branch before assuming a pin transfers. See [14-compatibility → GExiv2 version handling rewritten](14-compatibility.md#gexiv2-version-handling-rewritten). + +## Database access + +### "My addon iterates the DB but raises `KeyError` halfway through." + +The DB contains a reference to a handle that no longer resolves. This happens in real-world data; mocked tests don't exhibit it because mocks always return the same fixed set. + +**The fix.** Always guard handle dereferences: + +```python +event = db.get_event_from_handle(handle) +if event is None: + continue # silently skip dangling reference +``` + +See [05-data-access → Reading: one object at a time](05-data-access.md#reading-one-object-at-a-time) for the pattern. The same shape applies to every `get__from_handle` call. + +### "Backlinks return `(class_name, handle)` not `(class, handle)`." + +`db.find_backlink_handles(handle)` yields tuples whose first element is the **class name as a string** (`"Person"`, `"Family"`, …), not the Python class itself. The most common bug here is `isinstance` checks that never match. + +```python +# Wrong: +for cls, h in db.find_backlink_handles(handle): + if cls is Person: # always False — cls is "Person" + ... + +# Right: +for type_name, h in db.find_backlink_handles(handle): + if type_name == "Person": + ... +``` + +### "The fix worked in my mocked test but breaks on `example.gramps`." + +Real data has shapes the mock doesn't model: + +- **Cross-typed backlinks** — a Source can be backlinked from a Person, a Family, an Event, a Place, a Media, a Note, a Citation, and a Repository. Mocks tend to model only the type the test author was focused on. +- **ID normalisation** — `I0001` vs `I0021` vs `I12345`. A regex that matches the mock's 4-digit IDs misses the real data's variable-width IDs. +- **Optional fields actually being absent** — `person.get_birth_ref()` returns `None` in real data far more often than in mocks. + +**The fix.** Add an `example.gramps`-backed test alongside the mock. See [05-data-access → Testing data access](05-data-access.md#testing-data-access) and [07-testing](07-testing.md). + +## Translation and locale + +### "My addon translates fine on Linux, not on Windows" (or vice versa). + +The two platforms set up the locale differently: + +- **Linux** — needs `locale-gen` for the language and the `LANGUAGE` env var set (not just `LANG`). +- **Windows** — reads `LANG` directly via `win32locale.py`, no OS locale config needed. + +**The fix in repro scripts.** Sidestep both by instantiating `GrampsLocale(localedir, languages)` directly — see [08-debug → Reproduction scripts that bypass the GUI](08-debug.md#reproduction-scripts-that-bypass-the-gui). + +**The fix in production.** Make sure the per-addon `.po` files compile cleanly on both platforms (`make.py compile ` in addons-source). A `.mo` file that's missing or malformed will silently fall back to English on whichever platform fails to load it. + +### "Strings I marked with `_()` aren't translated." + +You've forgotten to bind `_` to the addon's catalog. At the top of the implementation module: + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale + +_ = glocale.get_addon_translator(__file__).gettext +``` + +Without that line, `_()` falls back to Gramps' core catalog rather than the addon's own `/po/` translations. The strings stay English regardless of UI language. See [04-fundamentals → Translation](04-fundamentals.md#translation). + +## Testing + +### "Tests pass locally, fail in CI." + +Three common causes: + +1. **Filename prefix wrong for the platform.** `test_linux_*.py` is skipped on the Windows runner, `test_windows_*.py` is skipped on Linux. A test you intended as cross-platform but accidentally named with a prefix runs only where the prefix points. See [07-testing → Filename conventions](07-testing.md#filename-conventions-addons-source-ci). + +2. **`requires_mod` deps assumed in tests.** Addon tests must be runnable without the addon's `requires_mod` dependencies installed in the test Python — Mac contributors can't easily install addon deps into the Gramps Python (Gary Griffin, 2026-05-16). Mock at the import boundary or skip cleanly. + +3. **Test loaded by dotted path surfaces the namespace trap.** Local `discover` from `tests/` would hide the `from import ` bug; CI loads by dotted path (`.tests.`), which exposes it. See bug 12691. + +### "PR's pre-commit passed but CI is red." + +Pre-commit catches static checks only. Test failures (e.g. an import that breaks at module load) surface in CI's actual unit-test run, not in pre-commit. After pushing, watch the PR's checks until they finish: + +```bash +gh pr checks --watch +``` + +See [16-guidelines → Verification before commit](16-guidelines.md#verification-before-commit). + +## Pull-request shape + +### "`.gpr.py` version bump rejected on PR." + +addons-source PRs do **not** bump the addon's `version` field. The maintainer manages versions centrally. Leave the `version = "…"` line in `.gpr.py` untouched. (Tripped on addons-source PR 911.) + +### "PR sits without review." + +Two things to check: + +1. **Branch target.** `addons-source` PRs target `maintenance/gramps60`, not `master`. Gary cherry-picks forward to `gramps61`. Core PRs target `maintenance/gramps61`. A PR against the wrong branch may sit untouched waiting for retargeting. See [16-guidelines → Contributor workflow](16-guidelines.md#contributor-workflow). + +2. **PR body shape.** Reviewers expect a **`**User impact:**`** opener (before Root cause), then *Summary / What to look at / Root cause / Fix / Verification* (the #106 format). A PR body that leads with internals instead of the user-visible effect often returns without a substantive review until it conforms. + +### "PR was rejected as duplicate." + +You wrote a fix without checking that upstream already had one in flight. The pre-flight check is [16-guidelines → Contributor workflow](16-guidelines.md#contributor-workflow) — the bullets about "check upstream isn't ahead" and "if a PR already exists, VERIFY it, do not duplicate." Searching by **affected file path**, not just the bug number, is the part that catches the most duplicates. + +## See also + +- [08-debug](08-debug.md) — technique-level coverage for reproducing what these symptoms describe. +- [07-testing](07-testing.md) — the test conventions that catch many of these symptoms before they reach a user. +- [12-packaging](12-packaging.md) — the source-to-distribution flow, where the "edits disappeared" trap lives. +- [14-compatibility](14-compatibility.md) — the 6.0 vs 6.1 deltas behind several entries here. +- [16-guidelines](16-guidelines.md) — normative reference; this chapter describes what *goes wrong*, that chapter describes what *must hold*. +- [Mantis bug tracker](https://gramps-project.org/bugs) — where the recurring failures get filed. diff --git a/docs/addon-development/10-code-analysis.md b/docs/addon-development/10-code-analysis.md new file mode 100644 index 000000000..1ebb28ea4 --- /dev/null +++ b/docs/addon-development/10-code-analysis.md @@ -0,0 +1,235 @@ +# Code Analysis + +[← Previous](09-troubleshoot.md) · [Index](01-overview.md) · [Next →](11-internationalization.md) + + + +## Overview + +What automated checks run against addon code, locally and in CI, and how to keep an addon passing them. The goal is "PR opens green" — every check below catches a class of issue cheaper than a maintainer review round. + +The checks vary by repo. Two combinations matter; the cheat sheet: + +| Check | gramps core | addons-source | +|----------------------------------------|------------------------|-------------------| +| Black formatting (`--check --diff`) | pre-commit + CI | — | +| `mypy` static types | pre-commit + CI | — | +| `ruff` E9 / F63 / F7 / F82 | — | pre-commit + CI | +| `python -m py_compile` / `ast.parse` | — | pre-commit + CI | +| `msgfmt` on `po/*.po` | upstream build | upstream build | +| `pylint` ≥ 9 on new files | manual; not gated | not enforced | + +`addons-source` does **not** enforce Black today; gramps core does. This is the most common surprise for authors moving between the two. See [Black](#black) below. + +## Pre-commit + +There is no published upstream `.pre-commit-config.yaml` for `addons-source`; addon authors can install their own locally to mirror the CI gates above (ruff, py_compile), but this is convenience tooling, not an upstream requirement. The authoritative checks live in `addons-source/.github/workflows/ci.yml`; if your local pre-commit and CI disagree, CI wins. + +For gramps core, the upstream pre-commit config under the `gramps/` repo covers Black and `mypy`; install with the standard `pre-commit install` flow from the repo root. + +## Black + +[Black](https://black.readthedocs.io/) is an opinionated Python formatter. Where enforced, the CI lint job is `psf/black@stable --check --diff`; a violation fails the build and blocks merging. + +**Where enforced.** gramps core (`maintenance/gramps61` and `master`) — both pre-commit and CI. addons-source does *not* enforce Black today; PRs there go through without formatting checks. + +**The trap on gramps core.** Tiny diffs that look harmless trip the gate: + +- A mid-module-import blank line. +- A multi-line `.append()` collapsed to one line. + +PR 2326 tripped this on `cli/clidbman.py` and a new test file; the fix was a Black-cleaned force-push rebase. Run `black --check` on the changed files before pushing: + +```bash +git diff --name-only --diff-filter=ACMR origin/master...HEAD \ + | grep '\.py$' \ + | xargs --no-run-if-empty black --check --diff +``` + +## `mypy` + +Gramps core's CI runs `mypy` against the tree; type errors block the build. `*.gpr.py` plugin registration files are excluded (they run in the injected-name scope and would otherwise complain about `register`, `_`, etc.). + +This applies to gramps core only. addons-source PRs don't run `mypy`; addon Python doesn't ship type hints by default. Where an addon does add type hints, prefer the 3.10+ shape (`X | None`, `list[X]`) per [16-guidelines → Coding style](16-guidelines.md#coding-style). + +## `ruff` E9 / F63 / F7 / F82 + +addons-source's pre-commit and CI run `ruff` with a tight rule selection: + +| Code | Catches | +|-------|------------------------------------------------------------------------| +| `E9*` | Syntax errors | +| `F63` | Comparison and membership operator mistakes (`is not` vs `not is`) | +| `F7` | Imports inside dead code, syntax-level structural issues | +| `F82` | Undefined names | + +It's a syntax-and-undefined-names net, not a style enforcer — the goal is "code that *imports*", which is what gets you past the plugin-discovery gate. + +Local invocation: + +```bash +ruff check --select E9,F63,F7,F82 / +``` + +The undefined-name rule (`F82`) is the most useful single check for addon authors — `Pillow` typo'd as `Pilllow`, `gramps.gen.plugin` typo'd as `plugn`, the kind of typo that produces a silent skip in the plugin manager and no traceback. `ruff F82` catches them. + +A lint flag is a symptom, not the bug. Don't just add a `# noqa` or a defensive import to silence `F82` — read the enclosing function first. An undefined name in shipping code usually means dead or broken code. + +## `python -m py_compile` / `ast.parse` + +Both pre-commit and addons-source CI compile every changed `.py`: + +```bash +python -m py_compile /*.py +``` + +`ast.parse` is the more lenient check (won't import code, just parses); both exist because a file that compiles can still fail to import (`NameError` at module-level, missing dep). The compile pass is the absolute floor — failing it means the addon can't even register. + +## `msgfmt` on `po/*.po` + +Per-addon `.po` files have to compile to `.mo` cleanly for translations to take effect at runtime. A malformed catalog — mismatched `%s` substitutions, unclosed plural-form expression — silently falls back to English on the platform where compilation fails. Run `msgfmt -c` on every per-addon catalog before publishing. + +Local check: + +```bash +make.py gramps60 compile +``` + +`make.py compile` wraps `msgfmt` with the right paths; a failure prints the offending file and line. See [12-packaging → The localisation flow](12-packaging.md#the-localisation-flow). + +[Mantis 14234](https://gramps-project.org/bugs/view.php?id=14234) (lxml `ngettext` newline fix; addons-source PR 907) is the canonical example — a single misplaced newline in a plural form, caught by a `msgfmt -c` pass. + +## `pylint` + +Gramps' programming guidelines call for pylint ≥ 9 on new files and "changes to existing files shall not reduce the pylint score" — but this is **not** gated by CI. It's developer guidance, not a hard check. + +`pylint` doesn't run on addon code by default. When you do run it locally: + +```bash +pylint --disable=missing-docstring /.py +``` + +Run from the addon's parent directory so `pylint`'s import resolution finds it as a package. + +## Verifying `requires_mod` + +`requires_mod` takes the **importable** module name, not the PyPI distribution name (see [09-troubleshoot → `requires_mod` declares `Pillow`…](09-troubleshoot.md#requires_mod-declares-pillow-but-gramps-says-its-missing)). Before pushing, on a system with the dependency installed: + +```python +from importlib.util import find_spec +for mod in ["PIL", "lxml", "dateutil"]: + assert find_spec(mod) is not None, mod +``` + +This is a manual check, not a CI gate. It's listed here because the failure mode it catches — silent skip in the plugin manager — looks exactly like a `ruff F82` symptom but happens at a different layer. + +## Coding-standard rules worth running locally + +The full standard lives in `../gramps/AGENTS.md` and applies to all Gramps-related Python. The mechanical checks above cover formatting and syntax; the rules below need a manual pass. + +### Import grouping + +Three sections, each with a comment header: + +```python +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import os +import logging + +# ------------------------------------------------------------------------- +# +# GTK/Gnome modules +# +# ------------------------------------------------------------------------- +from gi.repository import Gtk + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gen.db.base import DbReadBase +from .mymodule import MyClass +``` + +Existing code that doesn't follow this stays as-is; new code does. + +### Callback names + +Callbacks are prefixed `cb_`: + +```python +def cb_save(self, *args): + ... +``` + +`pylint` also avoids the `W0613: Unused argument` warning for `cb_*`-prefixed methods, which is convenient for GTK signal handlers that receive arguments they don't use. + +### Class headers + +Every class — including `unittest.TestCase` subclasses — carries a navigation comment header: + +```python +# ------------------------------------------------------------ +# +# MyClass +# +# ------------------------------------------------------------ +class MyClass: + ... +``` + +This is for finding the class when multiple classes share a file, not for documentation. Sphinx-style docstrings handle the documentation. + +### Member-name conventions + +- `__private` (two underscores) — class-only access. +- `_protected` (one underscore) — class and subclass access. + +PEP 8 with one local addition: a space after every comma. + +### TAB stops + +No TABs in Python. Indentation is 4 spaces. Where TABs are unavoidable (Makefiles), they're at columns 9, 17, 25, … (equivalent to 8 spaces). Don't set your editor's TAB stops to 4 — that "fixes" indentation by making TABs invisible and produces files that look right but parse wrong. + +## Running everything locally before pushing + +A pragmatic checklist before opening a PR: + +```bash +# Addons-source PRs: +ruff check --select E9,F63,F7,F82 / +python -m py_compile /*.py +make.py gramps60 compile # exercises msgfmt +python -m unittest discover -s /tests -t . # tests + +# Gramps core PRs add: +black --check --diff .py +mypy +GRAMPS_RESOURCES=. python3 -m unittest discover -p "*_test.py" +``` + +See [12-packaging](12-packaging.md) for `make.py` setup and [07-testing](07-testing.md) for the test-loading conventions. + +## See also + +- [04-fundamentals](04-fundamentals.md) — the conventions the static checks verify. +- [07-testing](07-testing.md) — the runtime checks that complement static analysis. +- [09-troubleshoot → "PR's pre-commit passed but CI is red"](09-troubleshoot.md#prs-pre-commit-passed-but-ci-is-red) — the most common code-analysis-related symptom. +- [12-packaging](12-packaging.md) — `make.py` invocations. +- [16-guidelines → Coding style](16-guidelines.md#coding-style), [16-guidelines → Verification before commit](16-guidelines.md#verification-before-commit) — normative rules. +- [Programming guidelines](https://gramps-project.org/wiki/index.php/Programming_guidelines) — the standalone wiki page; primary scraped source. +- `../gramps/AGENTS.md` — the full Python coding standard, inherited from gramps core. diff --git a/docs/addon-development/11-internationalization.md b/docs/addon-development/11-internationalization.md new file mode 100644 index 000000000..5405ae82e --- /dev/null +++ b/docs/addon-development/11-internationalization.md @@ -0,0 +1,180 @@ +# Internationalization + +[← Previous](10-code-analysis.md) · [Index](01-overview.md) · [Next →](12-packaging.md) + +## Overview + +Gramps is a highly globalized application, and addons should be fully translatable to support users worldwide. This guide covers how to prepare your addon for internationalization (i18n), manage translation strings using `gettext`, and package translations with your addon. + +See [the addon development overview](01-overview.md) for where this fits into the broader addon lifecycle. + +## Working example + +To make strings in your addon translatable, you need to mark them using the standard translation functions, extract them into a template (`.pot`), and provide translations (`.po`). + +### Registration + +In your `*.gpr.py` file, the strings you provide for `name`, `description`, etc., should use `_()` so they can be extracted by Gramps' build tools. + +You do not need to import `_` in the `.gpr.py` file — Gramps' plugin registration loader pre-defines it to use your locale translations. Just mark strings with `_("TEXT")` and supply a translation in your `.po` file. + +```python +# exampleaddon.gpr.py +register( + KIND, + id="ExampleAddon", + name=_("Example Addon"), + description=_("A sample addon to demonstrate internationalization."), + version="1.0.0", + gramps_target_version="6.0", + status=STABLE, + fname="exampleaddon.py", +) +``` + +### Implementation + +Inside your Python implementation, you must set up your addon's translation domain or use the core Gramps translation tools if contributing to the main repository. + +```python +# exampleaddon.py +import os +from gramps.gen.plug import Gramplet + +# Typical setup for an external addon to manage its own translation domain +from gramps.gen.const import GRAMPS_LOCALE as glocale +_ = glocale.get_addon_translator(__file__).gettext + +class ExampleAddon(Gramplet): + def init(self): + # A simple translated string + message = _("Welcome to the Example Addon!") + self.set_text(message) + + def show_items(self, count): + # Using ngettext for proper pluralization + ngettext = glocale.get_addon_translator(__file__).ngettext + msg = ngettext( + "Found %d item.", + "Found %d items.", + count + ) % count + print(msg) +``` + +## Translating UI Files (Glade) + +Gramps' addon translation tools only automatically extract and manage Python strings. If your addon uses a Glade (`.ui` / `.glade`) file for its interface, those strings will not be picked up by the standard addon translation workflow. The recommended pattern is to mark the Glade strings as translatable (so they show up for translators), then override the label at runtime from Python so they get translated through your addon's gettext domain. + +1. Give the relevant widget a meaningful `id` in the `.glade` file (not the autogenerated `label3`-style id), so your Python code can look it up: + + ```xml + + place|Name: + + ``` + + The `place|` prefix is a translator context hint (see [String Marking Rules](#string-marking-rules)) — it tells the translator which sense of "Name" you mean, and is stripped before display. + +2. In the corresponding dialog's `__init__`, override the label with the runtime-translated string: + + ```python + PLACE_NAME = _("place|Name:") + + # inside __init__: + self.get_widget("place_name_label").set_label(PLACE_NAME) + ``` + + The exact setter depends on the widget — `GtkLabel` uses `set_text`, `GtkButton` uses `set_label`, etc. + +3. Re-run `make.py … init` so the new string lands in `template.pot`, then translate and test. + +## String Marking Rules + +| Function | Meaning / Usage | +|----------|---------| +| `_("...")` | Standard string translation. Marks a string for extraction and translates it at runtime. | +| `N_("...")` | Marks a string for extraction but *does not* translate it at runtime. Useful for defining lists of strings that will be translated later when displayed. | +| `ngettext("Singular", "Plural", n)` | Translates a string while applying the correct pluralization rules for the target language based on the integer `n`. | +| `_("Context\|String")` | The Gramps convention for translator context. Prefix the user-facing string with a short hint plus a pipe (`\|`). The translator sees the hint in the `.po` file and renders only the post-pipe portion. The same `_` handles it — no special function call is needed. The two-arg form `_("String", "Context")` works equivalently and dispatches to `pgettext` under the hood. | + +**Canonical context example:** the English word "Title" can mean the title of a *book* or the nobility *title* of a person. In many languages these need different translations. Mark them as: + +```python +_("book|Title") +_("person|Title") +``` + +Translators see the hint, drop the `prefix|` part, and translate the two senses independently. This is the form used throughout `addons-source` today; don't reach for `pgettext` or `sgettext` directly — go through `_`. + +**Note on obsolete functions:** In older versions of Gramps (pre-Gramps 4), you may have seen `lgettext`, `ugettext`, `lngettext`, and friends. The `l*` variants returned strings encoded according to the current locale (bytes, not text), and the `u*` variants existed only to force Unicode output under Python 2. With Python 3, all strings are Unicode by default, so both families became redundant. Use `_` (i.e. `gettext`) and `ngettext` — they always return translated strings as Python `str`. `sgettext` and `pgettext` also exist as internal helpers, but addon code should go through `_`. + +## Weblate (Gramps 6.0+) + +> **Gramps 6.0 Weblate Workflow:** Starting with Gramps 6.0 (and *only* 6.0), addon translations can be done collaboratively on the Gramps Weblate platform. The `Third-party Addons` component contains aggregated translations for every addon. If your addon is hosted in the official repository, you do not need to manually manage `.po` files. + +## Managing Translations Manually with `make.py` + +If you are managing translations manually (or for older Gramps versions), the Gramps `addons-source` repository provides a `make.py` script to manage the entire lifecycle of your translations. This script relies on the standard `gettext` tools. + +Assuming you are in the `addons-source` directory and your addon is named `ExampleAddon`, here is the workflow: + +### 1. Extracting Strings (Template Generation) + +To extract all marked strings from your Python files and generate the `template.pot` file: +```bash +python3 make.py gramps60 init ExampleAddon +``` +This command parses your addon, creates necessary subdirectories (like `po/`), and writes the base `.pot` template. + +### 2. Adding a New Language + +To initialize a translation file for a specific locale (e.g., French `fr`): +```bash +python3 make.py gramps60 init ExampleAddon fr +``` +This creates a new, empty `po/fr-local.po` file based on your template. A translator can now open this `.po` file in a tool like Poedit to provide translations. + +### 3. Updating Translations + +If you modify your Python code and add new strings, you must update your templates and existing language files: +```bash +python3 make.py gramps60 update ExampleAddon fr +``` +This synchronizes the existing `.po` file with the latest `template.pot` without destroying existing translations. + +### 4. Compiling Translations + +When testing locally or preparing to package, compile the human-readable `.po` files into binary `.mo` files (which are placed in `locale//LC_MESSAGES/.mo`): +```bash +python3 make.py gramps60 compile ExampleAddon +``` +*Note: To compile all projects in your local repository at once, use `compile all` instead of `ExampleAddon`.* + +Before committing a hand-edited `.po`, run a quick syntax sanity check: +```bash +msgfmt -c po/fr-local.po +``` +This catches malformed headers, missing/mismatched format placeholders, and broken plural forms without going through the full `make.py` pipeline. + +### 5. Building for Release + +When your addon is ready, the build command will package everything, including the compiled translations, into a `.tgz` archive: +```bash +python3 make.py gramps60 build ExampleAddon +``` + +## Implementation notes + +- **Do not use f-strings or `.format()` inside the translation wrapper:** Translation tools like `xgettext` cannot extract dynamically generated strings. You must use old-style `%` formatting or translate the static template string first before calling `.format()`. + - **Bad:** `_(f"User {name}")` + - **Good:** `_("User %s") % name` +- **Context is key:** If a word can mean multiple things (e.g., "Date" as a fruit vs. "Date" as a calendar day), consider adding translation context comments so translators know how to interpret it. +- **Extraction:** Addons distributed in the `gramps-addons` repository have their translation strings automatically extracted into a `.pot` file by the Gramps translation infrastructure. + +## See also + +- [Addon Development overview](01-overview.md) +- [Coding for translation](https://gramps-project.org/wiki/index.php/Coding_for_translation) — the core-side counterpart to this page; covers conventions for marking strings in Gramps itself. +- [Translating Gramps](https://gramps-project.org/wiki/index.php/Translating_Gramps) — general guidelines for translators (`.po` headers, plural forms, context, mnemonics). +- [Python `gettext` documentation](https://docs.python.org/3/library/gettext.html) — primary reference for `gettext`, `ngettext`, and the `GNUTranslations` class that backs them. diff --git a/docs/addon-development/12-packaging.md b/docs/addon-development/12-packaging.md new file mode 100644 index 000000000..4dac98850 --- /dev/null +++ b/docs/addon-development/12-packaging.md @@ -0,0 +1,285 @@ +# Packaging + +[← Previous](11-internationalization.md) · [Index](01-overview.md) · [Next →](13-community.md) + + + +## Overview + +From "works on my machine" to "users can install it from the addon manager." This chapter is the source-to-distribution pipeline: how `addons-source` becomes a `.addon.tgz` in `addons`, how the in-app addon manager picks it up, and what to send upstream. + +The normative *rules* a submission must satisfy (branch targeting, version-field discipline, PR body shape, Mantis trailers) live in [16-guidelines](16-guidelines.md). This page covers the *workflow* — what to run, what files appear, where they end up. + +## The three repositories + +![Fig. 1 — The source-to-distribution pipeline. Authors edit in `addons-source/`; `make.py build` packages each addon into `addons/grampsXY/download/.addon.tgz` and `make.py listing` refreshes `addons/grampsXY/listings/*.json`; the in-app addon manager fetches both over HTTPS and installs to the user's plugin directory. Edits in the user plugin dir are not pushed back — the flow is one-way only.](_media/packaging-pipeline.svg) + +Gramps addons live across three repositories. You'll have all three cloned side-by-side under one base directory: + +``` +base/ +├── gramps/ # gramps-project/gramps — Gramps itself; source of truth for the API +├── addons-source/ # gramps-project/addons-source — addon source code, one folder per addon +└── addons/ # gramps-project/addons — built distribution, one folder per Gramps version +``` + +| Repo | What's there | You edit? | +|-----------------|-------------------------------------------------------------------------------------|------------------------------------| +| `gramps` | The Gramps source tree; provides `GRAMPSPATH` for the build | No (unless writing a core change) | +| `addons-source` | The addon source: `/.gpr.py`, `/.py`, `po/`, `tests/` | **Yes — author addons here** | +| `addons` | Built `.addon.tgz` packages and listing JSON, organised by Gramps minor | No — `make.py` writes to it | + +`addons` is the **output**. The in-app addon manager hits its HTTPS mirror to fetch listings and downloads. Editing files in `addons` directly does nothing — the next `make.py` run overwrites them. + +### Branch directory split inside `addons/` + +Inside `addons/`, each Gramps minor gets its own subdirectory: + +``` +addons/ +├── gramps42/ +├── gramps50/ +├── gramps51/ +├── gramps52/ +├── gramps60/ +│ ├── download/ # .addon.tgz files +│ └── listings/ # JSON catalogues fetched by the addon manager +└── gramps61/ + ├── download/ + └── listings/ +``` + +The same addon can ship to multiple minors, each with its own `.addon.tgz` — that's why the addon manager reads only the listing for the running Gramps version. + +## Initial clone + +```bash +mkdir gramps-addons && cd gramps-addons + +git clone https://github.com/gramps-project/gramps.git +git clone https://github.com/gramps-project/addons-source.git +git clone https://github.com/gramps-project/addons.git + +cd addons-source +git checkout -b gramps60 origin/maintenance/gramps60 # for 6.0 +# or for master/6.1: +# git checkout -b gramps61 origin/master +``` + +The branch you check out in `addons-source` determines which Gramps minor your built addons target — `make.py` reads the branch name to pick the output directory inside `addons/`. + +See [14-compatibility](14-compatibility.md) for branch-targeting guidance per Gramps minor. + +## Build prerequisites + +`make.py` calls out to two environment things and one OS tool: + +- **`GRAMPSPATH`** — absolute path to your `gramps/` clone. +- **`LANGUAGE`** — must be set to `en_US.UTF-8` for the build to run. +- **`intltool`** — `sudo apt-get install intltool` on Debian/Ubuntu. + +The standard invocation: + +```bash +GRAMPSPATH=/path/to/gramps LANGUAGE='en_US.UTF-8' python3 make.py gramps60 +``` + +Cumbersome to type each time. Set the env vars in your shell startup once; only the `make.py` line varies per command. + +In the examples below, `gramps60` is the maintenance/gramps60 target; substitute `gramps61` when you're working on the master branch. + +## `make.py` cheat sheet + +`make.py` lives at the top of `addons-source` and runs against one addon at a time, or `all` for everything. The commands you'll use most: + +| Command | What it does | +|------------------------------------------|-------------------------------------------------------------------------------| +| `make.py gramps60 init ` | Create `/po/template.pot` from extracted strings | +| `make.py gramps60 init ` | Create `/po/-local.po` from the template | +| `make.py gramps60 update ` | Merge new strings from Gramps + the addon into `-local.po` | +| `make.py gramps60 compile ` | Compile every `-local.po` into `.mo` files | +| `make.py gramps60 build ` | Compile translations *and* produce `.addon.tgz` in `addons/gramps60/download/` | +| `make.py gramps60 listing ` | Refresh `addons/gramps60/listings/*.json` so the addon manager sees it | +| `make.py gramps60 clean ` | Delete generated files (`locale/`, `*.mo`) — run before `git add` | +| `make.py gramps60 build all` | Build every addon | + +`build` includes `compile`, so the standard release cycle is `clean` → edit → `build` → `listing` → commit + push to `addons/`. + +### What `build` packages + +By default `build` includes: + +- every `*.py` in the addon folder, +- every `*.glade`, `*.xml`, `*.txt`, +- every `locale/*/LC_MESSAGES/*.mo`. + +Anything else — README images, extra data files, help HTML — needs an explicit `MANIFEST` file in the addon's root listing them, **with the addon folder name prefixed on each line**: + +``` +/README.md +/help/index.html +/data/* +``` + +The `MANIFEST` mechanism was added in Gramps 5.0 and is the way to ship anything beyond the default file types. + +## The localisation flow + +Per-addon translations live under `/po/`. They are independent of Gramps' core catalogues — Gramps' plugin loader binds `_()` to the addon's own catalog when the implementation module declares: + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale + +_ = glocale.get_addon_translator(__file__).gettext +``` + +See [04-fundamentals → Translation](04-fundamentals.md#translation) for the full opt-in. Without that line, strings fall back to the core catalog regardless of where translators put them. + +### Adding a new language + +```bash +# 1. Generate (or refresh) the template from extracted strings. +make.py gramps60 init + +# 2. Initialise a fresh language file from the template. +make.py gramps60 init fr + +# 3. Translator edits /po/fr-local.po manually. + +# 4. Recompile so the .mo file lands under /locale/fr/LC_MESSAGES/. +make.py gramps60 compile + +# 5. Commit the .po (NOT the .mo or the generated locale/ tree). +git add /po/fr-local.po +git commit -m "Add French translation for " +``` + +### Refreshing an existing language + +When you've changed user-visible strings in the addon, every existing `-local.po` needs new entries merged in: + +```bash +make.py gramps60 update fr +``` + +This preserves existing translations and marks new or changed entries as `fuzzy` for the translator to review. + +### Editing `template.pot`'s header once + +The header of `/po/template.pot` carries author, maintainer, and team metadata. Edit it after the first `init` — the values propagate into every per-language file the next time you `init `. + +### What to commit, what to skip + +Commit: + +- `/po/template.pot` +- `/po/-local.po` (one per language) + +Don't commit: + +- `/locale/**` — generated by `compile` and `build`. Always run `make.py gramps60 clean ` (or `rm -rf /locale`) before `git add`. +- `/*.mo` outside `locale/` — same reason. + +## Publishing to `addons/` + +`build` writes one `.addon.tgz` per addon; `listing` rebuilds the JSON catalogues the addon manager fetches. Both go into the `addons/` repository and need committing **there**, not in `addons-source/`: + +```bash +# In addons-source/, build the package. +make.py gramps60 build +make.py gramps60 listing + +# Switch to the addons/ checkout. +cd ../addons + +# Stage the new .tgz and the refreshed listings. +git add gramps60/download/.addon.tgz +git add gramps60/listings/* +git commit -m "Add for Gramps 6.0" + +# Push when you have write access — see [16-guidelines] for the review gate. +``` + +The in-app addon manager hits the `addons` repo over HTTPS and shows the addon to users on their next "Check for updates" cycle. + +## Editing `addons-source/`, not the live plugin directory + +During development it's tempting to edit directly in `~/.local/share/gramps/gramps60/plugins//` so a Gramps restart picks up the change without copying. **Don't** — that directory is the auto-sync *target*, and Gramps writes back through it on every save. Edits made there are silently overwritten on the next source save. + +Edit in `addons-source//`. The dev loop is one of: + +- **Gramps 6.0** — copy / `rsync` the folder into the user plugin directory on each save. Plugin discovery doesn't follow symlinks. +- **Gramps 6.1+ on Linux/macOS** — symlink the working tree into the user plugin directory once, then edit in place. Plugin discovery follows symlinks with realpath-based loop dedup (commit `9443dcbb30`). +- **Windows, any version** — physical copy. The 6.1 symlink test is skipped on Windows because the platform's symlink behaviour is inconsistent without elevated privileges. + +See [01-overview → Where addons live](01-overview.md#where-addons-live) for the user plugin directory paths. + +## Submitting a new addon + +The first time you add an addon to `addons-source/`: + +```bash +cd addons-source + +# 1. Create the folder and the two required files. +mkdir +$EDITOR /.gpr.py +$EDITOR /.py + +# 2. (Optional, recommended) Translation scaffold + tests. +mkdir /po /tests +$EDITOR /tests/__init__.py # empty marker — see 07-testing +$EDITOR /tests/test_.py + +# 3. Clean any build artefacts before committing. +make.py gramps60 clean + +# 4. Commit and push to your fork, then open a PR. +git add +git commit -m "Add : " +``` + +Open the PR against the **correct branch** — for an addon targeting Gramps 6.0, that's `maintenance/gramps60`, which the maintainer cherry-picks forward to `gramps61` (see [16-guidelines → Contributor workflow](16-guidelines.md#contributor-workflow) for the full submission shape). + +## Update an existing addon + +After editing source: + +```bash +cd addons-source + +# 1. Iterate: edit, restart Gramps, verify behaviour. +# 2. Refresh translations if user-visible strings changed. +make.py gramps60 update all +# 3. Compile-check. +make.py gramps60 compile +# 4. Clean and commit. +make.py gramps60 clean +git add +git commit -m ": " +``` + +The `version` field in `.gpr.py` **stays untouched** in PRs — the maintainer manages versions centrally. (See [16-guidelines](16-guidelines.md#contributor-workflow); this came up on PR 911 where a bump was rejected.) + +## See also + +- [01-overview](01-overview.md) — what an addon is. +- [04-fundamentals → Translation](04-fundamentals.md#translation) — the `_()` injection that makes per-addon `.po` files load. +- [07-testing](07-testing.md) — what to put in `tests/` before packaging. +- [14-compatibility](14-compatibility.md) — picking the right Gramps minor and branch for your addon. +- [16-guidelines](16-guidelines.md) — the normative rules a PR must satisfy. +- [Addons development](https://gramps-project.org/wiki/index.php/Addons_development) — the standalone wiki page; primary scraped source for this chapter. +- [addons-source CONTRIBUTING.md](https://github.com/gramps-project/addons-source/blob/maintenance/gramps60/CONTRIBUTING.md) — the addons-source-side contributor guide. diff --git a/docs/addon-development/13-community.md b/docs/addon-development/13-community.md new file mode 100644 index 000000000..9478c8265 --- /dev/null +++ b/docs/addon-development/13-community.md @@ -0,0 +1,84 @@ +# Community + +[← Previous](12-packaging.md) · [Index](01-overview.md) · [Next →](14-compatibility.md) + + + +## Overview + +When the PR is merged and the package published ([Packaging](12-packaging.md)), the addon *exists* — but nobody can find it, read about it, or reach you about it. This page covers the four steps that make a merged addon part of the ecosystem: the addon-list entry, the addon's own wiki page, the announcement, and the ongoing support duty. None of them touch code; all of them decide whether the addon gets used. + +## List your addon + +Add a row for your addon to the release's addon list — [6.0 Addons](https://gramps-project.org/wiki/index.php/6.0_Addons) for the current release, or the next release's list (e.g. [6.1 Addons](https://gramps-project.org/wiki/index.php/6.1_Addons)) if the addon targets an unreleased minor. Copy an existing row and fill in the columns; the [Addon list legend](https://gramps-project.org/wiki/index.php/Addon_list_legend) explains what each column means (type, audience, rating, contact, download). + +The row skeleton, as it appears in the list page's wiki source: + +``` +|- +| +| +| +| +| +| +| +| +|- +``` + +This listing is what users browse; the Plugin Manager's download listing ([Packaging](12-packaging.md) → the `make.py listing` step) is what Gramps itself reads. An addon needs both. + +## Document your addon + +Give the addon its own wiki support page — the page the addon list's first column links to. Examine other addons' pages for the format; the conventional skeleton: + +``` +{{Third-party plugin}} + +== Usage == + +=== Configure Options === + +== Features == + +== Prerequisites == + +== Issues == + +[[Category:Addons]] +[[Category:Plugins]] +[[Category:Developers/General]] +``` + +Only add the sections the addon needs — a Gramplet with no options doesn't need *Configure Options*. The `{{Third-party plugin}}` template expands to the standard notice that the addon is third-party and where to report problems; every addon page carries it. + +## Announce the addon + +Join the [Gramps forum](https://gramps.discourse.group/) and announce the addon to users: what it does, why you built it, and how to use it. This is the step authors skip most — and an unannounced addon is invisible to the users who would have wanted it. + +## Support it through the issue tracker + +Register on the [Gramps MantisBT tracker](https://gramps-project.org/bugs/) and check it regularly. **There is no automated notification** that routes issues against your addon to you — reports sit unseen unless you look. (For fix workflow, the tracker conventions, and the commit-message trailers that close Mantis issues, see [Rules](16-guidelines.md) → Commit messages.) + +Users don't read code and they make assumptions; reports will be ambiguous or wrong about the cause. Be kind and guiding — a curt reply from an addon's own author is the fastest way to lose the users the announcement won. + +## Why addons exist + +Worth keeping in mind across the maintenance years that follow ([Compatibility](14-compatibility.md), [What's New](15-whats-new.md)): the addon channel is deliberately low-barrier. It provides: + +- a quick way for anyone to share their work — the project has never refused an addon; +- a place for a component to evolve continuously, often before core acceptance; +- a home for plugins that will never be accepted into core but are loved by many users; +- a place for experimental components to live. + +## See also + +- [Packaging](12-packaging.md) — the build/listing mechanics that precede these steps. +- [Compatibility](14-compatibility.md) — keeping the published addon working across Gramps versions. +- [Addons development](https://gramps-project.org/wiki/index.php/Addons_development) — the upstream page these steps derive from. +- [6.0 Addons](https://gramps-project.org/wiki/index.php/6.0_Addons) — the addon list itself. diff --git a/docs/addon-development/14-compatibility.md b/docs/addon-development/14-compatibility.md new file mode 100644 index 000000000..1d5a413fe --- /dev/null +++ b/docs/addon-development/14-compatibility.md @@ -0,0 +1,114 @@ +# Compatibility + +[← Previous](13-community.md) · [Index](01-overview.md) · [Next →](15-whats-new.md) + + + +## Overview + +How an addon survives — or fails — across Gramps versions. Two things to understand: the `gramps_target_version` contract (Gramps' minor matters; majors aren't even discussed), and the concrete deltas between adjacent maintenance branches that bite ports in practice. + +A working addon for Gramps 6.0 is usually a working addon for 6.1 with **zero** code changes. The exceptions are documented here; when in doubt, the safest move is to maintain one addon folder per Gramps minor in parallel `maintenance/gramps*` branches of `addons-source`. + +## `gramps_target_version` semantics + +The `.gpr.py` registration declares which Gramps minor the addon targets: + +```python +register( + GRAMPLET, + id="MyAddon", + gramps_target_version="6.0", # major.minor + ... +) +``` + +Gramps matches this **on the major.minor pair** at plugin discovery. A `6.0` addon will not load in 6.1, and a `6.1` addon will not load in 6.0 — the plugin manager silently skips the registration entry. + +### Supporting multiple minors + +The one-addon-per-minor convention is enforced by the branch directory split in the [`addons/`](https://github.com/gramps-project/addons) repo and the matching `maintenance/gramps*` branches in [`addons-source/`](https://github.com/gramps-project/addons-source). For an addon that supports 6.0 and 6.1: + +``` +addons-source @ maintenance/gramps60: MyAddon/MyAddon.gpr.py declares "6.0" +addons-source @ maintenance/gramps61: MyAddon/MyAddon.gpr.py declares "6.1" +``` + +A single `make.py gramps60 build MyAddon` on `maintenance/gramps60` produces the 6.0-targeted `.addon.tgz`; the same command with `gramps61` on `maintenance/gramps61` produces the 6.1-targeted one. See [12-packaging](12-packaging.md) for the workflow. + +When the **code is identical** between minors, the maintainer forward-merges the `maintenance/gramps60` branch into `maintenance/gramps61` and rebuilds — no per-minor source maintenance needed. + +When the code **isn't identical** (e.g. the GExiv2 version handling delta below), the two branches diverge intentionally, and you commit the minor-specific fix to each. + +## Branch targeting for fixes + +The rule that determines which branch a fix lands on differs between the two repos: + +- **`addons-source/`** → `maintenance/gramps60`. Gary cherry-picks forward to `gramps61`. (Gary Griffin, addons-source PR 915, 2026-05-24.) +- **`gramps/`** (core) → `maintenance/gramps61`. Fixes and cleanups go on the current production branch and forward-merge to `master`. Only genuinely new-feature work targets `master`. (jralls, gramps#2298.) + +A reviewer's instruction on a specific PR overrides the default (e.g. Nick-Hall asking for `master` on gramps#2299). See [16-guidelines → Contributor workflow](16-guidelines.md#contributor-workflow) for the normative form. + +## "Applies cleanly" is not "remains correct" + +A cherry-pick that `git` accepts without conflict can still be wrong on the target branch — the branches' *related* code may have changed even though the patch's hunks didn't. + +**Concrete example.** addons-source PR 829 rewrote GExiv2 version handling on `maintenance/gramps61` only. An addon that pins `requires_gi=[("GExiv2", "0.10")]` is fine on 6.0; the same pin on 6.1 may need adjustment because the code that reads the pin has changed shape. A cherry-pick of the addon would land cleanly and still be wrong. + +**The check.** Before treating a cross-branch port as done, diff the related code on the target branch — not just the file the patch touched. Read the surrounding functions; read the modules the declaration interacts with. + +## Notable 6.0 → 6.1 deltas + +The complete delta lives in the Gramps changelog; the entries below are the ones that have repeatedly affected addon authors. + +### Plugin discovery follows symlinks + +Gramps 6.0 plugin discovery **does not** follow symlinks; the addon folder must be physically present under the plugin path. Gramps 6.1 follows symlinks with realpath-based dedup against symlink loops (commit [`9443dcbb30`](https://github.com/gramps-project/gramps/commit/9443dcbb30) on `maintenance/gramps61`, with `_manager_symlinks_test.py` covering both the scan-via-symlink and loop-terminates cases). + +**Impact on dev loop.** On 6.0, copy or `rsync` the working tree into the user plugin directory on every save. On 6.1+ Linux/macOS, symlink once and edit in place. On Windows, the symlink test is skipped because the platform's symlink behaviour is inconsistent without elevated privileges; physical copy remains the safe approach on 6.1+ too. + +### Windows toolchain migrated to UCRT64 + +Gramps' Windows build migrated from MINGW64 to MSYS2 **UCRT64** in gramps PR [#2198](https://github.com/gramps-project/gramps/pull/2198) on `maintenance/gramps61` (merged 2026-04-19). MINGW64's Python target triple is rejected by orjson's `maturin` backend, so the change was forced. + +**Impact on addon Windows testing.** Addon tests run on UCRT64 on 6.1 and master only. Windows testing on 6.0 is unsupported by upstream's addons-source CI. See [07-testing](07-testing.md) for the filename-prefix convention that selects per-OS tests. + +### GExiv2 version handling rewritten + +addons-source PR [829](https://github.com/gramps-project/addons-source/pull/829) rewrote the GExiv2 version handling on `maintenance/gramps61`, in the EditExifMetadata addon. An addon that interacts with GExiv2 via `requires_gi` may need branch-specific declarations. + +**The check.** When the addon imports `GExiv2` or declares it in `requires_gi`, read the EditExifMetadata addon on the *target* branch before assuming a pin is correct. + +### BSDDB-on-Windows skip + +A test-skip rule for BSDDB on Windows landed on `maintenance/gramps61` only. Addons that exercise the BSDDB backend in tests need to account for the absence of BSDDB on Windows 6.1, not assume the 6.0 behaviour transfers. + +## Reading the deprecation signal + +When core deprecates an API, addon authors see two things in order: + +- A `DeprecationWarning` raised the first time the deprecated symbol is touched. Visible when Gramps runs with `python -W default`, or in the Gramps log window at `WARNING` level. +- A scheduled removal in the next major release. + +**Practical step.** Once a release, launch with `python -W default` against `example.gramps` and skim the log window. Every `DeprecationWarning` is a maintenance task for the next minor; deferring them until removal turns "the addon shows up but does nothing" bugs into the dominant porting failure mode. + +For the actual deprecated surface in the running Gramps, the authoritative reference is the source — search `gramps/gen/**/*.py` for `DeprecationWarning` on the target branch. + +## Sanity checks before a port + +1. **Read the new branch's relevant code.** Not the patch — the surrounding code. The patch lands; the assumption around it may have shifted. +2. **Run the addon's tests on the new branch.** The whole point of the per-OS prefix convention in [07-testing](07-testing.md) is to catch this exact case. +3. **Reproduce against `example.gramps` on both branches.** The canonical fixture is identical across minors, so an output difference is an actionable signal. +4. **Check the open PRs against `gramps` and `addons-source` for anything affecting your addon.** A fix may be in flight upstream; verifying that PR is usually better than writing your own. + +## See also + +- [01-overview → Where addons live](01-overview.md#where-addons-live) — the 6.0 vs 6.1 symlink discovery rule, with the dev-loop consequence. +- [04-fundamentals → The `.gpr.py` registration file](04-fundamentals.md#the-gprpy-registration-file) — `gramps_target_version` declaration in context. +- [12-packaging](12-packaging.md) — how the per-minor build flow uses `gramps_target_version`. +- [15-whats-new](15-whats-new.md) — scheduled per-release changes affecting addon authors. +- [16-guidelines → Contributor workflow](16-guidelines.md#contributor-workflow) — normative branch-targeting rules. diff --git a/docs/addon-development/15-whats-new.md b/docs/addon-development/15-whats-new.md new file mode 100644 index 000000000..fec2379da --- /dev/null +++ b/docs/addon-development/15-whats-new.md @@ -0,0 +1,79 @@ +# What's New + +[← Previous](14-compatibility.md) · [Index](01-overview.md) · [Next →](16-guidelines.md) + +## Overview + +API and convention changes that affect addon authors, per Gramps minor release. The audience is someone with a working addon on the previous version asking *"what do I need to know before I bump `gramps_target_version`?"* + +This page is the **addon-author slice** of the change log. It's not the full release notes — those live on the wiki proper. Entries here are filtered for things that affect: + +- the `gramps.gen.*` import surface, +- the plugin-registration surface (`_pluginreg.py`), +- the docgen and report APIs, +- per-addon translation / locale plumbing, +- the addon discovery and loading mechanism. + +For the practical *how to port* guidance — what to check on a cross-version port, when to maintain parallel branches — see [14-compatibility](14-compatibility.md). This page is the inventory; 14-compatibility is the procedure. + +## Gramps 6.1 + +Targeted from `maintenance/gramps61`; `master` until the 6.1.0 release. + +### Added + +- **Plugin discovery follows symlinks.** Symlinking a working-tree addon folder into the user plugin directory now works, with realpath-based dedup so cycles terminate. Commit [`9443dcbb30`](https://github.com/gramps-project/gramps/commit/9443dcbb30), with `_manager_symlinks_test.py` covering both the scan-via-symlink case and loop termination. The dev loop on Linux/macOS becomes *symlink once, edit in place*. (See [01-overview → Where addons live](01-overview.md#where-addons-live).) + +### Changed + +- **Windows toolchain migrated from MINGW64 to MSYS2 UCRT64.** Gramps' Windows build moved in PR [#2198](https://github.com/gramps-project/gramps/pull/2198) (merged 2026-04-19). MINGW64's Python target triple is rejected by orjson's `maturin` backend; the migration was forced. + - **Impact on addon authors:** Windows addon testing targets `maintenance/gramps61` and `master` only — Windows on 6.0 is not upstream-tested. See [14-compatibility → Windows toolchain migrated to UCRT64](14-compatibility.md#windows-toolchain-migrated-to-ucrt64). +- **GExiv2 version handling rewritten.** addons-source PR [829](https://github.com/gramps-project/addons-source/pull/829) rewrote how GExiv2's version is read and pinned. An addon's `requires_gi=[("GExiv2", "0.10")]` declaration may need adjustment; read the EditExifMetadata addon's GExiv2 code on the target branch before assuming a 6.0 pin transfers. See [14-compatibility → GExiv2 version handling rewritten](14-compatibility.md#gexiv2-version-handling-rewritten). +- **BSDDB-on-Windows test skip.** A skip rule for BSDDB on Windows landed on `maintenance/gramps61`. Addons exercising the BSDDB backend in tests need to account for its absence on Windows 6.1 (use `@unittest.skipUnless(...)`; see [07-testing → Skip cleanly](07-testing.md#skip-cleanly)). + +### Deprecated + +*None tracked here yet.* The authoritative reference for runtime deprecations is the source — search `gramps/gen/**/*.py` on the target branch for `DeprecationWarning`. See [14-compatibility → Reading the deprecation signal](14-compatibility.md#reading-the-deprecation-signal) for the recipe. + +### Removed + +*None tracked here yet.* + +## Gramps 6.0 + +The manual's baseline target. Addons declaring `gramps_target_version="6.0"` run on 6.0.x and are not loaded by 6.1 or later (and vice versa); see [14-compatibility → `gramps_target_version` semantics](14-compatibility.md#gramps_target_version-semantics). + +### Added + +- **SQLite became the default database backend.** New trees are SQLite-backed unless the user explicitly chooses BSDDB. Addons that do straight `gramps.gen.db.*` reads keep working unchanged — the abstraction holds — but addons that bypassed the abstraction (e.g. reaching into BSDDB-specific cursor APIs) need to migrate to the portable interface. + +### Changed + +- **Python 3.10+ minimum.** Older Pythons no longer run Gramps 6.0, which means addons can use modern type-hint syntax — `X | None` instead of `Optional[X]`, `list[X]` instead of `typing.List[X]` — without a compatibility shim. See [16-guidelines → Coding style](16-guidelines.md#coding-style). + +### Deprecated + +*Verify against the source.* `DeprecationWarning`s on `maintenance/gramps60` are the authoritative list. + +### Removed + +*None tracked here yet.* + +## Earlier releases + +The 5.x → 6.0 transition was a major release; many APIs changed and the maintenance window for addons targeting earlier minors is closing. The authoritative reference for cross-major changes is the [Gramps wiki's release-notes pages](https://www.gramps-project.org/wiki/index.php/Portal:Using_Gramps#Release_notes). + +Practical guidance: addons still targeting 5.x should pin `gramps_target_version="5.2"` (the last 5.x minor) and live on the matching `addons-source` branch; the cross-major port is a separate exercise from the per-minor deltas this page tracks. + +## How to read this page + +- Each release section is **incremental** — entries describe what changed *from the previous minor*, not the cumulative API surface. +- Where an entry has an upstream commit, PR, or addon-side fix, it's cited inline so the change is auditable. Entries without a citation reflect conventions that emerged rather than discrete commits. +- The *current* surface (what's available right now) lives in [06-api-reference](06-api-reference.md), not here. + +## See also + +- [14-compatibility](14-compatibility.md) — porting an addon across these releases; the practical companion to this inventory. +- [06-api-reference](06-api-reference.md) — the current `gramps.gen.*` surface. +- [Portal:Using Gramps → Release notes](https://www.gramps-project.org/wiki/index.php/Portal:Using_Gramps#Release_notes) — upstream release notes (full, not addon-filtered). +- [`gramps/NEWS`](https://github.com/gramps-project/gramps/blob/maintenance/gramps61/NEWS) — the in-tree change log on the target branch. diff --git a/docs/addon-development/16-guidelines.md b/docs/addon-development/16-guidelines.md new file mode 100644 index 000000000..fa427ad14 --- /dev/null +++ b/docs/addon-development/16-guidelines.md @@ -0,0 +1,183 @@ +# Rules + +[← Previous](15-whats-new.md) · [Index](01-overview.md) · [Next →](17-roadmap.md) + +## Overview + +Normative reference for addon authors. Conceptual / how-to material lives in the other section pages; this page enumerates the guidelines and is the one to cite in code review. + +## Repository scope + +- **This page applies to the addon repository — [`gramps-project/addons-source`](https://github.com/gramps-project/addons-source).** It does **not** govern Gramps core. +- Core contributions (`gramps-project/gramps`) follow the separate [Core Development — Rules](https://gramps-project.org/wiki/index.php/Gramps_6.1_Wiki_Manual_-_Core_Development_-_Rules) page. The two repositories diverge on branch target, test layout, translation tooling, and which static checks are enforced — do not transfer a rule across without checking it here. +- The full Python coding standard is inherited from core's `../gramps/AGENTS.md`; this page restates the parts addon code review enforces and adds the addon-specific structure, packaging, and translation rules that live outside that file. +- **When in doubt, the authoritative source wins and is what to check.** These pages are a convenience restatement. On coding style, core's `../gramps/AGENTS.md` is the source of truth; on addon-specific rules, the authority is upstream `addons-source` (its `CONTRIBUTING.md` and a maintainer's ruling on the PR). Where this page is silent, ambiguous, or disagrees with the authoritative source on the *target branch*, that source wins — verify against it rather than relying on this page from memory. +- **Core stands in where this page doesn't — one way only.** Where this page is not specific or prescriptive on a point, the [Core Development — Rules](https://gramps-project.org/wiki/index.php/Gramps_6.1_Wiki_Manual_-_Core_Development_-_Rules) page (and core's `AGENTS.md`) is the default that fills the gap — addons inherit from core. The fallback runs in this direction only: where this page *is* prescriptive on an addon-specific concern (structure, packaging, branch target, test layout — `tests/` + `test_*.py`, `maintenance/gramps60`), it governs and core does not override it; and the addon guidelines never fill a gap in the core page. + +## Conventions + +RFC 2119 keywords, with our short forms: + +| Keyword | Meaning | +|---------|---------| +| **MUST** / **MUST NOT** | Required; a violation is a defect | +| **SHOULD** / **SHOULD NOT** | Strongly recommended; deviate only with a stated reason | +| **MAY** | Allowed | + +Where a rule has a known origin — an upstream PR, a maintainer ruling, a Mantis bug — it's cited inline so the rule is auditable. + +## Structure + +- **MUST**: the addon's folder name is a valid Python import name (an importable identifier — no spaces). Gramps puts each addon's directory on `sys.path` and addons share code via `import ` (see [the upstream Addons development page](https://gramps-project.org/wiki/index.php/Addons_development) → "name your addons with a name appropriate for Python imports"). The folder name need **not** match the `id` in `.gpr.py`: the registration `id` is an independent plugin key and routinely differs (e.g. folder `DeepConnectionsGramplet` ↔ id `Deep Connections Gramplet`), and one folder may register several plugins with unrelated ids. +- **MUST**: `.gpr.py` declares `gramps_target_version` matching the Gramps minor the addon targets. +- **MUST**: `fname` points to an implementation module shipped in the same folder. +- **MUST**: the addon is physically present under the plugin path — a physical copy works on every Gramps version and OS. (Gramps 6.1+ also discovers an addon reached via a symlink, but a physical copy is the portable default.) +- **MUST NOT**: import `register`, `GRAMPLET`, `STABLE`, `_`, or any other name Gramps injects into the `.gpr.py` namespace. +- **MUST NOT**: add `__init__.py` to the addon directory itself. The plugin loader puts the addon dir on `sys.path` and imports `.py` by name; making the addon dir a regular package disturbs that resolution and can trigger the [Mantis 12691](https://gramps-project.org/bugs/view.php?id=12691) submodule-binding trap. (See [07-testing → Why `tests/__init__.py` exists](07-testing.md#why-tests__init__py-exists).) +- **MUST** (`TOOL` kind): register an `optionclass` even when the tool takes no options. Gramps refuses to load a `TOOL` without one; an empty `tool.ToolOptions` subclass is sufficient. +- **SHOULD**: ship a `po/` directory with at least `template.pot` if any user-visible string exists. Generate it with `make.py init ` (see [12-packaging](12-packaging.md)); if it's missing the maintainer creates it on initial check-in. +- **MAY**: ship a `tests/` package with an `__init__.py` marker and at least one test — most existing addons predate addon unit tests. When tests are shipped, the `__init__.py` marker keeps dotted-path loading deterministic and the layout rules under *Testing* apply; a bug fix still **SHOULD** ship a regression test. +- **MAY**: ship multiple plugin kinds from a single addon — multiple `register(...)` calls in one `.gpr.py`, and/or multiple `.gpr.py` files in the addon folder (the loader scans every `*.gpr.py`). + +## Source location + +- **MUST**: edit addon source in `addons-source/`, never in the live plugin directory. The auto-sync runs source → installed plugin one-way; edits in the live dir are silently overwritten on the next source save. + +## Translation + +The full how-to (registration setup, `make.py` lifecycle, Glade runtime-override pattern, function reference) lives in [11-internationalization](11-internationalization.md). The rules below are what code review enforces. + +- **MUST**: wrap every user-visible string with `_()`. +- **MUST NOT**: `import _` in `.gpr.py` — Gramps' plugin loader injects it. Implementation modules **MUST** bind it explicitly via `_ = glocale.get_addon_translator(__file__).gettext`. +- **MUST** (multi-file packages): when the addon's code is split across a nested package, bind `_` **once at the addon root** — the directory that holds `locale/`, in a root-level module (e.g. `_i18n.py`) — and import it everywhere else by **bare name** (`from _i18n import _`), **not** a `.`-prefixed path: the addon dir is on `sys.path` and its root is **not** a package (see *Structure* → MUST NOT `__init__.py`), so a root-level module imports directly, whereas `from ..i18n import _` raises `'' is not a package` at import time. `get_addon_translator(filename)` derives the catalog dir as `dirname(abspath(filename)) + "/locale"` (`gramps.gen.utils.grampslocale`), so a `get_addon_translator(__file__)` call from a nested module (e.g. `myaddon/views/tab.py`) resolves `myaddon/views/locale/`, which doesn't exist, and a non-English user silently gets the untranslated string. The flat `_ = glocale.get_addon_translator(__file__).gettext` form above is correct only because that module sits at the addon root; from a nested module, anchor the path at the root (e.g. `get_addon_translator(os.path.join(ADDON_ROOT, "_"))` — only `dirname(...)` is read, so the basename is an unused placeholder) instead of passing `__file__`. (NameSuite i18n-anchor fix, 2026-06-25.) +- **SHOULD**: verify an addon translation against an **addon-owned** msgid — one that appears only in the addon's `template.pot`, never a string that also exists in core (e.g. `"Given name"`). `get_addon_translator` returns the **core** translator with the addon catalog only as a *fallback*, so a core string renders translated whether or not the addon binding resolves — it cannot prove the fix. (Same fix: the original check used a core string and demonstrated nothing.) +- **MUST NOT**: wrap an f-string or `.format()` result in a translation function. `xgettext` cannot extract dynamically built strings. + - **Bad:** `_(f"User {name}")`, `_("User {}".format(name))` + - **Good:** `_("User %s") % name` +- **MUST** (Glade): translatable strings in `.glade` / `.ui` files are **not** picked up by the addon translation tooling — the extractor only sees Python. For each translatable Glade string, give the widget a meaningful `id`, mark the string with `translatable="yes"` (optionally with a `"context|"` prefix), and override the label at runtime in Python: `self.get_widget("place_name_label").set_label(_("place|Name:"))`. +- **SHOULD**: use `ngettext(singular, plural, n)` for plural forms. +- **SHOULD**: use the pipe-prefix form `_("Context|String")` whenever a word could carry multiple senses (e.g. `_("book|Title")` vs `_("person|Title")`). This is the convention used throughout `addons-source` and is what translators see in the `.po` file. The two-arg form `_(msg, context)` works equivalently. **MUST NOT** call `pgettext` or `sgettext` directly — go through `_`. +- **SHOULD**: use `N_("…")` to mark a string for extraction without translating it at call time (e.g. for module-level constants that are translated later when displayed). + +> Addons have no `POTFILES.in` to maintain by hand — the per-addon `po/template.pot` is regenerated by `make.py init ` (see [12-packaging](12-packaging.md)). Maintaining `po/POTFILES.in` / `POTFILES.skip` is a **core** rule; see the [Core Development — Rules](https://gramps-project.org/wiki/index.php/Gramps_6.1_Wiki_Manual_-_Core_Development_-_Rules) page. + +## Runtime + +- **MUST**: perform every database write inside a `DbTxn`: + ```python + with DbTxn(_("Adding example"), db) as trans: + db.add_person(person, trans) + ``` +- **MUST**: declare runtime imports in `requires_mod` using the *importable* module name (`PIL`), not the PyPI distribution name (`Pillow`). +- **MUST**: verify each `requires_mod` entry with `importlib.util.find_spec("")` on a system with the package installed before publishing. +- **MUST**: use `requires_gi` for GObject-Introspection bindings, with version strings. The version pin **must match what the code actually imports** at runtime — pins can drift between Gramps minors (e.g. GExiv2 handling was rewritten on `maintenance/gramps61` per addons-source PR 829), so verify the pin against the target branch's related code, not just the previous branch's working declaration. +- **MUST NOT**: mutate process-global state that Gramps' startup owns — run or quit the GTK main loop (`Gtk.main()` / `Gtk.main_quit()`), install screen-wide CSS / retheme the icon theme / change `Gtk.Settings`, replace `sys.excepthook`, call `locale.setlocale` or `gettext.install`, configure the root logger, leave permanent `sys.path` entries, or set `os.environ` keys. An addon is a guest in Gramps' process; the full startup surface with per-item alternatives is [04-fundamentals → The provided environment](04-fundamentals.md#the-provided-environment). +- **SHOULD**: use handles (`PersonHandle`, etc.) for internal traversal; reserve Gramps IDs (`I0001`, …) for user-facing display. Handles are internal and stable; Gramps IDs are user-editable and rewritten in bulk by the Reorder Gramps IDs tool. +- **SHOULD**: import only from `gramps.gen.*`. `gramps.gui.*` and `gramps.plugins.*` are internal to the shipped distribution and break across Gramps versions. +- **SHOULD**: use a module-level logger (`LOG = logging.getLogger(__name__)`); **MUST NOT** use `print()` for diagnostic output. +- **SHOULD**: raise existing exceptions from `gramps.gen.errors` and `gramps.gen.db.exceptions` before inventing a new class. +- **SHOULD**: raise `HandleError` for invalid or missing handles. +- **SHOULD**: compare backlink class names by string. `db.find_backlink_handles(handle)` yields `(class_name, handle)` tuples where `class_name` is `"Person"` / `"Family"` / … as a `str`, not the Python class — `if cls is Person:` always evaluates `False`. +- **MAY**: introduce a new exception class only when none of the existing ones accurately represent the error condition. + +## Testing + +- **MUST**: use stdlib `unittest` — never `pytest`. Gramps itself standardises on `unittest`, which keeps addon tests contributable upstream. +- **MUST**: name test files `test_*.py` and place them in a `tests/` package alongside the addon module. +- **MUST**: scope platform-specific tests with the correct prefix: + + | Prefix | Where it runs | + |--------|---------------| + | `test_*.py` | All platforms | + | `test_linux_*.py` | Linux only | + | `test_windows_*.py` | Windows only | + | `test_integration_*.py` | Linux only — full-pipeline / DB-backed | + +- **MUST**: tests run cleanly without the addon's `requires_mod` dependencies installed in the Python that runs them — mock at the import boundary, or skip cleanly with `@unittest.skipUnless(...)`. Mac contributors can't easily install addon deps into the Gramps Python, and there's no Gramps debug-mode on Mac. (Gary Griffin, 2026-05-16.) +- **MUST**: never call `gi.require_version` in addon modules or test files. At runtime Gramps pins Gtk/Gdk before any plugin loads (`gramps/grampsapp.py`, `gramps/gen/constfunc.py`); under test, the pins live once in addons-source's **repo-root** `tests/__init__.py` (addons-source PR 950) — the per-addon `tests/__init__.py` stays empty, and tests run from the repository root so the pinned environment holds. Redundant pins MAY be removed from files already being touched. A module-level pin passes unit tests but breaks inside Gramps as soon as the hardcoded pin and the running version diverge — see [07-testing → The GTK-pin contract](07-testing.md#the-gtk-pin-contract). +- **SHOULD**: ship a regression test with every bug fix that **fails pre-fix and passes post-fix**. Doc-only PRs are the only exception. (At PR level this hardens to a MUST-with-escape — the test, or an explicit "no test because X" rationale; see *Contributor workflow*.) +- **SHOULD**: prefer `example.gramps`-backed tests over mocked DBs for DB-traversal logic — real data has cross-typed backlinks and ID-normalisation shapes that mocks don't reproduce. +- **MAY**: ship mocked unit tests alongside real-DB tests as complementary coverage. + +## Coding style + +**The coding standard is core's `../gramps/AGENTS.md`, in full — this section lists only the addon deltas.** Black, Python 3.10+ type hints (`X | None`, `list[X]`), Sphinx docstrings, import grouping with comment headers, class-header navigation comments, the `cb_` callback prefix, handle/ID types from `gramps.gen.types` — all are specified there and apply to addon Python unchanged. They are **not** restated below; anything this section is silent on follows core. The deltas are only these: + +- **Enforcement is advisory, so the core standard's coding MUSTs read as SHOULDs here.** addons-source runs no `black` / `mypy` / pylint gate — the reviewer weighs the standard; CI does not block on it. You **SHOULD** still run `black --check` before pushing, so the maintainer's cherry-pick forward to gramps61 stays clean. +- **Two rules are not softened — they stay MUST despite the lighter gate:** every new `.py` file carries a GPL-2.0-or-later license header with copyright, and every user-visible string is wrapped with `_()` (§Translation). +- **`gen`-self-containment, reframed.** Core's MUST that `gramps.gen.*` import no other submodule has no direct addon analog, but addon code **SHOULD** uphold the same discipline against itself: factor pure logic into modules that don't import `gramps.gui.*`, so it stays unit-testable without a display. + +## Contributor workflow + +- **MUST**: one logical fix per PR. Bundling hides mistakes. +- **MUST**: target the right branch — addon changes (`addons-source`) → `maintenance/gramps60`. The maintainer cherry-picks forward to `gramps61`. (Gary Griffin on addons-source PR 915, 2026-05-24.) A reviewer's instruction on a specific PR wins over the default targeting. (e.g. Nick-Hall on gramps#2299.) Core changes target a different branch — see the [Core Development — Rules](https://gramps-project.org/wiki/index.php/Gramps_6.1_Wiki_Manual_-_Core_Development_-_Rules) page. +- **MUST**: branch from `upstream/`, not the fork's tracking copy — fork bases drift (e.g. PRs 2315/2316 carried a stray `AGENTS.md` from the fork). +- **MUST NOT**: bump the addon's `version` field in an addons-source PR. The maintainer manages versions centrally. (Caught on PR 911, bug 12572.) +- **MUST**: a bug-fix PR includes a regression test, or an explicit "no test because X" rationale plus a manual repro. "Add the test later" is not an option. +- **MUST**: open the PR body with a **`**User impact:**`** line (before Root cause), then structure it **Summary / What to look at / Root cause / Fix / Verification**, citing `path:lines` on the branch the PR targets in the Verification "Checked" line (the #106 format). +- **MUST**: when the PR modifies an addon, **call out its current maintainer** — add an `## Affected addon` section to the PR body that **@-mentions the addon's current maintainer**, a heads-up so they are *aware* of the change and don't miss it. This is awareness, not attribution. "Current maintainer" = the addon's `.gpr.py` `maintainers` field when declared, otherwise its `authors` (an addon with no separate maintainer is maintained by its original author — Doug's "original developer (or contributors)" and Nick's "current maintainer" are the same role). The `.gpr.py` records names/emails, not GitHub handles — resolve a handle best-effort from the declared email so the mention notifies, and name the person when no handle resolves. (Raised on addons-source PR #946 — Doug Blank: *"otherwise I could miss fixes to my addons"*; Nick Hall: *"mention the current maintainer if one exists."*) +- **OPTIONAL**: reference the Mantis bug in the PR body **when one exists** — a Mantis reference is optional for addons-source, since many addon fixes have no Mantis ticket (they're tracked as fork GitHub issues, or are ticketless). addons-source also does not use the `Fixes #NNNN` commit-message trailer at all — that is the core convention; see the [Core Development — Rules](https://gramps-project.org/wiki/index.php/Gramps_6.1_Wiki_Manual_-_Core_Development_-_Rules) page. +- **MUST**: keep upstream-repo cross-references out of PR text and fork issues — reference *other* upstream PRs/issues in **plain text** ("upstream PR 949"), never a GitHub URL or `owner/repo#NNN` cross-ref (it back-links/notifies that thread). The `#nnnn` Mantis reference and the PR's own target are exempt. Authoritative: `docs/INTEGRATION.md` §"No upstream-repo links". +- **MUST NOT**: merge across branches. Rebase rather than merge — PRs with merge commits are rejected upstream. +- **MUST NOT**: cosmetically update in-flight upstream PRs. Parity, "rebase is clean," and "branch is behind" are not reasons to force-push. Push only when a specific correctness issue needs fixing. +- **SHOULD**: before writing any fix, check upstream isn't ahead — merged history on the target branch AND `master`, *plus* closed and rejected PRs on the *affected file* (not just the bug number). Closed PRs are signal: a closed-unmerged PR with the same fix shape is the maintainer's "no." +- **SHOULD**: if a PR already exists for the bug, verify it instead of duplicating. Merged → confirm-and-close; open → review and defer to the maintainer; closed → treat as the maintainer's "no." +- **SHOULD**: reproduce against `example.gramps` first — it's the canonical fixture and "couldn't reproduce" is the most common reason a fix stalls in triage. +- **MAY**: open as a draft PR for early review or to publish work-in-progress; mark ready when the change is complete and the author has re-read the diff with fresh eyes. + +## Verification before commit + +- **MUST**: find a test procedure before committing — local run, dry-run, snippet check. Never commit untested changes. +- **MUST**: treat a green mechanical check (lint, `git cherry-pick` applies, build green, `py_compile` exits 0) as evidence of *that narrow check*, not of correctness. Name what the check verified and what it left unverified. +- **MUST**: after pushing a PR branch, watch the PR's CI checks until they finish (e.g. `gh pr checks --watch`). Local pre-commit catches static checks only; test failures surface in CI's actual unit-test run. + +## Commit messages + +Commit messages are parsed by scripts that update Mantis BT and generate the ChangeLog / News files for releases. Formatting must be followed precisely. + +- **MUST**: the first line is a short summary, **≤ 70 characters**. +- **MUST**: the description is separated from the summary by a single blank line, and wrapped at **80 characters**. +- **MUST**: describe the change from the user's perspective. Don't recap the diff — `git diff` exists. +- **SHOULD**: use complete sentences in the description. +- **MUST**: reference another commit by its **full hash**, not a short hash. GitHub auto-hyperlinks full hashes; short hashes in brackets do not link. +- **MUST**: the Mantis trailer is on the **last** line of the commit message, separated from the description by a single blank line. + +### Mantis trailer keywords + +To **resolve** a bug (closes it on commit): + +``` +Fixes #12345 +Fixed #12345 +Resolves #12345 +Resolved #12345 +Fixes #12345, #67890 +``` + +To **link** to a bug (cross-reference without closing): + +``` +Bug #12345 +Issue #12345 +Report #12345 +Bugs #12345, #67890 +``` + +Bare numbers (no `#`) and URLs both miss the auto-link — use the `#NNNN` form. Note this is the opposite of the convention *inside* MantisBT itself, where `#NNNN` auto-links to another Mantis issue and bare numbers are preferred; here, inside Git commit messages and GitHub PR bodies, `#NNNN` is what hooks the MantisBT scripts. + +For the trailer to wire up on Mantis, the Git **author** or **committer** has to be a developer on the Mantis bug tracker. The Git name must match the Mantis username or real name, or the Git email must match the Mantis email. + +### addons-source: bug reference in PR body + +addons-source PRs don't use `Fixes #NNNN` in the commit message — that trailer is the core convention. A Mantis bug reference in the PR body is **optional**: include it when the fix has a Mantis ticket, but many addon fixes have none (fork GitHub issue, or ticketless), and those need no reference. A present-but-malformed reference is still wrong. + +## See also + +- [Overview](01-overview.md) +- [Fundamentals](04-fundamentals.md) +- [Testing](https://gramps-project.org/wiki/index.php/Gramps_6.0_Wiki_Manual_-_Addon_Testing) +- [Code analysis](10-code-analysis.md) +- [Packaging](12-packaging.md) +- `../gramps/AGENTS.md` — the full Python coding standard inherited here. +- [addons-source CONTRIBUTING.md](https://github.com/gramps-project/addons-source/blob/maintenance/gramps60/CONTRIBUTING.md) +- [Committing policies](https://www.gramps-project.org/wiki/index.php/Committing_policies) — upstream's commit-message + Mantis-trailer rules. diff --git a/docs/addon-development/17-roadmap.md b/docs/addon-development/17-roadmap.md new file mode 100644 index 000000000..bec8e65ff --- /dev/null +++ b/docs/addon-development/17-roadmap.md @@ -0,0 +1,106 @@ +# Roadmap + +[← Previous](16-guidelines.md) · [Index](01-overview.md) + +## Overview + +Forward-looking view of the addon-development surface — what's planned, what's in flight, what's slated for deprecation, and what open questions will eventually become rules. The audience is an addon author asking "what do I need to plan around?" + +This page is the **prospective** counterpart to [What's new](https://gramps-project.org/wiki/index.php/Gramps_6.0_Wiki_Manual_-_Addon_Development_-_Whats_New), which is retrospective. An item moves from this page to *What's new* once it ships in a release. + +## How to read this page + +Each entry should answer four things: + +| Field | Meaning | +|-------|---------| +| **Status** | proposed / accepted / in-flight / shipped / deferred / rejected | +| **Target** | Gramps version (`6.1`, `6.2`, ...) or "unscheduled" | +| **Impact** | what addon authors need to do (rewrite / opt-in / nothing) | +| **Tracking** | PR / Mantis bug / wiki RFC / mailing-list thread | + +A roadmap entry without a tracking link is a wish, not a plan; either add the link or move the entry to a separate "ideas" section. + +## In flight + + + +- _none recorded yet_ + +## Accepted but not yet implemented + + + +- _none recorded yet_ + +## Deprecations and removals + + + +- _none recorded yet_ + +## Open questions + + + +- _none recorded yet_ + +## Deferred / rejected + + + +- _none recorded yet_ + +## Documentation roadmap + +The doc set itself is in flight. Pages with `managed: false` front-matter are draft stubs and will not appear in published output until promoted. Current draft state — flip to `managed: true` page by page as content lands: + +### Publishing-pipeline conventions (now supported) + +What `md2wiki.py` and `md2pdf.py` handle as of 2026-05-30 — pages authored with these conventions render correctly in both wikitext and PDF output. Verified by running both pipelines on [Fundamentals](04-fundamentals.md) (which contains an SVG embed + Obsidian-internal links). + +| Convention | Where converted | Notes | +|------------|-----------------|-------| +| `![[_media/foo.svg\|cap]]` Obsidian embed | `mdcommon.convert_obsidian_embeds` | Becomes `![cap](_media/foo.svg)` before pandoc | +| `[[Page]]` / `[[Page\|label]]` Obsidian-internal link | `mdcommon.convert_obsidian_internal_links` | Resolved via `mdcommon.build_title_map` (filename-stem → wiki title); unresolved targets error loudly | +| Markdown image with SVG src in PDF | `_preconvert_svgs` (md2pdf) | Pre-converted to PDF via `rsvg-convert` or `inkscape`; embeds natively in xelatex | +| Markdown image with relative path in PDF | `--resource-path` to pandoc | Resolved against the source file's directory | +| `[[File:_media/foo.svg]]` post-pandoc wikitext | `mdcommon.basenameify_file_refs` | Becomes `[[File:foo.svg]]` (MediaWiki's File: namespace is flat) | +| Media files alongside pages | `wikitransport.upload_if_changed` + `publish.upload_media_for` | SHA-1 dedup; uploaded BEFORE the page edit so refs never render red | +| HTML comments | `mdcommon.stash_html_comments` | Stashed around Obsidian preprocessors so syntax inside comments is not rewritten | + +What the pipeline already handled before these additions: +- `[label](wiki:Page_Name)` → wikitext `[[Page|label]]` / PDF anchor or external URL. +- `` template shims → raw `{{...}}` wikitext / dropped from PDF. +- YAML front-matter → `title`, `categories`, `managed`. +- Fenced code with language tags, tables. + +### Page-by-page state + +The section is substantive across all seventeen pages. Open deepening work: + +- [Tutorials](02-tutorials.md) — the screenshots for each tutorial's "Try it" closer are pending capture. +- [Data access](05-data-access.md) — worked examples for some API touch-points are still thin. +- [API Reference](06-api-reference.md) — needs periodic re-synchronisation against `gramps/gen/__init__.py` on the maintenance branch this manual targets. + +## See also + +- [What's new](https://gramps-project.org/wiki/index.php/Gramps_6.0_Wiki_Manual_-_Addon_Development_-_Whats_New) — retrospective counterpart. +- [Compatibility](14-compatibility.md) — porting guidance once an item ships. +- [Mantis bug tracker](https://gramps-project.org/bugs) — feature requests and design discussions originate here. +- [Gramps mailing lists](https://gramps-project.org/contact/) — where larger design questions get hashed out. diff --git a/docs/addon-development/README.md b/docs/addon-development/README.md new file mode 100644 index 000000000..95686ba8a --- /dev/null +++ b/docs/addon-development/README.md @@ -0,0 +1,24 @@ +# Gramps Addon Development manual + +The addon authors' manual for Gramps. Start at +[01-overview.md](01-overview.md) — the overview and section map. + +## Pages + +- [Addon Development](01-overview.md) +- [Tutorials](02-tutorials.md) +- [Addon Kinds](03-addon-kinds.md) +- [Fundamentals](04-fundamentals.md) +- [Data access](05-data-access.md) +- [API Reference](06-api-reference.md) +- [Testing](07-testing.md) +- [Debug](08-debug.md) +- [Troubleshoot](09-troubleshoot.md) +- [Code Analysis](10-code-analysis.md) +- [Internationalization](11-internationalization.md) +- [Packaging](12-packaging.md) +- [Community](13-community.md) +- [Compatibility](14-compatibility.md) +- [What's New](15-whats-new.md) +- [Rules](16-guidelines.md) +- [Roadmap](17-roadmap.md) diff --git a/docs/addon-development/_media/addon-kinds-ui-map.svg b/docs/addon-development/_media/addon-kinds-ui-map.svg new file mode 100644 index 000000000..f5507e356 --- /dev/null +++ b/docs/addon-development/_media/addon-kinds-ui-map.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + Gramps main window — where each addon kind appears + + + + + + + + + + File + + IMPORT / EXPORT + + + Edit + + + View + + + Reports + + REPORT + + + Tools + + TOOL + + + Windows + Help + + + + + [ toolbar ] + + + + + Navigator + ▸ Dashboard + ▸ People + ▸ Relationships + ▸ Families + ▸ Events + ▸ Places + ▸ Geography + ▸ Sources + ▸ Citations + ▸ Repositories + ▸ Media + ▸ Notes + + + + Main view area + (content varies by selected Navigator category) + + + + I0001 John Doe 1850– + + I0002 Jane Smith 1853– + + I0003 ... + + + + + Sidebar + + [ gramplet ] + + [ gramplet ] + + [ gramplet ] + + + + Bottombar + + [ gramplet ] + + [ gramplet ] + + [ gramplet ] + + [ gramplet ] + + + + + + SIDEBAR + — one per Navigator category + + + + + VIEW + — alternative way to browse a category + + + + + QUICKVIEW + — right-click context menu on a row + + + + + RULE + — Edit ▸ Person Filter Editor ▸ Add Rule + + + + + MAPSERVICE + — Geography view tile source + + + + + GRAMPLET + — Dashboard, sidebar, or bottombar widget + + + + + + Kinds with no direct UI surface + + DOCGEN — output format used by reports + DATABASE — backend selected at tree creation + RELCALC — used by Relationships view (per locale) + THUMBNAILER — media thumbnail generator + CITE — citation formatter style + GENERAL — shared library / pluggable category + + + Schematic — relative positions match Gramps 6.0's default layout; not pixel-accurate. See chapter 04-addon-kinds for the registration constants and base classes per kind. + diff --git a/docs/addon-development/_media/data-model.dot b/docs/addon-development/_media/data-model.dot new file mode 100644 index 000000000..dae803fc4 --- /dev/null +++ b/docs/addon-development/_media/data-model.dot @@ -0,0 +1,80 @@ +// Gramps primary objects and the most-traversed relationships. +// Regenerate the SVG with: +// dot -Tsvg data-model.dot -o data-model.svg +// +// Convention: +// - Solid arrow with no label = direct handle list +// - Solid arrow labelled "Ref" = goes through a ref object +// carrying metadata (Role, child +// relation, etc.) +// - Dashed arrow = reverse direction reached via +// db.find_backlink_handles() +// +// Notes and Tags can be attached to any primary object; omitted from +// the diagram to keep arrows readable, called out in the caption. + +digraph data_model { + rankdir=LR + bgcolor="transparent" + pad=0.25 + nodesep=0.5 + ranksep=0.9 + splines=true + + node [ + shape=box, + style="rounded,filled", + fillcolor="#eef2f7", + color="#4a5b6e", + fontname="Helvetica", + fontsize=11, + margin="0.18,0.10" + ] + edge [ + fontname="Helvetica", + fontsize=9, + color="#4a5b6e", + fontcolor="#4a5b6e" + ] + + // ---- Primary objects ---- + person [label="Person"] + family [label="Family"] + event [label="Event"] + place [label="Place"] + citation [label="Citation"] + source [label="Source"] + repository [label="Repository"] + media [label="Media"] + + // ---- Person <-> Family (two role-distinct relationships) ---- + person -> family [label=" parent_of\n family_handle_list "] + family -> person [label=" ChildRef ", style=solid] + + // ---- Events via EventRef (carries Role) ---- + person -> event [label=" EventRef\n (Role) "] + family -> event [label=" EventRef\n (Role) "] + + // ---- Places ---- + event -> place [label=" place_handle "] + place -> place [tailport="s", headport="s", label=" enclosed_by "] + + // ---- Sourcing chain ---- + person -> citation [label=" CitationRef "] + family -> citation [label=" CitationRef "] + event -> citation [label=" CitationRef "] + place -> citation [label=" CitationRef "] + citation -> source [label=" source_handle "] + source -> repository [label=" RepoRef "] + + // ---- Media ---- + person -> media [label=" MediaRef "] + event -> media [label=" MediaRef "] + source -> media [label=" MediaRef "] + + // ---- Layout hints to control column order ---- + { rank=same; person; family } + { rank=same; citation; media } + { rank=same; source } + { rank=same; repository } +} diff --git a/docs/addon-development/_media/data-model.svg b/docs/addon-development/_media/data-model.svg new file mode 100644 index 000000000..119275175 --- /dev/null +++ b/docs/addon-development/_media/data-model.svg @@ -0,0 +1,168 @@ + + + + + + +data_model + + +person + +Person + + + +family + +Family + + + +person->family + + +  parent_of +  family_handle_list   + + + +event + +Event + + + +person->event + + +  EventRef +  (Role)   + + + +citation + +Citation + + + +person->citation + + +  CitationRef   + + + +media + +Media + + + +person->media + + +  MediaRef   + + + +family->person + + +  ChildRef   + + + +family->event + + +  EventRef +  (Role)   + + + +family->citation + + +  CitationRef   + + + +place + +Place + + + +event->place + + +  place_handle   + + + +event->citation + + +  CitationRef   + + + +event->media + + +  MediaRef   + + + +place:s->place:s + + +  enclosed_by   + + + +place->citation + + +  CitationRef   + + + +source + +Source + + + +citation->source + + +  source_handle   + + + +repository + +Repository + + + +source->repository + + +  RepoRef   + + + +source->media + + +  MediaRef   + + + diff --git a/docs/addon-development/_media/packaging-pipeline.dot b/docs/addon-development/_media/packaging-pipeline.dot new file mode 100644 index 000000000..666835231 --- /dev/null +++ b/docs/addon-development/_media/packaging-pipeline.dot @@ -0,0 +1,85 @@ +// Three-repo packaging pipeline: addons-source -> make.py -> addons -> user. +// Regenerate the SVG with: +// dot -Tsvg packaging-pipeline.dot -o packaging-pipeline.svg + +digraph packaging_pipeline { + rankdir=LR + bgcolor="transparent" + pad=0.25 + nodesep=0.4 + ranksep=0.55 + + node [ + shape=box, + style="rounded,filled", + fillcolor="#eef2f7", + color="#4a5b6e", + fontname="Helvetica", + fontsize=11, + margin="0.18,0.10" + ] + edge [ + fontname="Helvetica", + fontsize=9, + color="#4a5b6e", + fontcolor="#4a5b6e" + ] + + // Repos and stages. + subgraph cluster_source { + label="addons-source repository" + labelloc="b" + fontname="Helvetica" + fontsize=10 + fontcolor="#4a5b6e" + color="#9aacc0" + style="rounded,dashed" + margin=10 + + source [label="MyAddon/\nMyAddon.gpr.py\nMyAddon.py\npo/\ntests/"] + } + + make [ + label="make.py gramps60 build MyAddon\n(compile po, package files)", + shape=box, + style="filled", + fillcolor="#dfe7f1" + ] + listing [ + label="make.py gramps60 listing MyAddon\n(refresh listings JSON)", + shape=box, + style="filled", + fillcolor="#dfe7f1" + ] + + subgraph cluster_addons { + label="addons repository" + labelloc="b" + fontname="Helvetica" + fontsize=10 + fontcolor="#4a5b6e" + color="#9aacc0" + style="rounded,dashed" + margin=10 + + tgz [label="gramps60/download/\nMyAddon.addon.tgz"] + listings [label="gramps60/listings/\n*.json"] + } + + manager [label="Gramps in-app\naddon manager"] + user [ + label="User plugin dir\n~/.local/share/gramps/\ngramps60/plugins/MyAddon/", + fillcolor="#e6efe2", + color="#5a7251" + ] + + // Edges. + source -> make [label=" author edits "] + source -> listing [style=invis] // keep alignment + make -> tgz [label=" build "] + make -> listing [style=dotted, arrowhead=none] + listing -> listings [label=" listing "] + tgz -> manager [label=" HTTPS fetch "] + listings -> manager [label=" HTTPS fetch "] + manager -> user [label=" install /\n update "] +} diff --git a/docs/addon-development/_media/packaging-pipeline.svg b/docs/addon-development/_media/packaging-pipeline.svg new file mode 100644 index 000000000..837a972a5 --- /dev/null +++ b/docs/addon-development/_media/packaging-pipeline.svg @@ -0,0 +1,124 @@ + + + + + + +packaging_pipeline + +cluster_source + +addons-source repository + + +cluster_addons + +addons repository + + + +source + +MyAddon/ +MyAddon.gpr.py +MyAddon.py +po/ +tests/ + + + +make + +make.py gramps60 build MyAddon +(compile po, package files) + + + +source->make + + +  author edits   + + + +listing + +make.py gramps60 listing MyAddon +(refresh listings JSON) + + + + +make->listing + + + + +tgz + +gramps60/download/ +MyAddon.addon.tgz + + + +make->tgz + + +  build   + + + +listings + +gramps60/listings/ +*.json + + + +listing->listings + + +  listing   + + + +manager + +Gramps in-app +addon manager + + + +tgz->manager + + +  HTTPS fetch   + + + +listings->manager + + +  HTTPS fetch   + + + +user + +User plugin dir +~/.local/share/gramps/ +gramps60/plugins/MyAddon/ + + + +manager->user + + +  install / +  update   + + + diff --git a/docs/addon-development/_media/plugin-discovery.dot b/docs/addon-development/_media/plugin-discovery.dot new file mode 100644 index 000000000..bb0179072 --- /dev/null +++ b/docs/addon-development/_media/plugin-discovery.dot @@ -0,0 +1,40 @@ +// Plugin discovery and load sequence. +// Regenerate the SVG with: +// dot -Tsvg plugin-discovery.dot -o plugin-discovery.svg + +digraph plugin_discovery { + rankdir=TB + bgcolor="transparent" + pad=0.2 + nodesep=0.4 + ranksep=0.5 + + node [ + shape=box, + style="rounded,filled", + fillcolor="#eef2f7", + color="#4a5b6e", + fontname="Helvetica", + fontsize=11, + margin="0.18,0.10" + ] + edge [ + fontname="Helvetica", + fontsize=9, + color="#4a5b6e", + fontcolor="#4a5b6e" + ] + + startup [label="Gramps startup"] + reg [label="reg_plugins(plugin_dir)\nrecursive scan\n(follows symlinks since 6.1)"] + gpr [label="Load each *.gpr.py\n(top-level register() calls execute)"] + catalog [label="Plugin catalog\nname, id, kind, target version\n(implementation module NOT loaded yet)"] + invoke [label="User invokes the addon\n(menu, sidebar, restart, ...)"] + load [label="Load fname module\nInstantiate the registered class"] + + startup -> reg + reg -> gpr [label=" for each\n addon folder"] + gpr -> catalog [label=" register(...)"] + catalog -> invoke [style=dashed, label=" later,\n on demand", constraint=false] + invoke -> load +} diff --git a/docs/addon-development/_media/plugin-discovery.svg b/docs/addon-development/_media/plugin-discovery.svg new file mode 100644 index 000000000..185d4468c --- /dev/null +++ b/docs/addon-development/_media/plugin-discovery.svg @@ -0,0 +1,90 @@ + + + + + + +plugin_discovery + + +startup + +Gramps startup + + + +reg + +reg_plugins(plugin_dir) +recursive scan +(follows symlinks since 6.1) + + + +startup->reg + + + + + +gpr + +Load each *.gpr.py +(top-level register() calls execute) + + + +reg->gpr + + +  for each +  addon folder + + + +catalog + +Plugin catalog +name, id, kind, target version +(implementation module NOT loaded yet) + + + +gpr->catalog + + +  register(...) + + + +invoke + +User invokes the addon +(menu, sidebar, restart, ...) + + + +catalog->invoke + + +  later, +  on demand + + + +load + +Load fname module +Instantiate the registered class + + + +invoke->load + + + + + From 813e841ec60041b4da51c882c7d105556a6a6f97 Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Mon, 20 Jul 2026 01:58:38 +0200 Subject: [PATCH 52/65] Point README and CONTRIBUTING at the in-repo addon manual README: the develop-your-own-addon pointer now leads to the in-repo docs/addon-development manual first, with CONTRIBUTING.md for the contributor workflow and the wiki page as an alternative rendering. The dead Travis badge is dropped. CONTRIBUTING: the deep technical sections that the manual now covers - addon kinds, registration and GENERAL plugins, prerequisites, addon configuration, localization, distribution contents, report categories, and the wiki listing/documentation templates - are reduced to a short retained summary plus a link into the manual, each under its original heading so existing deep links keep resolving. The contributor-workflow content that is unique to this document - repository and fork setup, development branches, the addon checklist, the pull-request walkthrough, and the maintenance guidance - is kept in place unchanged. Also repairs pre-existing broken links: seven table-of-contents and overview anchors that never matched their headings, two (#https://...) hrefs, the garbled Addon-list-legend link, and the Localization snippet that had lost its underscore binding. Depends on the PR that adds docs/addon-development. --- CONTRIBUTING.md | 619 +++++++++++------------------------------------- README.md | 4 +- 2 files changed, 145 insertions(+), 478 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dda23171b..702156efa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,13 +23,21 @@ If you're looking for *existing* addons to install, see If you're looking to contribute to Gramps directly, see [Portal:Developers](https://gramps-project.org/wiki/index.php/Getting_started_with_Gramps_development). +This document is the contributor **workflow** guide: repository setup, the +development loop, the checklist, and the pull-request process. The full +technical reference is the in-repo +[Addon Development manual](docs/addon-development/README.md) — seventeen +pages from a first Gramplet through testing, debugging, packaging, and +cross-version compatibility. Where a section below has a deeper counterpart +in the manual, it links there instead of repeating it. + ## Table of Contents * [What Can Addons Extend?](#what-can-addons-extend) * [Overview of Writing an Addon](#overview-of-writing-an-addon) * [Develop Your Addon](#develop-your-addon) - * [Addons Source Code Repository](#addon-source-code-repository) + * [Addons Source Code Repository](#addons-source-code-repository) * [Addons Download Repository](#addons-download-repository) - * [Set Up a Github Account](#setup-a-github-account) + * [Set Up a Github Account](#set-up-a-github-account) * [Create Project Forks in Github](#create-project-forks-in-github) * [Set Up Addon Development Environment](#set-up-addon-development-environment) * [Gramps Repository](#gramps-repository) @@ -50,7 +58,7 @@ If you're looking to contribute to Gramps directly, see * [Review the Addon Checklist](#review-the-addon-checklist) * [Create a Pull Request](#create-a-pull-request) * [Commit Your Changes](#commit-your-changes) - * [Verify Your Addon Is Current](#verify your addon is current) + * [Verify Your Addon Is Current](#verify-your-addon-is-current) * [Push To Your Fork](#push-to-your-fork) * [Create the PR](#create-the-pr) * [Work Towards a Merge](#work-towards-a-merge) @@ -60,47 +68,21 @@ If you're looking to contribute to Gramps directly, see * [List Your Addon](#list-your-addon) * [Document Your Addon](#document-your-addon) * [Support Your Addon Through Bug Tracker](#support-your-addon-through-bug-tracker) -* [Maintain Your Addon Code as Gramps Evolves](#maintain-your-addon-code-as-gramps-evolves) +* [Maintain Your Addon Code as Gramps Evolves](#maintain-your-addon-as-gramps-evolves) * [Resources](#resources) * [Addon Development Tutorials and Samples](#addon-development-tutorials-and-samples) * [Addons External to Github](#addons-external-to-github) ## What Can Addons Extend? - -Addons for Gramps can extend the program in many different ways. You can -add any of the following [types](https://github.com/gramps-project/gramps/blob/master/gramps/gen/plug/_pluginreg.py) of addons: - -* **Importer** (IMPORT) - adds additional file format import options to Gramps -* **Exporter** (EXPORT) - adds additional file format export options to Gramps -* **[Gramplet](https://gramps-project.org/wiki/index.php/Gramps_Glossary#gramplet)** (GRAMPLET) - adds a new -interactive interface section to a Gramps view mode, which can be -activated by right-clicking on the dashboard View or from the menu -of the Sidebar/Bottombar in other view categories. -* **Gramps** [**View mode**](https://gramps-project.org/wiki/index.php/Gramps_Glossary#viewmode) (VIEW) - adds a -new view mode to the list of views available within a -[View Category](https://gramps-project.org/wiki/index.php/Gramps_Glossary#view) -* **[Map Service](https://gramps-project.org/wiki/index.php/[Map_Services)** -(MAPSERVICE) - adds new mapping options to Gramps -* **Plugin lib** (GENERAL) - libraries that provide extra functionality when -present; can add, replace and/or modify builtin Gramps options. -* **[Quickreport/Quickview](https://gramps-project.org/wiki/indew/Gramps_6.0_Wiki_Manual_-_Reports_-_part_8#Quick_Views)** (QUICKREPORT) - a view -that you can run by right-clicking on an object, or if a person quickview, -then via the Quick View Gramplet -* **[Report](https://gramps-project.org/wiki/index.php/Gramps_6.0_Wiki_Manual_-_Reports_-_part_1)** (REPORT) - adds a new output report; this includes -**Website** that outputs a static genealogy website based on your Gramps -Family Tree data. -* **[Rule](https://gramps-project.org/wiki/index.php/Gramps_6.0_Wiki_Manual_-_Filters#Add_Rule_dialog)** (RULE) - adds new -[filter](https://gramps-project.org/wiki/index.php/Gramps_Glossary#filter) -rules. New starting with Gramps 5.1. -* **[Tool](https://gramps-project.org/wiki/index.php/Gramps_6.0_Wiki_Manual_-_Tools)** (TOOL) - adds a utility that helps process data from your family tree. -* **Doc creator** (DOCGEN) -* **Relationships** (RECALC) -* **Sidebar** (SIDEBAR) -* **[Database](https://gramps-project.org/wiki/index.php/Database_Backends)** -(DATABASE) - add support for another database backend. New starting with -Gramps 5.0. -* **Thumbnailer** (THUMBNAILER) New starting with Gramps 5.2 -* **Citation formatter** (CITE) New starting with Gramps 5.2 +Addons can extend Gramps at almost every plugin point: Importers and +Exporters, Gramplets, View modes, Map Services, plugin libraries (GENERAL), +Quickviews, Reports, filter Rules, Tools, document generators (DOCGEN), +relationship calculators, Sidebars, Database backends, Thumbnailers, and +Citation formatters. + +The full catalogue — with the registration constant, UI location, base +class, and per-kind notes for each — is in the manual: +[Addon Kinds](docs/addon-development/03-addon-kinds.md). ## Overview of Writing an Addon Writing an addon is fairly straightforward if you have a bit of Python @@ -110,11 +92,11 @@ general steps to writing and sharing your own addons are: * [Develop your addon](#develop-your-addon) * [Create a Gramps plugin registration file](#create-a-gramps-plugin-registration-file) - e.g., a file named ```my-addon.gpr.py``` * [Review the Addon Checklist](#review-the-addon-checklist) -* [Create a Pull Request for your addon](#create-pr) -* [Announce it on the Gramps forum](#announce-the-addon) - Let users +* [Create a Pull Request for your addon](#create-a-pull-request) +* [Announce it on the Gramps forum](#announce-your-addon) - Let users know it exists and how to use it. -* [Support it through the issue tracker](#support-it-through-issue-tracker) -* [Maintain the code](#maintain-the-code-as-gramps-continues-to-evolve) as +* [Support it through the issue tracker](#support-your-addon-through-bug-tracker) +* [Maintain the code](#maintain-your-addon-as-gramps-evolves) as the Gramps code continues to evolve We'll now expand on each of these steps. @@ -463,24 +445,24 @@ see [Writing a Plugin](https://gramps-project.org/wiki/index.php/Writing_a_Plugin) specifically. +The manual walks these end-to-end: +[Tutorials](docs/addon-development/02-tutorials.md) builds one addon per +kind, and [Fundamentals](docs/addon-development/04-fundamentals.md) covers +the cross-cutting basics every kind shares. ### Test Your Addon As You Develop -To test your addon as you develop, we recommend you copy your -```NewProjectName``` plugin into your Gramps user plugin directory -from your addon development directory, prior to testing. Or, just -edit in the Gramps user plugin directory until it is ready to publish, -then copy back to your addon development directory. Your installed Gramps -desktop application will search this folder (and subdirectories) for -any ```.gpr.py``` files, and add them to the plugin list. - -You can of course still use the ```git``` branch for your addon to store -intermediate steps and other work in progress. - -> #### Warning -> [Bug #10436](https://gramps-project.org/bugs/view.php?id=10436) -> Symbolic links to folders in the gramps plugin directory are not scanned, so -> you cannot just create a symbolic link pointing to your addon source tree; -> you will have to copy it. +To test your addon as you develop, copy your ```NewProjectName``` folder +into the Gramps user plugin directory and restart Gramps — plugin discovery +happens at startup. On Gramps 6.0, discovery does **not** follow symbolic +links ([Bug #10436](https://gramps-project.org/bugs/view.php?id=10436)), so +a physical copy is required; Gramps 6.1 and later follow symlinks, so you +can link your working tree in once and edit in place. + +The manual covers the full development loop — where addons live, the +restart cycle, and a first working Gramplet — in +[the overview](docs/addon-development/01-overview.md#where-addons-live), +and how to test logic without launching the GUI in +[Testing](docs/addon-development/07-testing.md). If you have code that you want to share between addons, you don't need to do anything special. Gramps adds each directory in which a ```.gpr.py``` @@ -490,123 +472,42 @@ should always make sure you name your addons with a name appropriate for Python imports. ### Addon Configuration -Some addons may want to have persistent data (data settings that remain -between sessions). You can handle this yourself, or you can use Gramps' -builtin configure system. - -At the top of the source file for your addon, you would do this: -``` - from config import config as configman - config = configman.register_manager("grampletname") - # register the values to save: - config.register("section.option-name1", value1) - config.register("section.option-name2", value2) - ... - # load an existing file, if one: - config.load() - # save it, it case it didn't exist: - config.save() -``` - -This will create the file ```grampletname.ini``` and put it in the same -directory as the addon. If the config file already exists, it remains intact. -The natural location for ```.ini``` files is in the directory in which -the addon is installed; using the main ```gramps.ini``` file for addon -preferences could potentially lead to a conflict between addons. Other -locations and file formats are possible. See -[The Gramps architect recommends leaving this decision to the addon developer](https://gramps.discourse.group/t/add-option-for-boolean-options-in-gramplet/6371/19). - -In the addon, you can then: -``` - x = config.get("section.option-name1") - config.set("section.option-name1", 3) -``` - -and when this code is exiting, you might want to save the config. In a -Gramplet that would be: -``` - def on_save(self): - config.save() -``` - -If your code is a system-level file, then you might want to save the -config in the Gramps system folder: -``` - config = configman.register_manager("system", use_config_path=True) -``` - -This is rare; most ```.ini``` files go into the plugins directory. - -In other code that might use this config file, you would do this: -``` - from config import config as configman - config = configman.get_manager("grampletname") - x = config.get("section.option-name1") -``` +Some addons want persistent settings that survive between sessions. Use +Gramps' builtin configuration manager (```configman```) rather than rolling +your own file handling — the addon's ```.ini``` file lands in the addon's +own directory, so it cannot conflict with ```gramps.ini``` or with other +addons ([the Gramps architect recommends leaving the location decision to +the addon developer](https://gramps.discourse.group/t/add-option-for-boolean-options-in-gramplet/6371/19)). + +The full pattern — registering keys, load/save, the rare +```use_config_path``` system-folder case, and reading another addon's +settings with ```get_manager``` — is in the manual: +[Fundamentals → Configuration and persistent settings](docs/addon-development/04-fundamentals.md#configuration-and-persistent-settings). ### Localization - -> #### Note -> These instructions will only work for Python strings. If you have a -> glade file, it will not get translated. - -For general help with translations for Gramps, see -[Coding for translation](https://gramps-project.org/wiki/index.php/Coding_for_translation). However, that will only use translations that come with Gramps, -or allow you to contribute translations to the Gramps core. To have your own -managed translations that will be packaged with your addon, you will need to -add a way to retrieve the translation. Add the following to the top of your -```NewProjectName.py``` file: +Wrap every user-visible string in ```_()```, and bind the addon's own +translation catalog at the top of each implementation module: ``` from gramps.gen.const import GRAMPS_LOCALE as glocale - = glocale.get_addon_translator(__file__).gettext -``` -Then you can use the standard "```_()```" function to translate phrases in -your addon. - -You can use one of a few different types of translation functions: + _ = glocale.get_addon_translator(__file__).gettext ``` - gettext - lgettext - ngettext - lngettext - sgettext -``` - -These are obsolete starting in Gramps 4.x; ```gettext```, ```ngettext```, and -```sgettext``` always return translated strings in Unicode for consistent -portability between Python2 and Python3. - -See the [Python documentation](http://docs.python.org/3/library/gettext.html#the-gnutranslations-class) for using ```gettext``` and ```ngettext```. The -"l" versions return the string encoded according to the -[currently set locale](http://docs.python.org/3/library/locale.html#locale.setlocale); -the "u" versions return Unicode strings in Python2 and are no longer available -in Python3. +Glade files are **not** extracted automatically — mark their strings +translatable and override the labels at runtime in Python. -The method ```sgettext``` should always be used; it is a Gramps extension -that filters out clarifying comments for translators, such as in -```_("Remaining names | rest")``` where "rest" is the English string that -we want to present and "Remaining names" is a hint for translators. +The full workflow — string marking rules, plural forms, context prefixes +(```_("Remaining names|rest")```), ```.pot```/```.po``` generation with +```make.py```, and Weblate — is in the manual: +[Internationalization](docs/addon-development/11-internationalization.md). ### Files Included in Addon Distribution -The process that creates the compressed tar file that the Gramps Download -Manager installs in Gramps to use your addon automatically includes the -following files: -``` - *.py - *.glade - *.xml - *.txt - locale/*/LC_MESSAGES/*.mo -``` -Starting with Gramp 5.0, if you have files other than those listed above, -you should create a ```MANIFEST``` file in the root of your addon folder -that lists the files (or pattern) to be added, one per line, like this -sample ```MANIFEST``` file: -``` - README.md - extra_dir/* - help_files/docs/help.html -``` +The build automatically packages ```*.py```, ```*.glade```, ```*.xml```, +```*.txt```, and ```locale/*/LC_MESSAGES/*.mo```. Anything else (a +```README.md```, help files, extra directories) needs a ```MANIFEST``` +file in the addon root, one file or glob pattern per line (Gramps 5.0+). + +The build flow, ```MANIFEST``` semantics, and what lands in the +```.addon.tgz``` are in the manual: +[Packaging → What build packages](docs/addon-development/12-packaging.md#what-build-packages). > #### TIP > Starting with Gramps 6.0 (and _only_ 6.0) translations can be done on @@ -629,280 +530,65 @@ takes this general form: [PTYPE](https://github.com/gramps-project/gramps/blob/master/gramps/gen/plug/_pluginreg.py#L76) values include: TOOL, GRAMPLET, REPORT, QUICKVIEW (formerly QUICKREPORT), IMPORT, EXPORT, DOCGEN, GENERAL, MAPSERVICE, VIEW, RELCALC, SIDEBAR, DATABASE, RULE, -THUMBNAILER, and CITE. - -ATTR depends on the PTYPE. +THUMBNAILER, and CITE. ATTR depends on the PTYPE. -You must include a ```gramps_target_version``` and addon ```version``` values. -```gramps_target_version``` should be a string of the form "X.Y" matching -a Gramps X (major) and Y (minor) version. ```version``` is a string of -the form "X.Y.Z" representing the version of your addon; X, Y, and Z should -all be integers. +You must include ```gramps_target_version``` (a string "X.Y" matching the +Gramps major and minor version the addon targets) and the addon +```version``` (a string "X.Y.Z"). Include author name(s) and email(s) as +arrays of strings, and — new in Gramps 5.2 — optionally ```maintainers``` +/ ```maintainers_email``` when the maintainer differs from the author; the +maintainer is the primary point of contact. -Be sure to include attributes for author name(s) and email(s) in the form -of an array of comma-separated strings. +In the ```.gpr.py```, the function ```_``` is predefined by the plugin +loader to use your locale translations — mark text with ```_("TEXT")```, +never import ```_``` there. -There is an additional set of attributes, ```maintainers``` and -```maintainers_email``` (new in Gramps 5.2). If you, the author, are also -the maintainer it will be identical to the author attributes, but you may -also designate a maintainer, in which case the maintainer will become the -primary point of contact. - -Here is a sample Tool GPR file: -``` - register(TOOL, - id = 'AttachSource', - name = _("Attach Source"), - description = _("Attaches a shared source to multiple objects."), - version = '1.0.0', - gramps_target_version = '6.0', - status = STABLE, - fname = 'AttachSourceTool.py', - authors = ["Douglas S. Blank"], - authors_email = ["doug.blank@gmail.com"], - maintainers = ["Douglas S. Blank"], - maintainers_email = ["doug.blank@gmail.com"], - category = TOOL_DBPROC, - toolclass = 'AttachSourceWindow', - optionclass = 'AttachSourceOptions', - tool_modes = [TOOL_MODE_GUI], - help_url = "Addon:AttachSourceTool" - ) -``` - -You can see examples of the kinds of addons -[here](https://github.com/gramps-project/gramps/plugins) (such as -[gramps-project/gramps/plugins/drawreport/drawplugins.gpr.py](https://github.com/gramps-project/gramps/plugins/drawreport/drawplugins.gpr.py)) -and see the full documentation in the -[master/gramps/gen/plug/_pluginreg.py][https://github.com/gramps-project/gramps/blob/3f0db9303f29811b43325c30149c8844c7ce24b6/gramps/gen/plug/_pluginreg.py#L23) -comments and docstrings. - -Note that this example ```.gpr.py``` will automatically use translations -if you have them (see [Localization](#localization)). That is, the -function "_" is predefined to use your -locale translations; you only need to mark the text with ```_("TEXT")``` -and include a translation of "TEXT" in your translation file. For example, -in the above example, ```_("Attach Source")``` is marked for translation. -If you have developed and packaged your addon with translation support, -then that phrase will be converted into the user's language. +The manual documents every registration field, the discovery model, and a +complete example per kind: +[Fundamentals → The .gpr.py registration file](docs/addon-development/04-fundamentals.md#the-gprpy-registration-file) +and [Addon Kinds](docs/addon-development/03-addon-kinds.md). ### Report Plugins -The possible report categories are -[gramps/gen/plug/_pluginreg.py](https://github.com/gramps-project/gramps/blob/892fc270592095192947097d22a72834d5c70447/gramps/gen/plug/_pluginreg.py#L141-L149): -``` - #possible report categories - CATEGORY_TEXT = 0 - CATEGORY_DRAW = 1 - CATEGORY_CODE = 2 - CATEGORY_WEB = 3 - CATEGORY_BOOK = 4 - CATEGORY_GRAPHVIZ = 5 - CATEGORY_TREE = 6 - REPORT_CAT = [ CATEGORY_TEXT, CATEGORY_DRAW, CATEGORY_CODE, - CATEGORY_WEB, CATEGORY_BOOK, CATEGORY_GRAPHVIZ, CATEGORY_TREE] -``` - -Each report category has a set of standards and an interface. The categories -```CATEGORY_TEXT``` and ```CATEGORY_DRAW``` use the Document interface of -Gramps. See also -[Report API](https://gramps-project.org/wiki/index.php/Report_API) -for a draft view on this. - -The application programming interface or API for reports is treated in the -[report writing tutorial](https://gramps-project.org/wiki/index.php/Report-writing_tutorial). For general information on Gramps development, see -[Portal:Developers](https://gramps-project.org/wiki/index.php/Portal:Developers) -and [Writing a Plugin](https://gramps-project.org/wiki/index.php/Writing_a_plugin). +A REPORT registration declares one of the report categories +(```CATEGORY_TEXT```, ```CATEGORY_DRAW```, ```CATEGORY_WEB```, and so on — +defined in ```gramps/gen/plug/_pluginreg.py```); the text and draw +categories use Gramps' Document interface. + +The manual covers the Report/ReportOptions pair, the docgen abstraction, +and a complete text-report walkthrough: +[Addon Kinds → REPORT](docs/addon-development/03-addon-kinds.md) and +[Tutorials → A text Report](docs/addon-development/02-tutorials.md#a-text-report). +See also the +[report writing tutorial](https://gramps-project.org/wiki/index.php/Report-writing_tutorial) +on the wiki. ### General Plugins -The plugin framework also allows you to create generic plugins for use. -This includes the ability to create libraries of functions, and plugins -of your own design. - -#### Example: A library of functions -In this example, a file named ```library.py``` will be imported at the -time of registration (i.e., any time Gramps starts): -``` - # file: library.gpr.py - - register(GENERAL, - id = 'My Library', - name = _("My Library"), - description = _("Provides a library for doing something."), - version = '1.0', - gramps_target_version = '6.0', - status = STABLE, - fname = 'library.py', - load_on_reg = True, - ) -``` - -You can access the loaded module in other code by issuing an -```import library``` as Python keeps track of files already -imported. However, the amount of useful code that you can run -when the program is imported is limited. You might like to have -the code do something that requires a ```dbstate``` or ```uistate object```, -but neither of these is available when just importing a file. - -If ```load_on_reg``` was not ```True```, this code would be unavailable -until manually loaded. There is no mechanism in Gramps to load ```GENERAL``` -plugins automatically. - -In addition to importing a file at startup, you can also run a single -function inside a ```GENERAL``` plugin, and it will be passed the -```dbstate```, the ```uistate```, and the plugin data. The function -must be called ```load_on_reg```, and take those three parameters: -``` - # file: library.py - - def load_on_reg(dbstate, uistate, plugin): - """ - Runs when plugin is registered. - """ - print("Hello World!") -``` - -Here, you could connect signals to the ```dbstate```, open windows, etc. - -Another example of what you can do with the plugin interface is to create -a general purpose plugin framework for use by other plugins. Here is the -basis for a plugin system that: - -* allows plugins to list data files -* allows the plugin to process all of the data files - -First, the ```gpr.py``` file: -``` - register(GENERAL, - id = "ID", - category = "CATEGORY", - load_on_reg = True, - process = "FUNCTION_NAME", - ) -``` - -This example uses three new features: - -* ```GENERAL``` plugins can have a category -* ```GENERAL``` plugins can have a load_on_reg function that returns data -* ```GENERAL``` plugins can have a function (called ```process```) which -will process the data - -If you (or someone else) create additional general plugins of this category, -and they follow your ```load_on_reg``` data format API, then they could be -used just like your original data. For example, here is an additional -general plugin in the ```WEBSTUFF``` category: -``` - # anew.gpr.py - - register(GENERAL, - id = 'a new plugin', - category = "WEBSTUFF", - version = '1.0', - gramps_target_version = '6.0', - data = ["a", "b", "c"], - ) -``` - -This doesn't have ```load_on_reg = True```, nor does it have an ```fname``` or -```process```, but it does set the data directly in the ```.gpr.py``` file. -Then, we have the following results: -``` - >>> from gui.pluginmanager import GuiPluginManager - >>> PLUGMAN = GuiPluginManager.get_instance() - >>> PLUGMAN.get_plugin_data('WEBSTUFF') - ["a", "b", "c", "Stylesheet.css", "Another.css"] - >>> PLUGMAN.process_plugin_data('WEBSTUFF') - ["A", "B", "C", "STYLESHEET.CSS", "ANOTHER.CSS"] -``` +```GENERAL``` is the escape hatch for plugin code that doesn't fit any +other kind: shared function libraries (imported at startup with +```load_on_reg = True``` and then available to other addons via a plain +```import```), and pluggable categories such as ```WEBSTUFF``` (narrative +website stylesheets) and ```Filters``` (filter-rule providers). + +The manual documents both uses and the full plugin-data API — the +```load_on_reg(dbstate, uistate, plugin)``` function form, the ```data``` +and ```process``` registration fields, and querying by category through +the plugin manager: +[Addon Kinds → GENERAL](docs/addon-development/03-addon-kinds.md). ### Registered GENERAL Categories -The following are examples of the published secondary plugin categories of -APIs of type ```GENERAL```. - -#### WEBSTUFF -A sample ```gpr.py``` file: -``` - # stylesheet.gpr.py - - register(GENERAL, - id = 'system stylesheets', - category = "WEBSTUFF", - name = _("CSS Stylesheets"), - description = _("Provides a collection of stylesheets for the web"), - version = '1.0', - gramps_target_version = '6.0', - fname = "stylesheet.py", - load_on_reg = True, - process = "process_list", - ) -``` - -Here is the associated program: -``` - # file: stylesheet.py - - def load_on_reg(dbstate, uistate, plugin): - """ - Runs when plugin is registered. - """ - return ["Stylesheet.css", "Another.css"] - - def process_list(files): - return [file.upper() for file in files] -``` - -#### Filters -For example, ```gpr.py```: -``` - register(GENERAL, - category="Filters", - ... - load_on_reg = True - ) -``` -And the actual plugin: -``` - def load_on_reg(dbstate, uistate, plugin): - # returns a function that takes a namespace, 'Person', 'Family', etc. - - def filters(namespace): - print("Ok...", plugin.category, namespace, uistate) - # return a Filter object here - - return filters -``` +The published ```GENERAL``` categories — ```WEBSTUFF``` and ```Filters``` +— with sample registrations and implementations, are covered in +[Addon Kinds → GENERAL](docs/addon-development/03-addon-kinds.md). ### List Your Addon Prerequistes -In your ```.gpr.py``` file, you can have a line like: -``` - ... - depends_on = ["libwebconnect"], - ... -``` - -which is a list of plug-in identifiers from other ```.gpr.py``` files. -This example will ensure that -[libwebconnect](https://gramps-project.org/wiki/index.php/Addon:Web_Connect_Pack#Prerequisites) -is loaded before your addon. If that ID can't be found, or you have a cycle -(a circular import), then your addons won't be loaded. The Gramps architect -summarizes this as: "The ```depends_on``` list is used to specify other plugins -which the plugin depends on. These will be installed automatically." - -Example code in the Addon:Web_Connect_Pack that references ```libwebconnect``` -prerequistes can be seen in -[addons-source/RUWebPack.gpr.py#L17](https://github.com/gramps-project/addons-source/blob/1304b65a7d758bfe17339c26260473ac3e9c4061/RUWebConnectPack/RUWebPack.gpr.py#L17). +```depends_on = ["libwebconnect"]``` in a ```.gpr.py``` lists the ids of +other plugins that must load first (they are installed automatically); +```requires_mod```, ```requires_gi```, and ```requires_exe``` (Gramps 5.2+) +declare Python-module, GObject-introspection, and executable prerequisites. -This allows common prerequisites to be shared between addons. Code can be -maintained in its own ```.gpr.py```/addon file instead of trying to -synchronize the maintenance of multiple copies across various silos. - -Additional requirements properties were implemented starting with the -Gramps 5.2 -[Registration Options](https://gramps-project.org/wiki/index.php/Gramplets_development#Register_Options) that provide for specifying plug-in preqrequisites: - -* For modules: [```requires_mod```](https://github.com/gramps-project/gramps/blob/0f8d4ecd429431b4df64910962f8764af9ff1766/gramps/gen/plug/_pluginreg.py#L689-L719) -* For GObject introspection: [```requires_gi```](https://github.com/gramps-project/gramps/blob/0f8d4ecd429431b4df64910962f8764af9ff1766/gramps/gen/plug/_pluginreg.py#L689-L719) -* For executables: [```requires_exe```](https://github.com/gramps-project/gramps/blob/0f8d4ecd429431b4df64910962f8764af9ff1766/gramps/gen/plug/_pluginreg.py#L689-L719) +Declaration rules — importable module names, verifying entries, version +pins — are in the manual: +[Fundamentals → Declaring dependencies](docs/addon-development/04-fundamentals.md#declaring-dependencies). ## Review the Addon Checklist Before you publish your new addon, review this checklist for completeness: @@ -915,6 +601,10 @@ Before you publish your new addon, review this checklist for completeness: * Has the help_url been changed from the GitHub repository to the wiki page? +The normative MUST / SHOULD / MAY rules a reviewer holds an addon to — +structure, runtime, testing, translation, and the contributor workflow — +are in the manual: [Guidelines](docs/addon-development/16-guidelines.md). + ## Create a Pull Request Once you have created your addon, built the ```.gpr.py``` registration file, and have tested it (you *did* test it, right?) so that you're sure @@ -1129,42 +819,30 @@ Now it is time to announce your addon to those who may not have heard about it yet. ### Gramps Forum -Join the [Gramps Forum](#https://gramps-project.org/wiki/index.php/Contact#Forum) if you have not already. Announce your addon to forum users with general -information on why you created it, what it does for the user, and how to use -it. +Join the [Gramps Forum](https://gramps.discourse.group/) if you have not +already. Announce your addon to forum users with general information on +why you created it, what it does for the user, and how to use it. ### Gramps Wiki Create an account on the -[Gramps Wiki](#https://gramps-project.org/wiki/index.php/Main_page) +[Gramps Wiki](https://gramps-project.org/wiki/index.php/Main_page) if you don't already have one. #### List Your Addon Add a short description of your addon to the Addons list in the wiki by -editing the current release listing: i.e., +editing the current release listing: i.e., [6.0_Addons](https://gramps-project.org/wiki/index.php/6.0_Addons), or if the addon is meant for a future release, [6.1_Addons](https://gramps-project.org/wiki/index.php/6.1_Addons) when available. Examine other addon entries when editing the wiki page, and refer to the -[Addon list legend]]](https://gramps-project.org/wiki/index.php/Addon_list_legend) -list legend]] to understand the meaning of each column. When ready, use the -following template to include your addon in the list: -``` -|- -| -| -| -| -| -| -| -| -|- -``` +[Addon list legend](https://gramps-project.org/wiki/index.php/Addon_list_legend) +to understand the meaning of each column. The row template to copy is in +the manual: [Community → List your addon](docs/addon-development/13-community.md#list-your-addon). #### Document Your Addon -Document your addon in the wiki using the page name format -**Addon:NewProjectName**. Examine some of the other addon documentaion +Document your addon in the wiki using the page name format +**Addon:NewProjectName**. Examine some of the other addon documentation pages for suggestions, and for the general format to use. > ##### TIP @@ -1173,30 +851,9 @@ pages for suggestions, and for the general format to use. > results page you will be provided with a link to create the new page. > Select that link to add your content. -Consider including the following information in your wiki page: - -``` - -{{Third-party plugin}} - - -== Usage == - -=== Configure Options === - -==Features== - -== Prerequisites == - -== Issues == - - -[[Category:Addons]] -[[Category:Plugins]] -[[Category:Developers/General]] -``` +The conventional page skeleton (the ```{{Third-party plugin}}``` banner and +the standard sections) is in the manual: +[Community → Document your addon](docs/addon-development/13-community.md#document-your-addon). ## Support Your Addon Through Bug Tracker Create a user account on the @@ -1233,6 +890,12 @@ often before being officially accepted. core, but are loved by many users (e.g., the Data Entry Gramplet). * A place for experimental components to live. +The technical side of keeping an addon working across Gramps releases — +```gramps_target_version``` semantics, the per-release deltas that bite +ports, and the porting checks — is in the manual: +[Compatibility](docs/addon-development/14-compatibility.md) and +[What's New](docs/addon-development/15-whats-new.md). + ### Examples of Common Enhancements And here are just some of the kinds of enhancements that might make sense: @@ -1277,6 +940,8 @@ Also you may want to [[Addons_development#Package_your_addon |Package your addon --> ## Resources +* [Addon Development manual](docs/addon-development/README.md) — the +in-repo technical reference this document links throughout. * [Brief introduction to Git](https://gramps-project.org/wiki/index.php/Brief_introduction_to_git) * [Getting started with Gramps development](https://gramps-project.org/wiki/index.php/Getting_started_with_Gramps_development) * [Portal:Developers](https://gramps-project.org/wiki/index.php/Portal:Developers) @@ -1289,6 +954,8 @@ Also you may want to [[Addons_development#Package_your_addon |Package your addon * For 4.1.x and earlier, see [Addons development old](https://gramps-project.org/wiki/index.php/Addons_development_old). ## Addon Development Tutorials and Samples +* [Tutorials](docs/addon-development/02-tutorials.md) — in-repo end-to-end +walkthroughs, one per addon kind (Gramplet, Tool, Report, Quick View, Rule). * [Develop an Addon Gramplet](https://gramps-project.org/wiki/index.php/Gramplets_development) (or add a [custom filtering option](https://gramps.discourse.group/t/looking-for-an-example-of-a-gramplet-with-a-custom-filter-configuration-option/5967)) * [Develop_an_Addon_Rule](https://gramps-project.org/wiki/index.php/Develop_an_Addon_Rule) for custom filters * [Develop_an_Addon_Tool](https://gramps-project.org/wiki/index.php/Develop_an_Addon_Tool) diff --git a/README.md b/README.md index 1f1fae31d..cd7ac8e66 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -addons-source [![Build Status](https://travis-ci.org/gramps-project/addons-source.svg?branch=master)](https://travis-ci.org/gramps-project/addons-source) +addons-source Translation status ============= Source code of contributed third-party addons for the [Gramps genealogy program](https://github.com/gramps-project/gramps). -You can develop your own addon following the [Addons Development](https://gramps-project.org/wiki/index.php?title=Addons_development) wiki. +You can develop your own addon following the in-repo [Addon Development manual](docs/addon-development/README.md); the contributor workflow (forks, branches, pull requests) is in [CONTRIBUTING.md](CONTRIBUTING.md). See also the [Addons Development](https://gramps-project.org/wiki/index.php?title=Addons_development) wiki page. Note: The default git branch is `master`. The master branch should only be used to develop addons that require features or changes found in the Gramps master branch. Most of the time addons should be developed to work with the current released version of Gramps (`maintenance/gramps60` for the Gramps 6.0.x versions for example). From e40e5192aff972542a77c7b327300fc47795f6f8 Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Mon, 20 Jul 2026 02:10:18 +0200 Subject: [PATCH 53/65] Strip trailing whitespace in Python source files The addons-source CI (PR 820) adds a lint step that fails on any tracked Python file carrying trailing whitespace. Three pre-existing files trip it (27 lines total): ArchiveAssist/ArchiveAssist.py (22), and two FilterRules modules (5). Strip the trailing whitespace so the gate can pass; whitespace only, no behavioural change (git diff -w is empty). --- ArchiveAssist/ArchiveAssist.py | 44 ++++++++++++------------ FilterRules/matcheventfilterrole.py | 8 ++--- FilterRules/matchparentoffilterfamily.py | 2 +- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/ArchiveAssist/ArchiveAssist.py b/ArchiveAssist/ArchiveAssist.py index 83f2aef86..ed581cab5 100644 --- a/ArchiveAssist/ArchiveAssist.py +++ b/ArchiveAssist/ArchiveAssist.py @@ -94,7 +94,7 @@ def parse_ref(text: str) -> dict: match = pattern.search(text) if not match: return {} - + archive = match.group("archive").strip() to_remove = ["kyrkoarkiv", "stadsarkiv"] for word in to_remove: @@ -127,7 +127,7 @@ def parse_ref(text: str) -> dict: # Gramplet class # ------------------------ class ArchiveAssist(Gramplet): - + # I am bad at GUI. Double check... def init(self): if getattr(self, "_initialized", False): @@ -170,7 +170,7 @@ def build_gui(self): vbox.pack_start(self.status_label, False, False, 0) return vbox - + def get_or_create_repository(self, name, trans): for handle in self.dbstate.db.get_repository_handles(): repo = self.dbstate.db.get_repository_from_handle(handle) @@ -195,11 +195,11 @@ def on_create_clicked(self, widget): if not parsed: self.status_label.set_text("Could not parse the reference string.") return - + # Find existing Source (by title) src = None src_handle = None - + for handle in self.dbstate.db.get_source_handles(): candidate = self.dbstate.db.get_source_from_handle(handle) for attr in candidate.get_attribute_list(): @@ -213,7 +213,7 @@ def on_create_clicked(self, widget): try: with DbTxn("Create Source and Citation", self.dbstate.db) as trans: - + # Source if src: self.status_label.set_text( @@ -223,14 +223,14 @@ def on_create_clicked(self, widget): src = Source() src.set_title(parsed["abr"]) src.set_publication_info(parsed["years"]) - + # FIXED NAD attribute nad_attr = Attribute() nad_attr.set_type("NAD") nad_attr.set_value(parsed["NAD"]) src.add_attribute(nad_attr) - - # FIXED AID attribute (if present) + + # FIXED AID attribute (if present) if parsed["AID"]: aid_attr = Attribute() aid_attr.set_type("AID") @@ -239,46 +239,46 @@ def on_create_clicked(self, widget): # First add the Source WITHOUT repo refs src_handle = self.dbstate.db.add_source(src, trans) - + # Now add RepoRef AFTER the source exists repo_ref = RepoRef() repo_handle = self.get_or_create_repository(parsed["provider"], trans) repo_ref.set_reference_handle(repo_handle) - + src.add_repo_reference(repo_ref) - + # Persist the updated Source with its RepoRef self.dbstate.db.commit_source(src, trans) - + # Citation if parsed["page"]: cit = Citation() cit.set_confidence_level(2) cit.set_page(parsed["page"]) - + years = parsed["years"] cit_date = Date() if years and "-" in years: start_year, end_year = [y.strip() for y in years.split("-")] cit_date.set( - modifier=Date.MOD_RANGE, + modifier=Date.MOD_RANGE, value=(0, 0, int(start_year), False, 0, 0, int(end_year), False)) cit.set_date_object(cit_date) elif years: cit_date.set_year(int(years.strip())) cit.set_date_object(cit_date) - - cit.set_reference_handle(src_handle) - + + cit.set_reference_handle(src_handle) + if parsed["full_AID"]: AID = Attribute() AID.set_type("AID") AID.set_value(parsed["full_AID"]) cit.add_attribute(AID) - + cit_handle = self.dbstate.db.add_citation(cit, trans) - + self.status_label.set_text( f"Created Source ({src.get_gramps_id()}) and Citation ({cit.get_gramps_id()})." ) @@ -287,7 +287,7 @@ def on_create_clicked(self, widget): f"Created Source ({src.get_gramps_id()}). No Citation created due to missing page info." ) - except Exception as e: + except Exception as e: LOG.error("ArchiveAssist failed: %s", str(e), exc_info=True) self.status_label.set_text( - "Failed to create Source/Citation. Check logs.") \ No newline at end of file + "Failed to create Source/Citation. Check logs.") \ No newline at end of file diff --git a/FilterRules/matcheventfilterrole.py b/FilterRules/matcheventfilterrole.py index 8c9cd983b..6e813aac9 100644 --- a/FilterRules/matcheventfilterrole.py +++ b/FilterRules/matcheventfilterrole.py @@ -54,7 +54,7 @@ def __init__(self, db): class MatchesEventFilterRole(MatchesEventFilter): labels = [_("Event filter name:"), _("Include Family events:"), (_('Role:'), Roletype)] name = _("Persons with events matching the with role") - + def prepare(self, db: Database, user): MatchesEventFilter.prepare(self, db, user) @@ -65,9 +65,9 @@ def prepare(self, db: Database, user): self.MPF_famevents = False except IndexError: self.MPF_famevents = False - + def apply_to_one(self, db: Database, person: Person) -> bool: - + filt = self.find_filter() if filt: for event_ref in person.get_event_ref_list(): @@ -89,4 +89,4 @@ def apply_to_one(self, db: Database, person: Person) -> bool: if filt.apply_to_one(db, event): return True return False - + diff --git a/FilterRules/matchparentoffilterfamily.py b/FilterRules/matchparentoffilterfamily.py index 0b202b61c..87d5dc1e5 100644 --- a/FilterRules/matchparentoffilterfamily.py +++ b/FilterRules/matchparentoffilterfamily.py @@ -62,7 +62,7 @@ class MatchesParentOfFilterFamily(MatchesFilterBase): # we want to have this filter show family filters namespace = "Family" - + def apply_to_one(self, db: Database, person: Person) -> bool: filt = self.find_filter() if filt: From 3eaf55764687b8777b60d7b38dd8e5bd087972a8 Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Mon, 20 Jul 2026 02:13:09 +0200 Subject: [PATCH 54/65] AssociationsTool: stop shadowing the gettext _ with a loop variable _build_with_progress used `for _ in range(batch_size)`, which makes _ a local variable throughout the whole method (Python binds it at compile time). That shadows the module-level `_ = _trans.gettext`, so: - the except handler at the top of the method, LOG.error(_("Error initializing data: %s") % str(e)), runs before the loop assigns _ and raises UnboundLocalError; and - the in-loop handler LOG.warning(_("Error processing person %s: %s" % ...)) calls _ after the loop bound it to an int, raising TypeError. Both fire only on the error paths, so they slipped through. The loop counter is unused; rename it to _batch_step so _ resolves to the module gettext everywhere. ruff (E9,F63,F7,F82) is clean on the module. This also clears the F82x lint error PR 820's ruff gate reports on the current tree. --- AssociationsTool/associationstool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AssociationsTool/associationstool.py b/AssociationsTool/associationstool.py index ef9a5bed0..c9b8c8906 100644 --- a/AssociationsTool/associationstool.py +++ b/AssociationsTool/associationstool.py @@ -184,7 +184,7 @@ def _build_with_progress(self) -> bool: # Process next batch batch_size = 50 - for _ in range(batch_size): + for _batch_step in range(batch_size): if self._build_index >= len(self._plist): # Done processing self.stats_list = self._build_data From 2ad10b0e7cad37689b3a82787dbf5cfcaae51a85 Mon Sep 17 00:00:00 2001 From: Eric Lenerville Date: Mon, 20 Jul 2026 12:00:55 -0400 Subject: [PATCH 55/65] MediaVerify auto update media object names when they match old filename #12610 --- MediaVerify/MediaVerify.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/MediaVerify/MediaVerify.py b/MediaVerify/MediaVerify.py index be567472b..2a55c0b18 100644 --- a/MediaVerify/MediaVerify.py +++ b/MediaVerify/MediaVerify.py @@ -390,6 +390,17 @@ def fix_media(self, button): for handle, new_path in self.moved_files: media = self.db.get_media_from_handle(handle) + + old_title = media.get_description() + old_path = media.get_path() + old_filename = os.path.splitext(os.path.basename(old_path))[0] + + # If the old media title matches the old filename then update the + # media title to match the new filename. + if old_title == old_filename: + new_title = os.path.splitext(os.path.basename(new_path))[0] + media.set_description(new_title) + media.set_path(new_path) self.db.commit_media(media, trans) From 6a56efe127ae8fc915aa93dbe195bb46410f7a47 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Fri, 24 Jul 2026 09:29:22 -0700 Subject: [PATCH 56/65] Merge MediaVerify auto update media object names #12610 #997 --- MediaVerify/MediaVerify.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MediaVerify/MediaVerify.gpr.py b/MediaVerify/MediaVerify.gpr.py index 9cae2fa3b..182a8b89f 100644 --- a/MediaVerify/MediaVerify.gpr.py +++ b/MediaVerify/MediaVerify.gpr.py @@ -30,7 +30,7 @@ id="mediaverify", name=_("Media Verify"), description=_("Verify that media is present in the correct path"), - version = '1.0.34', + version = '1.0.35', gramps_target_version="6.1", status=STABLE, fname="MediaVerify.py", From 5c6572c1e19098af1dd984592443136dabbf2920 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sun, 19 Jul 2026 07:59:29 -0700 Subject: [PATCH 57/65] Add AGENTS.md as a tool-agnostic mirror of CLAUDE.md Some agentic coding tools look for AGENTS.md rather than CLAUDE.md; keep the two in sync so contributors using either tool see the same repo conventions. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..735899371 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,260 @@ +# Agent Guidelines for gramps-project/addons-source + +This document specifies rules and conventions that agents should follow when +making changes to this repository. + +## Repository Overview + +`addons-source` holds the source code for third-party Gramps addons — +gramplets, views, tools, reports, importers/exporters, filter rules, database +backends, and other plugins that are not part of Gramps core. It is one of +three tightly-linked repositories: + +- **`gramps`** — Gramps core (the application these addons plug into). +- **`addons-source`** (this repo) — addon source code, developed here. +- **`addons`** — built `.addon.tgz` packages and listing JSON consumed by the + in-app Plugin Manager. Populated from this repo via `make.py`; not edited + by hand except by the maintainer doing a release build. + +There is no unified build or CI pipeline for the whole repo (the checked-in +`.travis.yml` is stale/unused) — each addon is largely independent. + +## Branch Model + +Branches are per Gramps release, e.g. `maintenance/gramps60`, +`maintenance/gramps61`. **Almost all addon work should target the maintenance +branch matching the current released Gramps version**, not `master`. Only use +`master` for an addon that depends on unreleased Gramps-core features. + +Pick the branch carefully: +- When creating a new addon or fixing an existing one, branch from + `origin/maintenance/gramps6X` (the current release line), not from a local + tracking branch that may carry unrelated WIP commits. +- When opening the PR, target that same branch on + `gramps-project/addons-source`. +- Before pushing, sync/rebase against upstream (`git pull --rebase`) so the + PR applies cleanly. + +All PRs must come from a personal fork, never from a branch pushed directly +to `gramps-project/addons-source` (see `CONTRIBUTING.md`, "Commit Your +Changes": "you want to put your changes into _your_ fork, _not_ the upstream +`gramps-project/addons-source` repository"). + +Weblate translation PRs are the one exception where commits must **not** be +squashed on merge; everything else can be merged/rebased normally. + +## Repository Layout + +Each addon is a single top-level directory, CamelCase-named to match its +Python import name (e.g. `FilterRules/`, `DataEntryGramplet/`). A directory +typically contains: + +- `AddonName.py` — the implementation. +- `AddonName.gpr.py` — the Gramps plugin registration file (required; see + below). +- `po/` — translation `.po` files, managed by `make.py`. +- `tests/` — optional unit tests (see Testing below). +- `MANIFEST` — optional; lists extra files (docs, data) to include in the + built package beyond the default `*.py`, `*.glade`, `*.xml`, `*.txt`, + `locale/*/LC_MESSAGES/*.mo`. +- `locale/` — generated `.mo` files; not something you hand-edit. + +Addon directories generally have **no `__init__.py`** (they rely on Python 3 +namespace packages) so that Gramps can add each one to `sys.path` at load +time and other addons can `import AddonName` directly. + +## Commands + +Addon packaging/translation tasks go through `make.py`, run from the repo +root. Point `GRAMPSPATH` at wherever *your* local Gramps checkout lives (it +defaults to `../../..`, i.e. it assumes `make.py` is being invoked from one +level inside a sibling-layout workspace — don't rely on that default, always +set it explicitly), and set `LANGUAGE=en_US.UTF-8`: + +```bash +GRAMPSPATH=/path/to/your/gramps LANGUAGE=en_US.UTF-8 python3 make.py gramps61 build AddonDirectory +``` + +Common subcommands (first positional arg is the branch/version tag, e.g. +`gramps61`): + +- `init AddonDirectory [lang]` — scaffold dirs / `.pot` for a new addon, or + an initial `po/-local.po`. +- `update AddonDirectory ` — refresh a language `.po` from the `.pot`. +- `compile [AddonDirectory|all]` — compile `.po` → `.mo`. +- `build [AddonDirectory|all]` — produce the downloadable `.addon.tgz`. +- `listing [AddonDirectory|all]` — generate/update the Plugin Manager + listing JSON. +- `clean AddonDirectory` — strip generated files (`locale/`, `*.pot`, etc.) + before committing. +- `manifest-check` — validate `MANIFEST` files. +- `as-needed` — build/list/clean only what's out of date, repo-wide. + +Unlike `GRAMPSPATH`, the `build`/`listing`/`check` subcommands write to a +**hardcoded relative path**, `../addons//...` — this isn't a +convention, it's how `make.py` itself resolves the output location, so it +only works if you've cloned the `addons` repo as a sibling of +`addons-source` (see `MAINTAINERS.md` for the recommended three-repo +workspace layout: `gramps` / `addons` / `addons-source` side by side). You +don't need that sibling checkout at all unless you're packaging/publishing +an addon. + +You do not need `make.py` just to write/test an addon's Python code — only +when packaging, translating, or updating the listing. + +## Testing + +Not every addon has tests, but when adding or fixing one, add regression +tests under `AddonName/tests/`, mirroring existing examples (`Form/tests/`, +`DataEntryGramplet/tests/`, `Sqlite/tests/`, `FilterRules/tests/`). + +Conventions here differ from Gramps core: + +- Test files are named `test_*.py` (pytest-style), **not** the `*_test.py` + suffix core Gramps uses — `unittest discover` here is invoked per-addon or + from the repo root without the core `-p "*_test.py"` pattern. +- Still use the `unittest` framework (`unittest.TestCase`), not `pytest` + APIs, even though filenames follow the `test_*.py` convention. +- `AddonName/tests/__init__.py` should be **empty**. Do not add + `gi.require_version(...)` pinning there or in individual test modules — + see GTK/GDK below. +- Because the addon directory has no `__init__.py`, test modules need a + `sys.path` hack to import it as a namespace package: + + ```python + 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) + ``` + + then `from AddonName.addonmodule import SomeClass`. +- Build test data programmatically against a real (temp-dir) database rather + than relying on fixtures that don't exist in this repo: + + ```python + from gramps.gen.db.utils import make_database + from gramps.gen.db import DbTxn + + db = make_database("sqlite") + db.load(tempfile.mkdtemp(prefix="myaddon_")) + with DbTxn("build test db", db) as txn: + ... + ``` +- If a test needs `gramps.gen.filters.CustomFilters`, call + `reload_custom_filters()` **before** importing the `CustomFilters` name + from `gramps.gen.filters` — that function rebinds the module-level global + rather than mutating it in place, so importing the name first leaves you + with a stale `None`. + +### GTK / GDK version pinning + +The repo-root `tests/__init__.py` pins `gi.require_version("Gtk", "3.0")` and +`gi.require_version("Gdk", "3.0")` for the whole test suite (added in PR +#950). `gi.require_version` sets process-global state, so **individual addon +test files should not re-pin GTK/GDK** — that's now redundant. A new test +module that imports something pulling in `gramps.gui.*` (which happens at +import time for most GUI-facing addons) only needs a guard for hosts with no +PyGObject at all: + +```python +try: + import gi +except ImportError as err: + raise unittest.SkipTest("PyGObject not available: %s" % err) +``` + +Do not add `gi.require_version(...)` calls back into new test files — if you +see them in an addon you're touching, they're safe to remove as redundant +(see the `Themes` addon's `tests/__init__.py` cleanup for precedent), but +don't remove them from files you aren't otherwise changing. + +### Running tests + +```bash +export GRAMPS_RESOURCES=/path/to/gramps/build/share # built Gramps checkout +export GDK_BACKEND=- +python3 -m unittest AddonName.tests.test_something -v +``` + +Run from the `addons-source` repo root so the addon's namespace package +resolves. + +## Code Style + +- Every new `.py` file needs the same GPL-2.0-or-later header with copyright + used throughout Gramps core. +- Group imports under comment-banner sections (`Standard Python modules`, + `GTK/Gnome modules`, `Gramps modules`), matching the style already used + across this repo (e.g. `FilterRules/isfamilyfiltermatchevent.py`). +- Format with [Black](https://black.readthedocs.io/) — not enforced by a + checked-in CI workflow here, but the existing codebase is Black-formatted + and new/changed files should be too (`black `). +- Docstrings: concise; full Sphinx `:param:`/`:returns:` style only when the + parameters aren't already obvious from the signature. + +## Internationalization + +Addons manage their own translations, separately from Gramps core. At the +top of an addon's main module: + +```python +from gramps.gen.const import GRAMPS_LOCALE as glocale + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.gettext +``` + +(`get_addon_translator` raises `ValueError` if the addon has no +`locale/` translations yet — fall back to the core translator rather than +letting the import crash.) Use `ngettext(singular, plural, n)` instead of a +single `_()` call for any string that counts a noun, for the same reason as +Gramps core: many languages have plural rules English doesn't, and only +`ngettext` lets gettext apply the target language's actual rule. + +## Plugin Registration (`.gpr.py`) + +Every addon needs an `AddonName.gpr.py` alongside `AddonName.py`: + +```python +register( + RULE, # or TOOL, GRAMPLET, REPORT, VIEW, GENERAL, IMPORT, EXPORT, ... + id="uniqueid", + name=_("Human-Readable Name"), + description=_("What it does."), + version="1.0.0", + gramps_target_version="6.1", + status=STABLE, # or UNSTABLE + fname="AddonName.py", + authors=["Author Name"], + authors_email=["author@example.com"], + help_url="Addon:WikiPageName", + ... # PTYPE-specific attributes, e.g. ruleclass/namespace for RULE +) +``` + +`gramps_target_version` and `version` are required. `help_url` should point +at the addon's Gramps wiki page (`Addon:PageName`), not a GitHub URL. + +> **Do not manually change `version` in a PR.** The `MAJOR.MINOR.PATCH` +> version in `.gpr.py` is bumped automatically by the `addons` repo's +> packaging build (`make.py`) when a change is released — the patch +> component increments on its own. Hand-editing it in a source PR just +> creates spurious diffs and can conflict with what the release build +> assigns; leave it as-is unless you're intentionally doing a MAJOR/MINOR +> bump for a breaking addon change. + +## Bug Reports and Commit Messages + +Bugs against addons are tracked in two places: the shared +[Gramps Mantis BT](https://gramps-project.org/bugs/view_all_bug_page.php) +and, informally, the [Gramps Discourse forum](https://gramps.discourse.group/) +— many addon issues start as a discourse thread rather than a Mantis report. +Unlike Gramps core, this repo has no automated changelog tooling parsing +commit messages, so there's no required `Fixes #N` keyword syntax — just +describe *why* the change was made, and link whatever report (Mantis bug or +discourse URL) motivated it if one exists. Don't invent a Mantis bug number +if you only have a discourse link or user report — link what you actually +have. From 3c7164f2e307623362a0bc9b69e4de6875b0302f Mon Sep 17 00:00:00 2001 From: Douglas Blank Date: Mon, 20 Jul 2026 10:12:53 -0400 Subject: [PATCH 58/65] Update AGENTS.md Co-authored-by: Eduard R. --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 735899371..f84d91c4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,12 @@ This document specifies rules and conventions that agents should follow when making changes to this repository. +For the normative addon rules — MUST / SHOULD / MAY, with the origin of +each rule (upstream PR, maintainer ruling, Mantis id) cited inline — see +[docs/addon-development/16-guidelines.md](docs/addon-development/16-guidelines.md) +from the Addon Development manual (PR 994). This document covers the +hands-on agent workflow around those rules. + ## Repository Overview `addons-source` holds the source code for third-party Gramps addons — From d17a25a85f2e8feae34a2c8c7357616caf3c7029 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 20 Jul 2026 07:19:41 -0700 Subject: [PATCH 59/65] Address review feedback from Nick-Hall on PR #991 - Clarify branch model: "release" means major/feature release, not patch release; note master exists but isn't currently used for addon work. - Note LANGUAGE=en_US.UTF-8 is no longer required on Gramps v6.0+, and mention checking out the matching branch in the core checkout. - Soften Black formatting guidance to reflect it's not currently required. - Note that strings already translated in Gramps core are excluded from the Weblate Addons component. - Add maintainers/maintainers_email fields to the .gpr.py example. --- AGENTS.md | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f84d91c4d..ec9474780 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,10 +27,11 @@ There is no unified build or CI pipeline for the whole repo (the checked-in ## Branch Model -Branches are per Gramps release, e.g. `maintenance/gramps60`, -`maintenance/gramps61`. **Almost all addon work should target the maintenance -branch matching the current released Gramps version**, not `master`. Only use -`master` for an addon that depends on unreleased Gramps-core features. +Branches are per Gramps major or feature release, e.g. `maintenance/gramps60`, +`maintenance/gramps61` — not one branch per patch release. **Almost all addon +work should target the maintenance branch matching the current released +Gramps version.** A `master` branch exists but is not currently used for +addon work. Pick the branch carefully: - When creating a new addon or fixing an existing one, branch from @@ -75,10 +76,13 @@ Addon packaging/translation tasks go through `make.py`, run from the repo root. Point `GRAMPSPATH` at wherever *your* local Gramps checkout lives (it defaults to `../../..`, i.e. it assumes `make.py` is being invoked from one level inside a sibling-layout workspace — don't rely on that default, always -set it explicitly), and set `LANGUAGE=en_US.UTF-8`: +set it explicitly), and make sure that checkout has the matching branch +checked out too (e.g. `maintenance/gramps61` in core when building against +`gramps61` here). Setting `LANGUAGE=en_US.UTF-8` is no longer required on +Gramps v6.0 and later: ```bash -GRAMPSPATH=/path/to/your/gramps LANGUAGE=en_US.UTF-8 python3 make.py gramps61 build AddonDirectory +GRAMPSPATH=/path/to/your/gramps python3 make.py gramps61 build AddonDirectory ``` Common subcommands (first positional arg is the branch/version tag, e.g. @@ -192,15 +196,20 @@ resolves. - Group imports under comment-banner sections (`Standard Python modules`, `GTK/Gnome modules`, `Gramps modules`), matching the style already used across this repo (e.g. `FilterRules/isfamilyfiltermatchevent.py`). -- Format with [Black](https://black.readthedocs.io/) — not enforced by a - checked-in CI workflow here, but the existing codebase is Black-formatted - and new/changed files should be too (`black `). +- [Black](https://black.readthedocs.io/) formatting is not currently required + for addons — there's no checked-in CI workflow enforcing it here, though + that may change in the future. The existing codebase is largely + Black-formatted, so running `black ` on files you're already + touching is a reasonable default. - Docstrings: concise; full Sphinx `:param:`/`:returns:` style only when the parameters aren't already obvious from the signature. ## Internationalization -Addons manage their own translations, separately from Gramps core. At the +Addons manage their own translations, separately from Gramps core, though +both go through the same Weblate instance: strings already translated in +Gramps core are matched first, so those strings are excluded from the +Weblate _Addons_ component and don't need separate translation there. At the top of an addon's main module: ```python @@ -236,6 +245,8 @@ register( fname="AddonName.py", authors=["Author Name"], authors_email=["author@example.com"], + maintainers=["Maintainer Name"], + maintainers_email=["maintainer@example.com"], help_url="Addon:WikiPageName", ... # PTYPE-specific attributes, e.g. ruleclass/namespace for RULE ) @@ -243,6 +254,9 @@ register( `gramps_target_version` and `version` are required. `help_url` should point at the addon's Gramps wiki page (`Addon:PageName`), not a GitHub URL. +`maintainers`/`maintainers_email` identify who currently maintains the +addon and may differ from `authors`/`authors_email` (the original author) +once upkeep passes to someone else. > **Do not manually change `version` in a PR.** The `MAJOR.MINOR.PATCH` > version in `.gpr.py` is bumped automatically by the `addons` repo's From 2dc7050c735131d852e48559dcd4a436b255f4b2 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sun, 26 Jul 2026 08:42:17 -0700 Subject: [PATCH 60/65] Add Windows Development section addressing MSYS2 vs native Windows Per hgohel's suggestion on PR #991 (comment 5026237564) and follow-up discussion, clarify the distinction between MSYS2 (POSIX-like shell, safe to run make.py/tests in) and native Windows cmd/PowerShell (unverified tooling) so agents know what they can and can't run. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ec9474780..46170c611 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,6 +189,36 @@ python3 -m unittest AddonName.tests.test_something -v Run from the `addons-source` repo root so the addon's namespace package resolves. +## Windows Development + +Per `CONTRIBUTING.md`, addons here are developed and tested primarily on +Linux; Windows and macOS are possible but far less thoroughly reviewed. +"Windows" itself covers two different things agents should not conflate: + +- **MSYS2** — the POSIX-like shell/toolchain environment Gramps's own + Windows installer is built with. A shell running inside MSYS2 behaves like + a Linux shell (`bash`, POSIX paths) and can run this repo's `make.py` and + `unittest` invocations normally. +- **Native Windows** — a bare `cmd.exe` or PowerShell host with no MSYS2 or + WSL layer underneath. The commands and paths used elsewhere in this + document (`make.py`, `python3 -m unittest ...`, the `GRAMPSPATH` / + `GRAMPS_RESOURCES` env vars) assume a POSIX shell and are not verified + against native `cmd`/PowerShell. + +If you are running as an agent on a native Windows host: + +- Reading, writing, and refactoring addon Python code is fine — the code + itself is portable. +- Do not run this repo's tests or `make.py` directly in `cmd`/PowerShell and + report the result as a real pass/fail — the tooling isn't validated there, + so a failure may be environmental noise rather than an actual regression. +- Ask the user to run the same command inside an MSYS2 shell, WSL, or a + Linux container/VM and share the output, rather than guessing at the + result yourself. + +If you're already inside MSYS2 or WSL, treat it as a POSIX shell and follow +the rest of this document (Commands, Testing) as written. + ## Code Style - Every new `.py` file needs the same GPL-2.0-or-later header with copyright From 04077f3e43022a85842870049bbe3e1c75a178e6 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Fri, 17 Jul 2026 07:50:57 -0700 Subject: [PATCH 61/65] PersonRelationshipFilter: fix HandleError crash and mismatched name fields IsSiblingofNamedSibling crashed with HandleError whenever applied to a person with no recorded parents, because get_family_from_handle(None) raises rather than returning None. RegExpPersonal/RegExpFamily also searched mismatched Name fields (title was listed twice in the personal list, and call name was searched by the family/surname rule instead), so personal search never matched a person's call name and surname search could false-positive on an unrelated title or call name. Adds unit tests covering both regressions plus the general relationship-matching rules, and brings the addon up to date with black/mypy. Co-Authored-By: Claude Sonnet 5 --- .../PersonRelationshipFilter.gpr.py | 38 ++ .../PersonRelationshipFilter.py | 353 ++++++++++++++++++ PersonRelationshipFilter/tests/__init__.py | 0 .../test_person_relationship_filter_rules.py | 326 ++++++++++++++++ 4 files changed, 717 insertions(+) create mode 100644 PersonRelationshipFilter/PersonRelationshipFilter.gpr.py create mode 100644 PersonRelationshipFilter/PersonRelationshipFilter.py create mode 100644 PersonRelationshipFilter/tests/__init__.py create mode 100644 PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py diff --git a/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py b/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py new file mode 100644 index 000000000..0805f6f67 --- /dev/null +++ b/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py @@ -0,0 +1,38 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2015 Nick Hall +# Copyright (C) 2024 Paul Womack (BugBear) +# 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. + +register( + GRAMPLET, + id="Person Relationship Filter", + name=_("Person Relationship Filter"), + description=_("Gramplet providing a person filter on relationships"), + version="1.0.0", + gramps_target_version="6.1", + status=STABLE, + fname="PersonRelationshipFilter.py", + height=200, + gramplet="PersonRelationshipFilter", + gramplet_title=_("Relationship Filter"), + navtypes=["Person"], + authors=["Paul Womack", "Doug Blank"], + authors_email=["doug.blank@gmail.com"], + help_url="Addon:AdvancedPersonFilter", +) diff --git a/PersonRelationshipFilter/PersonRelationshipFilter.py b/PersonRelationshipFilter/PersonRelationshipFilter.py new file mode 100644 index 000000000..a35169405 --- /dev/null +++ b/PersonRelationshipFilter/PersonRelationshipFilter.py @@ -0,0 +1,353 @@ +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2010 Doug Blank +# Copyright (C) 2011 Nick Hall +# Copyright (C) 2011 Tim G L Lyons +# Copyright (C) 2024 Paul Womack (BugBear) +# +# 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. +# + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- + +from gi.repository import Gtk + +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.plug import Gramplet + +from gramps.gen.filters.rules import Rule +from gramps.gen.filters.rules.person import ProbablyAlive +from gramps.gen.lib.person import Person +from gramps.gen.lib import Date +from gramps.gen.datehandler import displayer +from gramps.gen.filters import GenericFilter +from gramps.gui import widgets +from gramps.gui.filters.sidebar import SidebarFilter + +_ = glocale.translation.gettext + + +class _RegExpNameList(Rule): + """Rule that checks for full or partial name matches""" + + labels = [_("Text:")] + name = _("People with a name matching ") + description = _( + "Matches people's names containing a substring or " + "matching a regular expression" + ) + category = _("General filters") + allow_regex = True + + def field_list(self, name): + raise NotImplementedError + + def apply_to_one(self, db, person): + for name in [person.primary_name] + person.alternate_names: + for field in self.field_list(name): + if self.match_substring(0, field): + return True + else: + return False + + +class RegExpPersonal(_RegExpNameList): + def field_list(self, name): + return [name.first_name, name.title, name.call, name.nick] + + +class RegExpFamily(_RegExpNameList): + def field_list(self, name): + return [name.get_surname(), name.suffix, name.famnick] + + +class _HasNamedRelation(Rule): + labels = [_("Filter name:")] + name = _("Children of name match") + category = _("Family filters") + description = _("Matches children of anybody with a given name") + + def __init__(self, arg, name_matcher, use_regex=False): + super().__init__(arg, use_regex) + self.name_matcher = name_matcher(arg, use_regex) + + def prepare(self, db, user): + self.name_matcher.requestprepare(db, user) + + def reset(self): + self.name_matcher.requestreset() + + def get_rel_list(self, db, person): + raise NotImplementedError + + def get_spouse_list(self, db, person): + handles = [] + for fam_id in person.family_list: + fam = db.get_raw_family_data(fam_id) + if fam: + for spouse_id in [fam.father_handle, fam.mother_handle]: + if not spouse_id: + continue + if spouse_id == person.handle: + continue + handles.append(spouse_id) + return handles + + def apply_to_one(self, db, person): + for rel_id in self.get_rel_list(db, person): + if rel_id: + rel = db.get_raw_person_data(rel_id) + if self.name_matcher.apply_to_one(db, rel): + return True + return False + + +class _HasNamedParent(_HasNamedRelation): + def get_parent_families(self, db, person): + families = [] + for fam_id in person.parent_family_list: + fam = db.get_family_from_handle(fam_id) + if fam: + families.append(fam) + return families + + +class HasNamedFather(_HasNamedParent): + def get_rel_list(self, db, person): + return map( + lambda fam: fam.get_father_handle(), self.get_parent_families(db, person) + ) + + +class HasNamedMother(_HasNamedParent): + def get_rel_list(self, db, person): + return map( + lambda fam: fam.get_mother_handle(), self.get_parent_families(db, person) + ) + + +class IsSiblingofNamedSibling(_HasNamedRelation): + def get_rel_list(self, db, person): + handles = [] + fam_id = person.get_main_parents_family_handle() # or all families, per above? + fam = db.get_family_from_handle(fam_id) if fam_id else None + if fam: + for child_ref in fam.get_child_ref_list(): + if child_ref and child_ref.ref != person.handle: + handles.append(child_ref.ref) + return handles + + +class HasNamedChild(_HasNamedRelation): + def get_rel_list(self, db, person): + handles = [] + for fam_id in person.family_list: + fam = db.get_family_from_handle(fam_id) + if fam: + for child_ref in fam.get_child_ref_list(): + if child_ref: + handles.append(child_ref.ref) + return handles + + +class HasNamedSpouse(_HasNamedRelation): + def get_rel_list(self, db, person): + return self.get_spouse_list(db, person) + + +class HasName(_HasNamedRelation): + def get_rel_list(self, db, person): + if person.gender == Person.FEMALE and isinstance( + self.name_matcher, RegExpFamily + ): + # for female surnames, we want to trawl the spouses surnames + handles = self.get_spouse_list(db, person) + handles.append(person.handle) + return handles + else: + return [person.handle] + + +def extract_text(entry_widget): + """ + Extract the text from the entry widget, strips off any extra spaces. + """ + return str(entry_widget.get_text().strip()) + + +# leverage to split the name into fore and aft +class SearchableNamePair: + def __init__(self, label, rule_class): + self.widget_personal = widgets.BasicEntry() + self.widget_personal.set_placeholder_text(_("given")) + self.widget_family = widgets.BasicEntry() + self.widget_family.set_placeholder_text(_("surname")) + self.label = label + self.rule_class = rule_class + + def place(self, sidebar): + # container.add_text_entry(self.label, self.widget_personal) + # self.add_text_entry(container, self.label, self.widget_personal) + # unrolled + + sidebar.grid.attach(widgets.BasicLabel(self.label), 1, sidebar.position, 1, 1) + + self.widget_personal.set_hexpand(True) + sidebar.grid.attach(self.widget_personal, 2, sidebar.position, 1, 1) + self.widget_personal.connect("key-press-event", sidebar.key_press) + + self.widget_family.set_hexpand(True) + sidebar.grid.attach(self.widget_family, 3, sidebar.position, 1, 1) + self.widget_family.connect("key-press-event", sidebar.key_press) + sidebar.position += 1 + + def clear(self): + self.widget_personal.set_text("") + self.widget_family.set_text("") + + def _add_to_filter(self, generic_filter, regex, widget, search_class): + v = extract_text(widget) + if v: + rule = self.rule_class([v], search_class, use_regex=regex) + generic_filter.add_rule(rule) + + def add_to_filter(self, generic_filter, regex): + self._add_to_filter(generic_filter, regex, self.widget_personal, RegExpPersonal) + self._add_to_filter(generic_filter, regex, self.widget_family, RegExpFamily) + + +# ------------------------------------------------------------------------- +# +# PersonSidebarFilter class +# +# ------------------------------------------------------------------------- +class PersonSidebarFilter(SidebarFilter): + + def __init__(self, dbstate, uistate, clicked): + self.clicked_func = clicked + self.sensitive_regex = False + + self.names = [ + SearchableNamePair(_("Person"), HasName), + SearchableNamePair(_("Father"), HasNamedFather), + SearchableNamePair(_("Mother"), HasNamedMother), + SearchableNamePair(_("Spouse"), HasNamedSpouse), + SearchableNamePair(_("Sibling 1"), IsSiblingofNamedSibling), + SearchableNamePair(_("Sibling 2"), IsSiblingofNamedSibling), + SearchableNamePair(_("Child 1"), HasNamedChild), + SearchableNamePair(_("Child 2"), HasNamedChild), + ] + self.filter_alive = widgets.DateEntry(uistate, []) + + self.filter_regex = Gtk.CheckButton(label=_("Use regular expressions")) + + SidebarFilter.__init__(self, dbstate, uistate, "Person") + + def create_widget(self): + exdate1 = Date() + exdate2 = Date() + exdate1.set( + Date.QUAL_NONE, + Date.MOD_RANGE, + Date.CAL_GREGORIAN, + (0, 0, 1800, False, 0, 0, 1900, False), + ) + exdate2.set( + Date.QUAL_NONE, Date.MOD_BEFORE, Date.CAL_GREGORIAN, (0, 0, 1850, False) + ) + + msg1 = displayer.display(exdate1) + msg2 = displayer.display(exdate2) + + for w in self.names: + w.place(self) + + self.add_text_entry( + _("Probably Alive"), + self.filter_alive, + _("example: '%(msg1)s' or '%(msg2)s'") % {"msg1": msg1, "msg2": msg2}, + ) + self.add_regex_entry(self.filter_regex) + + def clear(self, obj): + for w in self.names: + w.clear() + self.filter_alive.set_text("") + + def get_filter(self): + """ + Extracts the text strings from the sidebar, and uses them to build up + a new filter. + """ + + regex = self.filter_regex.get_active() + + # build a GenericFilter + generic_filter = GenericFilter() + for w in self.names: + w.add_to_filter(generic_filter, regex) + + alive = extract_text(self.filter_alive) + if alive: + rule = ProbablyAlive([alive]) + generic_filter.add_rule(rule) + + return generic_filter + + +# ------------------------------------------------------------------------- +# +# Filter class +# +# ------------------------------------------------------------------------- +class Filter(Gramplet): + """ + The base class for all filter gramplets. + """ + + FILTER_CLASS: type[SidebarFilter] | None = None + + def init(self): + self.filter = self.FILTER_CLASS( + self.dbstate, self.uistate, self.__filter_clicked + ) + self.widget = self.filter.get_widget() + self.gui.get_container_widget().remove(self.gui.textview) + self.gui.get_container_widget().add(self.widget) + self.widget.show_all() + + def __filter_clicked(self): + """ + Called when the filter apply button is clicked. + """ + self.gui.view.generic_filter = self.filter.get_filter() + self.gui.view.build_tree() + + +# ------------------------------------------------------------------------- +# +# PersonFilter class +# +# ------------------------------------------------------------------------- +class PersonRelationshipFilter(Filter): + """ + A gramplet providing a Person Filter. + """ + + FILTER_CLASS = PersonSidebarFilter diff --git a/PersonRelationshipFilter/tests/__init__.py b/PersonRelationshipFilter/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py b/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py new file mode 100644 index 000000000..cbbe68eb3 --- /dev/null +++ b/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py @@ -0,0 +1,326 @@ +# +# 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. +# + +""" +Unit tests for the relationship-matching filter rules defined in +``PersonRelationshipFilter.py``. + +The tests build a small family tree directly in an in-memory database +(no dependency on the shared ``example.gramps`` fixture) so each +rule's relationship traversal and name-field matching can be checked +precisely. Two regressions are covered explicitly: + +* ``IsSiblingofNamedSibling`` used to raise ``HandleError`` when + applied to a person with no recorded parents, because + ``get_family_from_handle(None)`` raises rather than returning + ``None``. +* ``RegExpPersonal``/``RegExpFamily`` searched mismatched ``Name`` + fields (``title`` was listed twice in the personal field list, and + ``call`` name was searched by the family/surname rule instead), so + personal search never matched a person's call name and surname + search could false-positive on an unrelated title or call name. +""" + +# ------------------------ +# Python modules +# ------------------------ +import os +import sys +import unittest + +# The addon imports Gtk at module load — skip cleanly if gi/Gtk are not +# available, mirroring what other addons' test suites do. +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError, AttributeError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +# ------------------------ +# Gramps modules +# ------------------------ +# The addon directory goes on sys.path so ``import PersonRelationshipFilter`` +# resolves the flat ``PersonRelationshipFilter.py`` module directly (there is +# no wrapping package — the file lives right in the addon directory). +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__)) + +try: + from gramps.gen.db import DbTxn + from gramps.gen.db.utils import make_database + from gramps.gen.lib import ChildRef, Family, Name, Person, Surname + + from PersonRelationshipFilter import ( + HasName, + HasNamedChild, + HasNamedFather, + HasNamedMother, + HasNamedSpouse, + IsSiblingofNamedSibling, + RegExpFamily, + RegExpPersonal, + ) +except Exception as err: # noqa: BLE001 — environment guard + raise unittest.SkipTest("PersonRelationshipFilter module unavailable: %s" % err) + + +def _make_name(first, surname, call="", nick="", title="", famnick=""): + """Build a Name with the given given/surname plus the secondary fields + under test (call name, nickname, title, family nickname).""" + name = Name() + name.set_first_name(first) + name.set_call_name(call) + name.set_nick_name(nick) + name.set_title(title) + name.famnick = famnick + surname_obj = Surname() + surname_obj.set_surname(surname) + name.set_surname_list([surname_obj]) + return name + + +def _make_database(): + db = make_database("sqlite") + db.load(":memory:") + return db + + +class PersonRelationshipFilterRulesTest(unittest.TestCase): + """ + Exercises the rules against a small, precisely-built family tree:: + + Frank Farnsworth (father) + Martha Miller (mother) + -> Carol Farnsworth (call "Caz", nick "Care", title "Dr.", + family nickname "Farns") + -> Dan Farnsworth + Carol Farnsworth + Sam Smith (spouse) + Owen Orphanage -- no recorded parents + """ + + @classmethod + def setUpClass(cls): + cls.db = _make_database() + with DbTxn("build test tree", cls.db) as trans: + father = Person() + father.set_gender(Person.MALE) + father.set_primary_name(_make_name("Frank", "Farnsworth")) + father_handle = cls.db.add_person(father, trans) + + mother = Person() + mother.set_gender(Person.FEMALE) + mother.set_primary_name(_make_name("Martha", "Miller")) + mother_handle = cls.db.add_person(mother, trans) + + carol = Person() + carol.set_gender(Person.FEMALE) + carol.set_primary_name( + _make_name( + "Carol", + "Farnsworth", + call="Caz", + nick="Care", + title="Dr.", + famnick="Farns", + ) + ) + carol_handle = cls.db.add_person(carol, trans) + + dan = Person() + dan.set_gender(Person.MALE) + dan.set_primary_name(_make_name("Dan", "Farnsworth")) + dan_handle = cls.db.add_person(dan, trans) + + orphan = Person() + orphan.set_gender(Person.MALE) + orphan.set_primary_name(_make_name("Owen", "Orphanage")) + orphan_handle = cls.db.add_person(orphan, trans) + + spouse = Person() + spouse.set_gender(Person.MALE) + spouse.set_primary_name(_make_name("Sam", "Smith")) + spouse_handle = cls.db.add_person(spouse, trans) + + parent_family = Family() + parent_family.set_father_handle(father_handle) + parent_family.set_mother_handle(mother_handle) + for child_handle in (carol_handle, dan_handle): + child_ref = ChildRef() + child_ref.set_reference_handle(child_handle) + parent_family.add_child_ref(child_ref) + parent_family_handle = cls.db.add_family(parent_family, trans) + + father.add_family_handle(parent_family_handle) + mother.add_family_handle(parent_family_handle) + carol.add_parent_family_handle(parent_family_handle) + dan.add_parent_family_handle(parent_family_handle) + + marriage = Family() + marriage.set_father_handle(spouse_handle) + marriage.set_mother_handle(carol_handle) + marriage_handle = cls.db.add_family(marriage, trans) + spouse.add_family_handle(marriage_handle) + carol.add_family_handle(marriage_handle) + + for person in (father, mother, carol, dan, orphan, spouse): + cls.db.commit_person(person, trans) + + cls.father = cls.db.get_person_from_handle(father_handle) + cls.mother = cls.db.get_person_from_handle(mother_handle) + cls.carol = cls.db.get_person_from_handle(carol_handle) + cls.dan = cls.db.get_person_from_handle(dan_handle) + cls.orphan = cls.db.get_person_from_handle(orphan_handle) + cls.spouse = cls.db.get_person_from_handle(spouse_handle) + + @classmethod + def tearDownClass(cls): + cls.db.close() + cls.db = None + + def _match_name(self, matcher_class, value, person, use_regex=False): + """Apply a bare RegExpPersonal/RegExpFamily rule to one person.""" + rule = matcher_class([value], use_regex=use_regex) + rule.requestprepare(self.db, None) + try: + return rule.apply_to_one(self.db, person) + finally: + rule.requestreset() + + def _match_relation(self, rule_class, value, person, matcher_class=RegExpPersonal): + """Apply a _HasNamedRelation rule (Father/Mother/Sibling/Child/Spouse) + to one person.""" + rule = rule_class([value], matcher_class, use_regex=False) + rule.requestprepare(self.db, None) + try: + return rule.apply_to_one(self.db, person) + finally: + rule.requestreset() + + # -- name field matching -------------------------------------------- + + def test_personal_matches_first_name(self): + self.assertTrue(self._match_name(RegExpPersonal, "Carol", self.carol)) + + def test_personal_matches_call_name(self): + """Regression: the personal field list used to list 'title' twice + instead of including the call name.""" + self.assertTrue(self._match_name(RegExpPersonal, "Caz", self.carol)) + + def test_personal_matches_nick_name(self): + self.assertTrue(self._match_name(RegExpPersonal, "Care", self.carol)) + + def test_personal_matches_title(self): + self.assertTrue(self._match_name(RegExpPersonal, "Dr", self.carol)) + + def test_personal_does_not_match_surname(self): + self.assertFalse(self._match_name(RegExpPersonal, "Farnsworth", self.carol)) + + def test_family_matches_surname(self): + self.assertTrue(self._match_name(RegExpFamily, "Farnsworth", self.carol)) + + def test_family_matches_famnick(self): + self.assertTrue(self._match_name(RegExpFamily, "Farns", self.carol)) + + def test_family_does_not_match_title(self): + """Regression: the family/surname field list used to include the + personal title field, causing false-positive surname matches.""" + self.assertFalse(self._match_name(RegExpFamily, "Dr", self.carol)) + + def test_family_does_not_match_call_name(self): + """Regression: the family/surname field list used to include the + personal call name field, causing false-positive surname matches.""" + self.assertFalse(self._match_name(RegExpFamily, "Caz", self.carol)) + + def test_regex_mode_matches_pattern(self): + self.assertTrue( + self._match_name(RegExpPersonal, "^Car", self.carol, use_regex=True) + ) + self.assertFalse( + self._match_name(RegExpPersonal, "^ar", self.carol, use_regex=True) + ) + + # -- relationship traversal ------------------------------------------ + + def test_has_named_father(self): + self.assertTrue(self._match_relation(HasNamedFather, "Frank", self.carol)) + self.assertFalse(self._match_relation(HasNamedFather, "Nobody", self.carol)) + + def test_has_named_mother(self): + self.assertTrue(self._match_relation(HasNamedMother, "Martha", self.carol)) + + def test_has_named_child(self): + self.assertTrue(self._match_relation(HasNamedChild, "Carol", self.father)) + self.assertTrue(self._match_relation(HasNamedChild, "Dan", self.mother)) + + def test_has_named_spouse(self): + self.assertTrue(self._match_relation(HasNamedSpouse, "Sam", self.carol)) + self.assertFalse(self._match_relation(HasNamedSpouse, "Frank", self.carol)) + + def test_is_sibling_of_named_sibling(self): + self.assertTrue( + self._match_relation(IsSiblingofNamedSibling, "Dan", self.carol) + ) + self.assertTrue( + self._match_relation(IsSiblingofNamedSibling, "Carol", self.dan) + ) + + def test_is_sibling_of_named_sibling_excludes_self(self): + self.assertFalse( + self._match_relation(IsSiblingofNamedSibling, "Carol", self.carol) + ) + + def test_is_sibling_of_named_sibling_with_no_parents_does_not_crash(self): + """Regression: applying this rule to a person with no recorded + parents used to raise HandleError from + get_family_from_handle(None).""" + self.assertFalse( + self._match_relation(IsSiblingofNamedSibling, "Anyone", self.orphan) + ) + + def test_has_name_matches_own_name(self): + self.assertTrue(self._match_relation(HasName, "Carol", self.carol)) + + def test_has_name_female_family_search_trawls_spouse_surname(self): + """A female's own family/surname search also matches her spouse's + surname (so 'Person' search finds her under her married name).""" + self.assertTrue( + self._match_relation( + HasName, "Smith", self.carol, matcher_class=RegExpFamily + ) + ) + self.assertFalse( + self._match_relation( + HasName, "Smith", self.father, matcher_class=RegExpFamily + ) + ) + + +if __name__ == "__main__": + unittest.main() From 06881f151f2634a8510982d9daf0fcb073d75c35 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Fri, 17 Jul 2026 13:24:48 -0700 Subject: [PATCH 62/65] PersonRelationshipFilter: fix help_url to reference this addon's own wiki page help_url pointed at Addon:AdvancedPersonFilter, a different addon name left over from this addon's origin, instead of Addon:PersonRelationshipFilter. Co-Authored-By: Claude Sonnet 5 --- PersonRelationshipFilter/PersonRelationshipFilter.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py b/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py index 0805f6f67..90381f9d8 100644 --- a/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py +++ b/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py @@ -34,5 +34,5 @@ navtypes=["Person"], authors=["Paul Womack", "Doug Blank"], authors_email=["doug.blank@gmail.com"], - help_url="Addon:AdvancedPersonFilter", + help_url="Addon:PersonRelationshipFilter", ) From 0f1623a4ff8d47c93c1e7d517149a31730eaef47 Mon Sep 17 00:00:00 2001 From: Douglas Blank Date: Thu, 23 Jul 2026 13:53:10 -0400 Subject: [PATCH 63/65] Apply suggestion from @dsblank --- .../tests/test_person_relationship_filter_rules.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py b/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py index cbbe68eb3..68317e3bf 100644 --- a/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py +++ b/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py @@ -45,16 +45,6 @@ import sys import unittest -# The addon imports Gtk at module load — skip cleanly if gi/Gtk are not -# available, mirroring what other addons' test suites do. -try: - import gi - - gi.require_version("Gtk", "3.0") - gi.require_version("Gdk", "3.0") -except (ImportError, ValueError, AttributeError) as err: - raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) - # ------------------------ # Gramps modules # ------------------------ From 76539a05dbd66e889e5f0fb0fcf5646d9116ded5 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sun, 26 Jul 2026 08:50:43 -0700 Subject: [PATCH 64/65] Fix ImportError in PersonRelationshipFilter test module import `from PersonRelationshipFilter import (HasName, ...)` breaks when unittest loads this file as `PersonRelationshipFilter.tests.test_...`, because by then the outer `PersonRelationshipFilter` is already a namespace package in sys.modules, so the bare import looks for each name as an attribute of the package instead of importing the PersonRelationshipFilter.py submodule that defines them. Use the fully-qualified `PersonRelationshipFilter.PersonRelationshipFilter` import path, matching the pattern already used in DataEntryGramplet/tests/test_data_entry_gramplet.py. Reported by GaryGriffin in gramps-project/addons-source#987. Co-Authored-By: Claude Sonnet 5 --- .../tests/test_person_relationship_filter_rules.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py b/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py index 68317e3bf..0b4ccb67f 100644 --- a/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py +++ b/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py @@ -48,9 +48,14 @@ # ------------------------ # Gramps modules # ------------------------ -# The addon directory goes on sys.path so ``import PersonRelationshipFilter`` -# resolves the flat ``PersonRelationshipFilter.py`` module directly (there is -# no wrapping package — the file lives right in the addon directory). +# Addon root goes on sys.path so ``from PersonRelationshipFilter. +# PersonRelationshipFilter import ...`` resolves the class/functions inside +# the addon module. The fully-qualified form matters: when unittest loads +# this file as ``PersonRelationshipFilter.tests.test_...``, the outer +# ``PersonRelationshipFilter`` is already a namespace package in +# ``sys.modules``, so a bare ``from PersonRelationshipFilter import X`` +# would look for ``X`` as an attribute of that namespace package instead of +# importing the ``PersonRelationshipFilter.py`` submodule that defines it. 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) @@ -68,7 +73,7 @@ from gramps.gen.db.utils import make_database from gramps.gen.lib import ChildRef, Family, Name, Person, Surname - from PersonRelationshipFilter import ( + from PersonRelationshipFilter.PersonRelationshipFilter import ( HasName, HasNamedChild, HasNamedFather, From d8c0f4f1a4ed76cf0eab87e2503abf9ccad6c66c Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Sun, 26 Jul 2026 11:08:49 -0700 Subject: [PATCH 65/65] Merge PersonRelationshipFilter#987 --- .../PersonRelationshipFilter.gpr.py | 2 +- PersonRelationshipFilter/po/template.pot | 117 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 PersonRelationshipFilter/po/template.pot diff --git a/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py b/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py index 90381f9d8..bed0adf9c 100644 --- a/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py +++ b/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py @@ -24,7 +24,7 @@ id="Person Relationship Filter", name=_("Person Relationship Filter"), description=_("Gramplet providing a person filter on relationships"), - version="1.0.0", + version = '1.0.1', gramps_target_version="6.1", status=STABLE, fname="PersonRelationshipFilter.py", diff --git a/PersonRelationshipFilter/po/template.pot b/PersonRelationshipFilter/po/template.pot new file mode 100644 index 000000000..77083244b --- /dev/null +++ b/PersonRelationshipFilter/po/template.pot @@ -0,0 +1,117 @@ +# 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-26 11:05-0700\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" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:49 +msgid "Text:" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:50 +msgid "People with a name matching " +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:52 +msgid "" +"Matches people's names containing a substring or matching a regular " +"expression" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:55 +msgid "General filters" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:81 +msgid "Filter name:" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:82 +msgid "Children of name match" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:83 +msgid "Family filters" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:84 +msgid "Matches children of anybody with a given name" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:198 +msgid "given" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:200 +msgid "surname" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:247 +msgid "Person" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:248 +msgid "Father" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:249 +msgid "Mother" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:250 +msgid "Spouse" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:251 +msgid "Sibling 1" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:252 +msgid "Sibling 2" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:253 +msgid "Child 1" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:254 +msgid "Child 2" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:258 +msgid "Use regular expressions" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:282 +msgid "Probably Alive" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.py:284 +#, python-format +msgid "example: '%(msg1)s' or '%(msg2)s'" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.gpr.py:25 +msgid "Person Relationship Filter" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.gpr.py:26 +msgid "Gramplet providing a person filter on relationships" +msgstr "" + +#: PersonRelationshipFilter/PersonRelationshipFilter.gpr.py:33 +msgid "Relationship Filter" +msgstr ""