From b1b5a205699a48351de76eed289af06a87873c1b Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 6 Jul 2026 11:09:45 -0700 Subject: [PATCH 01/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] =?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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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",