diff --git a/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py b/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py new file mode 100644 index 000000000..7a9ab947f --- /dev/null +++ b/PersonRelationshipFilter/PersonRelationshipFilter.gpr.py @@ -0,0 +1,38 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2015 Nick Hall +# Copyright (C) 2024 Paul Womack (BugBear) +# Copyright (C) 2026 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +register( + GRAMPLET, + id="Person Relationship Filter", + name=_("Person Relationship Filter"), + description=_("Gramplet providing a person filter on relationships"), + version="1.0.0", + gramps_target_version="5.1", + status=STABLE, + fname="PersonRelationshipFilter.py", + height=200, + gramplet="PersonRelationshipFilter", + gramplet_title=_("Relationship Filter"), + navtypes=["Person"], + authors=["Paul Womack", "Doug Blank"], + authors_email=["doug.blank@gmail.com"], + help_url="Addon:PersonRelationshipFilter", +) diff --git a/PersonRelationshipFilter/PersonRelationshipFilter.py b/PersonRelationshipFilter/PersonRelationshipFilter.py new file mode 100644 index 000000000..79e769ad7 --- /dev/null +++ b/PersonRelationshipFilter/PersonRelationshipFilter.py @@ -0,0 +1,363 @@ +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2010 Doug Blank +# Copyright (C) 2011 Nick Hall +# Copyright (C) 2011 Tim G L Lyons +# Copyright (C) 2024 Paul Womack (BugBear) +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- + +from gi.repository import Gtk + +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.plug import Gramplet + +from gramps.gen.filters.rules import Rule +from gramps.gen.filters.rules.person import ProbablyAlive +from gramps.gen.lib.person import Person +from gramps.gen.lib import Date +from gramps.gen.datehandler import displayer +from gramps.gen.filters import GenericFilter +from gramps.gui import widgets +from gramps.gui.filters.sidebar import SidebarFilter + +_ = glocale.translation.gettext + + +class _RegExpNameList(Rule): + """Rule that checks for full or partial name matches""" + + labels = [_("Text:")] + name = _("People with a name matching ") + description = _( + "Matches people's names containing a substring or " + "matching a regular expression" + ) + category = _("General filters") + allow_regex = True + + def field_list(self, name): + raise NotImplementedError + + def apply(self, db, person): + for name in [person.primary_name] + person.alternate_names: + for field in self.field_list(name): + if self.match_substring(0, field): + return True + else: + return False + + +class RegExpPersonal(_RegExpNameList): + def field_list(self, name): + return [name.first_name, name.title, name.call, name.nick] + + +class RegExpFamily(_RegExpNameList): + def field_list(self, name): + return [name.get_surname(), name.suffix, name.famnick] + + +class _HasNamedRelation(Rule): + labels = [_("Filter name:")] + name = _("Children of name match") + category = _("Family filters") + description = _("Matches children of anybody with a given name") + + def __init__(self, arg, name_matcher, use_regex=False): + super().__init__(arg, use_regex) + self.name_matcher = name_matcher(arg, use_regex) + + def prepare(self, db, user): + self.name_matcher.requestprepare(db, user) + + def reset(self): + self.name_matcher.requestreset() + + def get_rel_list(self, db, person): + raise NotImplementedError + + def get_spouse_list(self, db, person): + handles = [] + for fam_id in person.family_list: + fam = db.get_family_from_handle(fam_id) + if fam: + for spouse_id in [fam.father_handle, fam.mother_handle]: + if not spouse_id: + continue + if spouse_id == person.handle: + continue + handles.append(spouse_id) + return handles + + def apply(self, db, person): + for rel_id in self.get_rel_list(db, person): + if rel_id: + rel = db.get_person_from_handle(rel_id) + if self.name_matcher.apply(db, rel): + return True + return False + + +class _HasNamedParent(_HasNamedRelation): + def get_parent_families(self, db, person): + families = [] + for fam_id in person.parent_family_list: + fam = db.get_family_from_handle(fam_id) + if fam: + families.append(fam) + return families + + +class HasNamedFather(_HasNamedParent): + def get_rel_list(self, db, person): + return map( + lambda fam: fam.get_father_handle(), self.get_parent_families(db, person) + ) + + +class HasNamedMother(_HasNamedParent): + def get_rel_list(self, db, person): + return map( + lambda fam: fam.get_mother_handle(), self.get_parent_families(db, person) + ) + + +class IsSiblingofNamedSibling(_HasNamedRelation): + def get_rel_list(self, db, person): + handles = [] + fam_id = person.get_main_parents_family_handle() # or all families, per above? + fam = db.get_family_from_handle(fam_id) if fam_id else None + if fam: + for child_ref in fam.get_child_ref_list(): + if child_ref and child_ref.ref != person.handle: + handles.append(child_ref.ref) + return handles + + +class HasNamedChild(_HasNamedRelation): + def get_rel_list(self, db, person): + handles = [] + for fam_id in person.family_list: + fam = db.get_family_from_handle(fam_id) + if fam: + for child_ref in fam.get_child_ref_list(): + if child_ref: + handles.append(child_ref.ref) + return handles + + +class HasNamedSpouse(_HasNamedRelation): + def get_rel_list(self, db, person): + return self.get_spouse_list(db, person) + + +class HasName(_HasNamedRelation): + def get_rel_list(self, db, person): + if person.gender == Person.FEMALE and isinstance( + self.name_matcher, RegExpFamily + ): + # for female surnames, we want to trawl the spouses surnames + handles = self.get_spouse_list(db, person) + handles.append(person.handle) + return handles + else: + return [person.handle] + + +def extract_text(entry_widget): + """ + Extract the text from the entry widget, strips off any extra spaces. + """ + return str(entry_widget.get_text().strip()) + + +# leverage to split the name into fore and aft +class SearchableNamePair: + def __init__(self, label, rule_class): + self.widget_personal = widgets.BasicEntry() + self.widget_personal.set_placeholder_text(_("given")) + self.widget_family = widgets.BasicEntry() + self.widget_family.set_placeholder_text(_("surname")) + self.label = label + self.rule_class = rule_class + + def place(self, sidebar): + # container.add_text_entry(self.label, self.widget_personal) + # self.add_text_entry(container, self.label, self.widget_personal) + # unrolled + + sidebar.grid.attach(widgets.BasicLabel(self.label), 1, sidebar.position, 1, 1) + + self.widget_personal.set_hexpand(True) + sidebar.grid.attach(self.widget_personal, 2, sidebar.position, 1, 1) + self.widget_personal.connect("key-press-event", sidebar.key_press) + + self.widget_family.set_hexpand(True) + sidebar.grid.attach(self.widget_family, 3, sidebar.position, 1, 1) + self.widget_family.connect("key-press-event", sidebar.key_press) + sidebar.position += 1 + + def clear(self): + self.widget_personal.set_text("") + self.widget_family.set_text("") + + def _add_to_filter(self, generic_filter, regex, widget, search_class): + v = extract_text(widget) + if v: + rule = self.rule_class([v], search_class, use_regex=regex) + generic_filter.add_rule(rule) + + def add_to_filter(self, generic_filter, regex): + self._add_to_filter(generic_filter, regex, self.widget_personal, RegExpPersonal) + self._add_to_filter(generic_filter, regex, self.widget_family, RegExpFamily) + + +# ------------------------------------------------------------------------- +# +# PersonSidebarFilter class +# +# ------------------------------------------------------------------------- +class PersonSidebarFilter(SidebarFilter): + + def __init__(self, dbstate, uistate, clicked): + self.clicked_func = clicked + self.sensitive_regex = False + + self.names = [ + SearchableNamePair(_("Person"), HasName), + SearchableNamePair(_("Father"), HasNamedFather), + SearchableNamePair(_("Mother"), HasNamedMother), + SearchableNamePair(_("Spouse"), HasNamedSpouse), + SearchableNamePair(_("Sibling 1"), IsSiblingofNamedSibling), + SearchableNamePair(_("Sibling 2"), IsSiblingofNamedSibling), + SearchableNamePair(_("Child 1"), HasNamedChild), + SearchableNamePair(_("Child 2"), HasNamedChild), + ] + self.filter_alive = widgets.DateEntry(uistate, []) + + self.filter_regex = Gtk.CheckButton(label=_("Use regular expressions")) + + SidebarFilter.__init__(self, dbstate, uistate, "Person") + + def create_widget(self): + exdate1 = Date() + exdate2 = Date() + exdate1.set( + Date.QUAL_NONE, + Date.MOD_RANGE, + Date.CAL_GREGORIAN, + (0, 0, 1800, False, 0, 0, 1900, False), + ) + exdate2.set( + Date.QUAL_NONE, Date.MOD_BEFORE, Date.CAL_GREGORIAN, (0, 0, 1850, False) + ) + + msg1 = displayer.display(exdate1) + msg2 = displayer.display(exdate2) + + for w in self.names: + w.place(self) + + self.add_text_entry( + _("Probably Alive"), + self.filter_alive, + _("example: '%(msg1)s' or '%(msg2)s'") % {"msg1": msg1, "msg2": msg2}, + ) + self.add_regex_entry(self.filter_regex) + + def clear(self, obj): + for w in self.names: + w.clear() + self.filter_alive.set_text("") + + def get_filter(self): + """ + Extracts the text strings from the sidebar, and uses them to build up + a new filter. + """ + + regex = self.filter_regex.get_active() + + # build a GenericFilter + generic_filter = GenericFilter() + for w in self.names: + w.add_to_filter(generic_filter, regex) + + alive = extract_text(self.filter_alive) + if alive: + rule = ProbablyAlive([alive]) + generic_filter.add_rule(rule) + + return generic_filter + + +# ------------------------------------------------------------------------- +# +# Filter class +# +# ------------------------------------------------------------------------- +class Filter(Gramplet): + """ + The base class for all filter gramplets. + """ + + FILTER_CLASS: type[SidebarFilter] | None = None + + def init(self): + self._building = False + self.filter = self.FILTER_CLASS( + self.dbstate, self.uistate, self.__filter_clicked + ) + self.widget = self.filter.get_widget() + self.gui.get_container_widget().remove(self.gui.textview) + self.gui.get_container_widget().add(self.widget) + self.widget.show_all() + + def __filter_clicked(self): + """ + Called when the filter apply button is clicked. + """ + # Guard against re-entrant clicks (e.g. pressing Enter again before + # the previous build_tree() has finished), which can otherwise hit + # the person tree view mid-rebuild. + if self._building: + return + self._building = True + try: + self.gui.view.generic_filter = self.filter.get_filter() + self.gui.view.build_tree() + finally: + self._building = False + + +# ------------------------------------------------------------------------- +# +# PersonFilter class +# +# ------------------------------------------------------------------------- +class PersonRelationshipFilter(Filter): + """ + A gramplet providing a Person Filter. + """ + + FILTER_CLASS = PersonSidebarFilter diff --git a/PersonRelationshipFilter/tests/__init__.py b/PersonRelationshipFilter/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py b/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py new file mode 100644 index 000000000..45b600a15 --- /dev/null +++ b/PersonRelationshipFilter/tests/test_person_relationship_filter_rules.py @@ -0,0 +1,326 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Unit tests for the relationship-matching filter rules defined in +``PersonRelationshipFilter.py``. + +The tests build a small family tree directly in an in-memory database +(no dependency on the shared ``example.gramps`` fixture) so each +rule's relationship traversal and name-field matching can be checked +precisely. Two regressions are covered explicitly: + +* ``IsSiblingofNamedSibling`` used to raise ``HandleError`` when + applied to a person with no recorded parents, because + ``get_family_from_handle(None)`` raises rather than returning + ``None``. +* ``RegExpPersonal``/``RegExpFamily`` searched mismatched ``Name`` + fields (``title`` was listed twice in the personal field list, and + ``call`` name was searched by the family/surname rule instead), so + personal search never matched a person's call name and surname + search could false-positive on an unrelated title or call name. +""" + +# ------------------------ +# Python modules +# ------------------------ +import os +import sys +import unittest + +# The addon imports Gtk at module load — skip cleanly if gi/Gtk are not +# available, mirroring what other addons' test suites do. +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError, AttributeError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +# ------------------------ +# Gramps modules +# ------------------------ +# The addon directory goes on sys.path so ``import PersonRelationshipFilter`` +# resolves the flat ``PersonRelationshipFilter.py`` module directly (there is +# no wrapping package — the file lives right in the addon directory). +ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ADDON_DIR not in sys.path: + sys.path.insert(0, ADDON_DIR) + +try: + import gramps +except ImportError as err: + raise unittest.SkipTest("gramps package not available: %s" % err) + +if "GRAMPS_RESOURCES" not in os.environ: + os.environ["GRAMPS_RESOURCES"] = os.path.dirname(os.path.dirname(gramps.__file__)) + +try: + from gramps.gen.db import DbTxn + from gramps.gen.db.utils import make_database + from gramps.gen.lib import ChildRef, Family, Name, Person, Surname + + from PersonRelationshipFilter import ( + HasName, + HasNamedChild, + HasNamedFather, + HasNamedMother, + HasNamedSpouse, + IsSiblingofNamedSibling, + RegExpFamily, + RegExpPersonal, + ) +except Exception as err: # noqa: BLE001 — environment guard + raise unittest.SkipTest("PersonRelationshipFilter module unavailable: %s" % err) + + +def _make_name(first, surname, call="", nick="", title="", famnick=""): + """Build a Name with the given given/surname plus the secondary fields + under test (call name, nickname, title, family nickname).""" + name = Name() + name.set_first_name(first) + name.set_call_name(call) + name.set_nick_name(nick) + name.set_title(title) + name.famnick = famnick + surname_obj = Surname() + surname_obj.set_surname(surname) + name.set_surname_list([surname_obj]) + return name + + +def _make_database(): + db = make_database("sqlite") + db.load(":memory:") + return db + + +class PersonRelationshipFilterRulesTest(unittest.TestCase): + """ + Exercises the rules against a small, precisely-built family tree:: + + Frank Farnsworth (father) + Martha Miller (mother) + -> Carol Farnsworth (call "Caz", nick "Care", title "Dr.", + family nickname "Farns") + -> Dan Farnsworth + Carol Farnsworth + Sam Smith (spouse) + Owen Orphanage -- no recorded parents + """ + + @classmethod + def setUpClass(cls): + cls.db = _make_database() + with DbTxn("build test tree", cls.db) as trans: + father = Person() + father.set_gender(Person.MALE) + father.set_primary_name(_make_name("Frank", "Farnsworth")) + father_handle = cls.db.add_person(father, trans) + + mother = Person() + mother.set_gender(Person.FEMALE) + mother.set_primary_name(_make_name("Martha", "Miller")) + mother_handle = cls.db.add_person(mother, trans) + + carol = Person() + carol.set_gender(Person.FEMALE) + carol.set_primary_name( + _make_name( + "Carol", + "Farnsworth", + call="Caz", + nick="Care", + title="Dr.", + famnick="Farns", + ) + ) + carol_handle = cls.db.add_person(carol, trans) + + dan = Person() + dan.set_gender(Person.MALE) + dan.set_primary_name(_make_name("Dan", "Farnsworth")) + dan_handle = cls.db.add_person(dan, trans) + + orphan = Person() + orphan.set_gender(Person.MALE) + orphan.set_primary_name(_make_name("Owen", "Orphanage")) + orphan_handle = cls.db.add_person(orphan, trans) + + spouse = Person() + spouse.set_gender(Person.MALE) + spouse.set_primary_name(_make_name("Sam", "Smith")) + spouse_handle = cls.db.add_person(spouse, trans) + + parent_family = Family() + parent_family.set_father_handle(father_handle) + parent_family.set_mother_handle(mother_handle) + for child_handle in (carol_handle, dan_handle): + child_ref = ChildRef() + child_ref.set_reference_handle(child_handle) + parent_family.add_child_ref(child_ref) + parent_family_handle = cls.db.add_family(parent_family, trans) + + father.add_family_handle(parent_family_handle) + mother.add_family_handle(parent_family_handle) + carol.add_parent_family_handle(parent_family_handle) + dan.add_parent_family_handle(parent_family_handle) + + marriage = Family() + marriage.set_father_handle(spouse_handle) + marriage.set_mother_handle(carol_handle) + marriage_handle = cls.db.add_family(marriage, trans) + spouse.add_family_handle(marriage_handle) + carol.add_family_handle(marriage_handle) + + for person in (father, mother, carol, dan, orphan, spouse): + cls.db.commit_person(person, trans) + + cls.father = cls.db.get_person_from_handle(father_handle) + cls.mother = cls.db.get_person_from_handle(mother_handle) + cls.carol = cls.db.get_person_from_handle(carol_handle) + cls.dan = cls.db.get_person_from_handle(dan_handle) + cls.orphan = cls.db.get_person_from_handle(orphan_handle) + cls.spouse = cls.db.get_person_from_handle(spouse_handle) + + @classmethod + def tearDownClass(cls): + cls.db.close() + cls.db = None + + def _match_name(self, matcher_class, value, person, use_regex=False): + """Apply a bare RegExpPersonal/RegExpFamily rule to one person.""" + rule = matcher_class([value], use_regex=use_regex) + rule.requestprepare(self.db, None) + try: + return rule.apply(self.db, person) + finally: + rule.requestreset() + + def _match_relation(self, rule_class, value, person, matcher_class=RegExpPersonal): + """Apply a _HasNamedRelation rule (Father/Mother/Sibling/Child/Spouse) + to one person.""" + rule = rule_class([value], matcher_class, use_regex=False) + rule.requestprepare(self.db, None) + try: + return rule.apply(self.db, person) + finally: + rule.requestreset() + + # -- name field matching -------------------------------------------- + + def test_personal_matches_first_name(self): + self.assertTrue(self._match_name(RegExpPersonal, "Carol", self.carol)) + + def test_personal_matches_call_name(self): + """Regression: the personal field list used to list 'title' twice + instead of including the call name.""" + self.assertTrue(self._match_name(RegExpPersonal, "Caz", self.carol)) + + def test_personal_matches_nick_name(self): + self.assertTrue(self._match_name(RegExpPersonal, "Care", self.carol)) + + def test_personal_matches_title(self): + self.assertTrue(self._match_name(RegExpPersonal, "Dr", self.carol)) + + def test_personal_does_not_match_surname(self): + self.assertFalse(self._match_name(RegExpPersonal, "Farnsworth", self.carol)) + + def test_family_matches_surname(self): + self.assertTrue(self._match_name(RegExpFamily, "Farnsworth", self.carol)) + + def test_family_matches_famnick(self): + self.assertTrue(self._match_name(RegExpFamily, "Farns", self.carol)) + + def test_family_does_not_match_title(self): + """Regression: the family/surname field list used to include the + personal title field, causing false-positive surname matches.""" + self.assertFalse(self._match_name(RegExpFamily, "Dr", self.carol)) + + def test_family_does_not_match_call_name(self): + """Regression: the family/surname field list used to include the + personal call name field, causing false-positive surname matches.""" + self.assertFalse(self._match_name(RegExpFamily, "Caz", self.carol)) + + def test_regex_mode_matches_pattern(self): + self.assertTrue( + self._match_name(RegExpPersonal, "^Car", self.carol, use_regex=True) + ) + self.assertFalse( + self._match_name(RegExpPersonal, "^ar", self.carol, use_regex=True) + ) + + # -- relationship traversal ------------------------------------------ + + def test_has_named_father(self): + self.assertTrue(self._match_relation(HasNamedFather, "Frank", self.carol)) + self.assertFalse(self._match_relation(HasNamedFather, "Nobody", self.carol)) + + def test_has_named_mother(self): + self.assertTrue(self._match_relation(HasNamedMother, "Martha", self.carol)) + + def test_has_named_child(self): + self.assertTrue(self._match_relation(HasNamedChild, "Carol", self.father)) + self.assertTrue(self._match_relation(HasNamedChild, "Dan", self.mother)) + + def test_has_named_spouse(self): + self.assertTrue(self._match_relation(HasNamedSpouse, "Sam", self.carol)) + self.assertFalse(self._match_relation(HasNamedSpouse, "Frank", self.carol)) + + def test_is_sibling_of_named_sibling(self): + self.assertTrue( + self._match_relation(IsSiblingofNamedSibling, "Dan", self.carol) + ) + self.assertTrue( + self._match_relation(IsSiblingofNamedSibling, "Carol", self.dan) + ) + + def test_is_sibling_of_named_sibling_excludes_self(self): + self.assertFalse( + self._match_relation(IsSiblingofNamedSibling, "Carol", self.carol) + ) + + def test_is_sibling_of_named_sibling_with_no_parents_does_not_crash(self): + """Regression: applying this rule to a person with no recorded + parents used to raise HandleError from + get_family_from_handle(None).""" + self.assertFalse( + self._match_relation(IsSiblingofNamedSibling, "Anyone", self.orphan) + ) + + def test_has_name_matches_own_name(self): + self.assertTrue(self._match_relation(HasName, "Carol", self.carol)) + + def test_has_name_female_family_search_trawls_spouse_surname(self): + """A female's own family/surname search also matches her spouse's + surname (so 'Person' search finds her under her married name).""" + self.assertTrue( + self._match_relation( + HasName, "Smith", self.carol, matcher_class=RegExpFamily + ) + ) + self.assertFalse( + self._match_relation( + HasName, "Smith", self.father, matcher_class=RegExpFamily + ) + ) + + +if __name__ == "__main__": + unittest.main()