diff --git a/CombinedView/combinedview.gpr.py b/CombinedView/combinedview.gpr.py index 75a28cf9d..4470b1291 100644 --- a/CombinedView/combinedview.gpr.py +++ b/CombinedView/combinedview.gpr.py @@ -23,7 +23,7 @@ id="combinedview", name=_("Combined"), description=_("A view showing relationships and events for a person"), - version = '2.0.16', + version = '2.0.17', gramps_target_version="6.0", status=STABLE, fname="combinedview.py", diff --git a/CombinedView/combinedview.py b/CombinedView/combinedview.py index 4b5079640..7e3d9510f 100644 --- a/CombinedView/combinedview.py +++ b/CombinedView/combinedview.py @@ -474,7 +474,8 @@ def filter_editor(self, *obj): return def edit_active(self, *obj): - self.active_page.edit_active() + if self.active_page is not None: + self.active_page.edit_active() def change_db(self, db): #reset the connects @@ -498,9 +499,18 @@ def object_changed(self, obj_type, handle): def change_object(self, obj_tuple): if obj_tuple is None: + # No active object, e.g. the family tree was closed. Clear the + # display instead of leaving people from the closed tree on + # screen: their handles no longer resolve, so editing one would + # raise HandleError (bug #12572, bug #14226). + for page in self.pages.values(): + page.disable_actions(self.uimanager) + self.uimanager.update_menu() + list(map(self.header.remove, self.header.get_children())) + list(map(self.stack.remove, self.stack.get_children())) + self.active_page = None return - if self.redrawing: return False self.redrawing = True diff --git a/DescendantBooks/DescendantBooks.gpr.py b/DescendantBooks/DescendantBooks.gpr.py index 2095dd824..772f3dbb4 100644 --- a/DescendantBooks/DescendantBooks.gpr.py +++ b/DescendantBooks/DescendantBooks.gpr.py @@ -21,7 +21,7 @@ id="DescendantBook", name=_("Descendant Book"), description=_("Produces one or more descendant reports based on a supplied query."), - version = '1.1.35', + version = '1.1.36', gramps_target_version="6.0", status=STABLE, fname="DescendantBookReport.py", @@ -42,7 +42,7 @@ description=_( "Produces one or more detailed descendant reports based on a supplied query." ), - version = '1.1.35', + version = '1.1.36', gramps_target_version="6.0", status=STABLE, fname="DetailedDescendantBookReport.py", diff --git a/DescendantBooks/DetailedDescendantBookReport.py b/DescendantBooks/DetailedDescendantBookReport.py index 0ac2cec21..a91b7b213 100644 --- a/DescendantBooks/DetailedDescendantBookReport.py +++ b/DescendantBooks/DetailedDescendantBookReport.py @@ -788,7 +788,35 @@ def write_report_ref(self, person, main_person): def append_event(self, event_ref, family = False): - (repno, gen, per, mate, name) = self.report_app_ref[self.phandle][0] # get first reference to the person + # The pre-pass under `if self.dubperson:` in write_report() is the + # only path that fills report_app_ref. When "Omit duplicate + # ancestors" is unselected but an index option (Index of Dates / + # Places / Names) is enabled, the indexes still call append_event + # for every event — and a missing handle here raised KeyError + # (bug 14051) or AttributeError (bug 12857, before the partial + # fix). Populate the entry on first encounter so subsequent + # encounters (which happen when omit-duplicates is off and the + # same person appears in more than one per-ascendant report) + # keep using the first encounter's coordinates — matching the + # `[0]` semantic the omit-duplicates path emits, so an index + # entry resolves to the same canonical document position + # regardless of the omit-duplicates setting. + if (self.phandle not in self.report_app_ref + or not self.report_app_ref[self.phandle]): + if self.phandle in self.dnumber: + per = self.dnumber[self.phandle] + elif self.phandle in self.dmates \ + and self.dmates[self.phandle] in self.dnumber: + per = self.dnumber[self.dmates[self.phandle]] + else: + per = "?" + person = self.database.get_person_from_handle(self.phandle) + name = person.get_primary_name().get_name() + self.report_app_ref[self.phandle] = [ + (self.report_count, self.generation + 1, per, False, name) + ] + (repno, gen, per, mate, name) = \ + self.report_app_ref[self.phandle][0] text = "" event = self.database.get_event_from_handle(event_ref.ref) diff --git a/DescendantBooks/tests/__init__.py b/DescendantBooks/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/DescendantBooks/tests/test_append_event_index_without_omit_duplicates.py b/DescendantBooks/tests/test_append_event_index_without_omit_duplicates.py new file mode 100644 index 000000000..6077c4a67 --- /dev/null +++ b/DescendantBooks/tests/test_append_event_index_without_omit_duplicates.py @@ -0,0 +1,296 @@ +# +# 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 Mantis bug 14051. + +`DetailedDescendantBookReport.append_event` reads +``self.report_app_ref[self.phandle][0]`` to build the "Ref: ..." text +that prefixes each index-of-dates / index-of-places entry. The lookup +table ``report_app_ref`` is only filled by the up-front reference pass +guarded by ``if self.dubperson:`` (the "omit duplicate ancestors" +option). When that option is unselected but Index of Dates or Index of +Places is enabled, ``append_event`` still runs from +``write_person_info`` / ``__write_family_events`` and raises against +the empty / missing table — historically an ``AttributeError`` (before +the partial fix for bugs 12857/12859 unconditionally initialised the +dict), now a ``KeyError`` on the current handle. + +This test exercises ``append_event`` in isolation with the settings +combination from bug 14051 (``dubperson=False`` + indexes enabled) and +asserts it completes without raising and that the indexes receive an +entry referencing the current writing-pass context. +""" + +import os +import sys +import types +import unittest +from unittest import mock + +# Make the addon importable from its parent directory, matching the +# JSON / TMGimporter / Form test convention. Required when this test +# is loaded by its dotted path (`DescendantBooks.tests.`). +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Stub RunReport so importing DetailedDescendantBookReport does not pull +# in gramps.gui (which requires gi.require_version('Gtk', '4.0') and a +# display). The append_event method does not touch the report dialog. +_run_report_stub = types.ModuleType("RunReport") +_run_report_stub.RunReport = lambda *a, **kw: None +sys.modules.setdefault("RunReport", _run_report_stub) + +import DetailedDescendantBookReport as ddbr + + +def _make_event(year, place_handle=""): + """Build a stub event_ref / event pair usable by append_event.""" + date_obj = mock.MagicMock() + date_obj.get_year.return_value = year + + event = mock.MagicMock() + event.get_date_object.return_value = date_obj + event.get_place_handle.return_value = place_handle + event.get_type.return_value = "Birth" + + event_ref = mock.MagicMock() + event_ref.ref = "EVENT-HANDLE" + return event_ref, event, date_obj + + +class TestAppendEventWithoutOmitDuplicates(unittest.TestCase): + """append_event must not crash when the reference pre-pass is skipped. + + Bug 14051: with ``Omit duplicate ancestors`` off and an index option + on, the pre-pass under ``if self.dubperson:`` never populates + ``report_app_ref``, but ``append_event`` is still invoked for every + event because the indexes are enabled. + """ + + def _make_report(self): + # Build a DetailedDescendantBookReport without calling __init__ + # (the real __init__ takes options/user/database and constructs + # a Bibliography etc., none of which append_event uses). + report = ddbr.DetailedDescendantBookReport.__new__( + ddbr.DetailedDescendantBookReport + ) + + # Minimum attributes append_event reads. Mirrors what + # write_report would set up before invoking the writing pass. + report.report_app_ref = {} # the bug's empty table + report.index_of_dates = {} + report.index_of_places = {} + report.dnumber = {"P1": "1"} + report.dmates = {} + report.report_count = 3 # third ascendant tree + report.generation = 1 # second generation in it + report.phandle = "P1" + report.inc_index_of_dates = True + report.inc_index_of_places = True + + # _ is the report's translation callable; sgettext-style — just + # return the string verbatim so we can assert on it. + report._ = lambda s: s + # _get_date / _get_type return strings; stub to identity-like. + report._get_date = lambda d: "1850" + report._get_type = lambda t: str(t) + + # Stub the database so place lookups return a known title. + place = mock.MagicMock() + place.get_title.return_value = "Dublin" + db = mock.MagicMock() + db.get_event_from_handle.return_value = None # set per-test + db.get_place_from_handle.return_value = place + db.get_person_from_handle.return_value = mock.MagicMock( + get_primary_name=lambda: mock.MagicMock(get_name=lambda: "John Doe") + ) + report.database = db + return report + + def test_append_event_does_not_crash_without_prepopulated_table(self): + """append_event with an empty report_app_ref must not raise. + + Failure pre-fix: raises ``KeyError: 'P1'`` (or, on addon versions + prior to the partial fix for 12857/12859, ``AttributeError`` on + ``report_app_ref``) — the exact crash signature from bug 14051. + """ + report = self._make_report() + event_ref, event, _ = _make_event(year=1850, place_handle="PLACE-HANDLE") + report.database.get_event_from_handle.return_value = event + + # This is the call the report system makes from + # write_person_info when (inc_index_of_dates or inc_index_of_places) + # is enabled. It must not raise. + report.append_event(event_ref) + + # The indexes should have been populated with an entry tagged + # with the writing-pass coordinates at the FIRST encounter of + # this handle — (report_count=3, generation+1=2, + # dnumber[phandle]=1) — matching the `[0]` semantic the + # omit-duplicates path emits (see + # TestRefSemanticsParityWithOmitDuplicates below). + self.assertIn(1850, report.index_of_dates, + "Year 1850 should be indexed") + self.assertIn("Dublin", report.index_of_places, + "Place 'Dublin' should be indexed") + + date_entries = list(report.index_of_dates[1850].values()) + self.assertTrue(any("Ref: 3 2 1" in entry for entry in date_entries), + "Index entry should reference first-encounter " + "coordinates (3 2 1); got %r" % date_entries) + + def test_append_event_for_mate_without_dnumber(self): + """A mate's event where the mate is not in dnumber must not crash. + + ``__write_mate`` sets ``self.phandle = mate_handle`` when the + mate is being printed in full; the mate may not have a + ``dnumber`` entry (only descendants do). append_event must + still produce an entry with a sensible fallback Ref rather + than KeyError on ``self.dnumber[mate_handle]``. + """ + report = self._make_report() + report.phandle = "MATE-NOT-IN-DNUMBER" + # dmates is empty in __write_mate's Branch A (the not-inc_materef + # path that sets phandle = mate_handle). Leave it that way. + event_ref, event, _ = _make_event(year=1860) + report.database.get_event_from_handle.return_value = event + + report.append_event(event_ref) + + # The index should still receive an entry; the Ref's "per" field + # falls back gracefully when the mate's dnumber isn't known. + self.assertIn(1860, report.index_of_dates) + + +class TestRefSemanticsParityWithOmitDuplicates(unittest.TestCase): + """When the same person/event is processed in multiple per-ascendant + reports (the dubperson=False case with multi-ascendant trees), the + index entry for that event must resolve to the SAME (repno, gen, + per) coordinates that the omit-duplicates path would emit — i.e. + the FIRST encounter's coordinates. Otherwise the dubperson-on and + dubperson-off documents' index entries point to different sections + of their respective documents for the same event. + + This test simulates two encounters of person P in reports 1 and 2 + against append_event, and compares the resulting index Ref to what + the dubperson-on path's `[0]` read of report_app_ref would have + produced (the first encounter). + """ + + def _make_report(self): + report = ddbr.DetailedDescendantBookReport.__new__( + ddbr.DetailedDescendantBookReport + ) + report.report_app_ref = {} + report.index_of_dates = {} + report.index_of_places = {} + report.dnumber = {} + report.dmates = {} + report.inc_index_of_dates = True + report.inc_index_of_places = True + report._ = lambda s: s + report._get_date = lambda d: "1850-01-15" + report._get_type = lambda t: str(t) + place = mock.MagicMock() + place.get_title.return_value = "Dublin" + db = mock.MagicMock() + db.get_place_from_handle.return_value = place + db.get_person_from_handle.return_value = mock.MagicMock( + get_primary_name=lambda: mock.MagicMock(get_name=lambda: "John Doe") + ) + report.database = db + return report + + def _make_event(self, year=1850, place_handle="PLACE-DUBLIN"): + date_obj = mock.MagicMock() + date_obj.get_year.return_value = year + event = mock.MagicMock() + event.get_date_object.return_value = date_obj + event.get_place_handle.return_value = place_handle + event.get_type.return_value = "Birth" + event_ref = mock.MagicMock() + event_ref.ref = "EVENT-HANDLE" + return event_ref, event + + def test_dubperson_on_baseline_ref_is_first_encounter(self): + """Baseline: with the pre-pass populated as it is when + omit-duplicates is on, append_event reads `[0]` — the first + encounter — and writes that Ref into the index.""" + report = self._make_report() + # Simulate the dubperson-on pre-pass having populated + # report_app_ref for person P with the first encounter + # (report=1, generation+1=2, dnumber["P"]="3"). + report.report_app_ref["P"] = [(1, 2, "3", False, "John Doe")] + report.report_count = 1 + report.generation = 1 + report.dnumber = {"P": "3"} + report.phandle = "P" + event_ref, event = self._make_event() + report.database.get_event_from_handle.return_value = event + report.append_event(event_ref) + + # The index Ref should reference the first encounter (1, 2, 3). + entries = list(report.index_of_places["Dublin"].values()) + self.assertTrue(any("Ref: 1 2 3" in e for e in entries), + "dubperson-on baseline must yield Ref '1 2 3'; " + "got %r" % entries) + + def test_dubperson_off_multi_encounter_matches_first_encounter(self): + """The fallback path (omit-duplicates OFF; report_app_ref + un-pre-populated) must produce the SAME Ref as the + omit-duplicates-on baseline above — i.e. the FIRST encounter's + coordinates — even when the same person/event is processed + again in a later report.""" + report = self._make_report() + event_ref, event = self._make_event() + report.database.get_event_from_handle.return_value = event + + # First encounter in report 1 (P is generation 1 → gen+1=2, + # dnumber 3 within report 1). + report.report_count = 1 + report.generation = 1 + report.dnumber = {"P": "3"} + report.phandle = "P" + report.append_event(event_ref) + + # Second encounter in report 2 (DIFFERENT coordinates: P is + # generation 3 → gen+1=4, dnumber 5 within report 2). + report.report_count = 2 + report.generation = 3 + report.dnumber = {"P": "5"} + report.phandle = "P" + report.append_event(event_ref) + + # The index entry overwrites on each call. The surviving Ref + # must point at the FIRST encounter (1, 2, 3) — matching the + # dubperson-on baseline — NOT the second (2, 4, 5). + entries = list(report.index_of_places["Dublin"].values()) + self.assertTrue(any("Ref: 1 2 3" in e for e in entries), + "Multi-encounter index Ref must match the " + "first-encounter coordinates the dubperson-on " + "path would emit; got %r" % entries) + self.assertFalse(any("Ref: 2 4 5" in e for e in entries), + "Multi-encounter index Ref must NOT have " + "drifted to the last encounter's coordinates; " + "got %r" % entries) + + +if __name__ == "__main__": + unittest.main() diff --git a/FRWebConnectPack/FRWebPack.gpr.py b/FRWebConnectPack/FRWebPack.gpr.py index 8fe4e9232..7f4d2fdc4 100644 --- a/FRWebConnectPack/FRWebPack.gpr.py +++ b/FRWebConnectPack/FRWebPack.gpr.py @@ -11,7 +11,7 @@ name=_("FR Web Connect Pack"), description=_("Collection of Web sites for the FR (requires libwebconnect)"), status=STABLE, - version = '1.0.39', + version = '1.0.40', gramps_target_version="6.0", fname="FRWebPack.py", load_on_reg=True, diff --git a/FRWebConnectPack/FRWebPack.py b/FRWebConnectPack/FRWebPack.py index f693fda66..212dc1035 100755 --- a/FRWebConnectPack/FRWebPack.py +++ b/FRWebConnectPack/FRWebPack.py @@ -42,7 +42,7 @@ ["Person", "Geneabank", _("Genealogical bank"), "https://gbkcouples.geneabank.org/nom/?name=%(surname)s&place=&start=&end=&source=gbk*"], #["Person", "Centre Départemental d'Histoire des Familles (CDHF)", _("Historical Families Center Departement (CDHF)"), "http://www.cdhf.net/fr/index.php?t=bases&d=bases%2Fmoteurpat&c=moteurpat&f=selection&p=&order=&order2=&motcle=&patronyme=%(surname)s"], ["Person", "Fichier Origine", _("OrigineFile (Quebec)"), "https://www.fichierorigine.com/recherche?nom=%(surname)s"], - ["Person", "Geneanet", "Geneanet", "https://search.geneanet.org/result.php?lang=fr&name=%(surname)s"], + ["Person", "Geneanet", "Geneanet", "https://www.geneanet.org/fonds/individus/?go=1&nom=%(surname)s&prenom=%(given)s"], ["Person", "Filae", "Filae", "https://www.filae.com/v4/genealogie/Search.mvc/SearchForm?ln=%(surname)s&fn=%(given)s"], #["Person", "Geneanet-Favrejhas", "Geneanet, Favrejhas", "http://gw1.geneanet.org/index.php3?b=favrejhas&m=NG&n=%(surname)s&t=N&x=0&y=0"], ["Person", "Roglo", "Roglo", "http://roglo.eu/roglo?m=NG&n=%(given)s+%(surname)s&t=PN"], diff --git a/FRWebConnectPack/tests/__init__.py b/FRWebConnectPack/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FRWebConnectPack/tests/test_geneanet_url.py b/FRWebConnectPack/tests/test_geneanet_url.py new file mode 100644 index 000000000..d0a78689a --- /dev/null +++ b/FRWebConnectPack/tests/test_geneanet_url.py @@ -0,0 +1,127 @@ +# +# 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 bug 14145: FRWebConnectPack Geneanet link is stale. + +Pre-fix WEBSITES entry pointed at +``https://search.geneanet.org/result.php?lang=fr&name=`` -- +Geneanet deprecated that URL, returning an unusable page, and the +template only carried the surname (Geneanet's current individus search +takes both ``nom`` and ``prenom``). Reporter on Mantis 14145 supplied +the corrected URL; callmedave confirmed the bug (note 5) despite +recommending the WebSearch Gramplet as a longer-term replacement, so +the live FrWebConnectPack addon still needs fixing. + +The test imports ``WEBSITES`` from ``FRWebConnectPack.FRWebPack`` and +applies the same ``pattern % dict`` formatting libwebconnect itself +uses (see ``libwebconnect.Search.callback``). No network, no display, +no Gtk -- pure string assertion. +""" + +import os +import sys +import unittest + +# 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 TestGeneanetUrl(unittest.TestCase): + """Regression: the Geneanet WEBSITES entry must build a working URL.""" + + @staticmethod + def _geneanet_pattern(): + # Addon dir and impl module share the name ``FRWebConnectPack`` + # under the addon's package namespace; load the implementation + # via the explicit submodule path to dodge the namespace-package + # shadowing trap (gramps bug 0012691 family). + from FRWebConnectPack import FRWebPack # pylint: disable=import-outside-toplevel + + for entry in FRWebPack.WEBSITES: + # entry: [nav_type, key, name, url_pattern] + if entry[1] == "Geneanet": + return entry[3] + raise AssertionError("Geneanet entry missing from FRWebPack.WEBSITES") + + def test_built_url_contains_both_name_parts(self): + """Geneanet URL template must take given AND surname. + + Pre-fix the template only carried ``%(surname)s``; reporter + on 14145 noted searches returned the wrong people because the + given name was discarded. + """ + pattern = self._geneanet_pattern() + url = pattern % { + "surname": "Dupont", + "given": "Marie", + "middle": "", + "birth": "", + "death": "", + } + self.assertIn("Dupont", url, "surname must appear in built URL") + self.assertIn("Marie", url, "given name must appear in built URL") + + def test_built_url_uses_current_geneanet_host_and_path(self): + """Geneanet URL must target the current individus search. + + Pre-fix the template hit ``search.geneanet.org/result.php`` -- + deprecated. Reporter on 14145 supplied the replacement + ``www.geneanet.org/fonds/individus/?go=1&nom=...&prenom=...``. + """ + pattern = self._geneanet_pattern() + url = pattern % { + "surname": "Dupont", + "given": "Marie", + "middle": "", + "birth": "", + "death": "", + } + # Stale form -- must NOT appear after the fix. + self.assertNotIn( + "result.php", + url, + "deprecated Geneanet search URL must not be used", + ) + self.assertNotIn( + "search.geneanet.org", + url, + "deprecated Geneanet search host must not be used", + ) + # Corrected form -- must appear. + self.assertIn( + "www.geneanet.org/fonds/individus/", + url, + "current Geneanet individus search path must be used", + ) + self.assertIn( + "nom=Dupont", + url, + "surname must be passed as the 'nom' query parameter", + ) + self.assertIn( + "prenom=Marie", + url, + "given name must be passed as the 'prenom' query parameter", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Form/CensusCheckQuickview.gpr.py b/Form/CensusCheckQuickview.gpr.py index 4ad83e12a..4b0cd4584 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.3', + version = '1.0.4', gramps_target_version = '6.0', 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.3', + version = '1.0.4', gramps_target_version = '6.0', status = STABLE, fname = 'CensusCheckUpQuickview.py', diff --git a/Form/form.py b/Form/form.py index 81b99352f..940c92be1 100644 --- a/Form/form.py +++ b/Form/form.py @@ -94,6 +94,7 @@ "form_dk.xml", "form_fr.xml", "form_gb.xml", + "form_ie.xml", "form_pl.xml", "form_us.xml", "test.xml", diff --git a/Form/form_ie.xml b/Form/form_ie.xml new file mode 100644 index 000000000..4c22b470d --- /dev/null +++ b/Form/form_ie.xml @@ -0,0 +1,383 @@ + + +
+ + <_attribute>Total Area in Statute Acres + + + <_attribute>Signature of Head of Household + + + <_attribute>Signature of Enumerator + + + <_attribute>Persons + + + <_attribute>Males + + + <_attribute>Females + + + <_attribute>Rooms + + + <_attribute>County or Co. Borough + + + <_attribute>District Electoral Division or Ward + + + <_attribute>Townland + + + <_attribute>Urban Dist. Town or Village + + + <_attribute>Street etc with No. of House + + + <_attribute>No. of Schedule + + + <_attribute>Name of Public Institution + +
+ + <_attribute>Name + 16 + <_longname>Name and Surname + + + <_attribute>Relation + 7 + <_longname>Relationship to Head of Household + + + <_attribute>Year Age + 4 + <_longname>Age in years + + + <_attribute>Month Age + 4 + <_longname>Age in months + + + <_attribute>Sex + 4 + + + <_attribute>Married + 4 + <_longname>Marriage or Orphanhood + + + <_attribute>Birthplace + 9 + + + <_attribute>Irish Language + 9 + <_longname>"Irish only", "Irish and English", "English and Irish", "Read but cannot speak Irish" + + + <_attribute>Religion + 9 + + + <_attribute>Personal Occupation + 9 + + + <_attribute>Employment + 9 + + + <_attribute>Years Married + 4 + <_longname>Years married in present Marriage + + + <_attribute>Months Married + 4 + <_longname>Months married in present Marriage + + + <_attribute>Present Marriage Children + 4 + <_longname>Children born alive to present marriage + + + <_attribute>Total Children Under 16 Living + 4 + +
+
+
+ + <_attribute>(1.) Entry no. + + + <_attribute>Superintendent Registrar's District + + + <_attribute>Registrar's District + + + <_attribute>District + + + <_attribute>Union + + + <_attribute>County + + + <_attribute>(10.) Signature of Registrar + + + <_attribute>(11.) Baptismal Name if added after Registration of Birth, and Date + +
+ + <_attribute>Name + <_longname>(3.) Name (if any) + + + <_attribute>Place of Birth + <_longname>(2.) Date and Place of Birth + + + <_attribute>Date of Birth + <_longname>(2.) Date and Place of Birth + + + <_attribute>Sex + <_longname>(4.) M or F for male or female + 18 + +
+
+ + <_attribute>Name + <_longname>(5.) Name and Surname and Dwelling-place of Father + + + <_attribute>Residence + <_longname>(5.) Name and Surname and Dwelling-place of Father + + + <_attribute>Occupation + <_longname>(7.) Rank or Profession of Father + +
+
+ + <_attribute>Name + <_longname>(6.) Name and Surname and Maiden Surname of Mother + + + <_attribute>Maiden Surname + <_longname>(6.) Name and Surname and Maiden Surname of Mother + +
+
+ + <_attribute>Name + <_longname>(8.) Signature, Qualification, and Residence of Informant + + + <_attribute>Residence + <_longname>(8.) Signature, Qualification, and Residence of Informant + + + <_attribute>When Registered + <_longname>(9.) Date when birth was registered + +
+
+
+ + <_attribute>Year + + + <_attribute>Solemnized at the Catholic + + + <_attribute>Registrar's District + + + <_attribute>Superintendent Registrar's District + + + <_attribute>County + + + <_attribute>Certificate number + + + <_attribute>Page number + + + <_attribute>(1.) Entry No. + + + <_attribute>(2.) When Married + +
+ + <_attribute>Name + <_longname>(3.) Name and Surname + + + <_attribute>Age + <_longname>(4.) Full or Minor + + + <_attribute>Condition + <_longname>(5.) Bachelor or Spinster + + + <_attribute>Occupation + <_longname>(6.) Rank or Profession + + + <_attribute>Residence + <_longname>Residence at the Time of Marriage + +
+
+ + <_attribute>Name + <_longname>(8.) Father's Name and Surname + + + + <_attribute>Occupation + <_longname>(9.) Rank or Profession of Father + +
+
+ + <_attribute>Name + <_longname>(8.) Father's Name and Surname + + + + <_attribute>Occupation + <_longname>(9.) Rank or Profession of Father + +
+
+ + <_attribute>Name + + + <_attribute>Signed + +
+
+ + <_attribute>Name + + + <_attribute>Signed + +
+
+
+ + <_attribute>Superintendent Registrar's District + + + <_attribute>Registrar's District + + + <_attribute>District + + + <_attribute>Union + + + <_attribute>County + + + <_attribute>Certificate number + + + <_attribute>Page number + + + <_attribute>(1.) Entry No. + + + <_attribute>(10.) When Registered + + + <_attribute>(11.) Signature of Registrar + +
+ + <_attribute>Name + <_longname>Name and Surname + + + <_attribute>Date of Death + <_longname>(2.) Date and Place of Death + + + <_attribute>Place of Death + <_longname>(2.) Date and Place of Death + + + <_attribute>Sex + <_longname>(4.) M or F for Male or Female + + + <_attribute>Condition + <_longname>(5.) Widowed or Married + + + <_attribute>Age + <_longname>(6.) Age last Birthday + + + <_attribute>Occupation + <_longname>(7.) Rank, Profession, or Occupation + + + <_attribute>Cause of Death + <_longname>(8.) Certified Cause of Death and Duration of Illness + + + <_attribute>Duration of Illness + <_longname>(8.) Certified Cause of Death and Duration of Illness + +
+
+ + <_attribute>Name + + + <_attribute>Signed + + + <_attribute>Relation to deceased + <_longname>(9.) e.g. Son, Daughter + + + <_attribute>Description + <_longname>(9.) e.g. Present at death + + + <_attribute>Residence + +
+
+
diff --git a/Form/formgramplet.gpr.py b/Form/formgramplet.gpr.py index ab87391d1..43d4cddad 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.53', + version = '2.0.54', gramps_target_version="6.0", navtypes=["Person"], fname="formgramplet.py", diff --git a/GenealogyTree/gt_sandclock.py b/GenealogyTree/gt_sandclock.py index 5fd7e421d..bab8001fb 100644 --- a/GenealogyTree/gt_sandclock.py +++ b/GenealogyTree/gt_sandclock.py @@ -90,6 +90,7 @@ def __init__(self, database, options, user): self.max_down = menu.get_option_by_name('gendown').get_value() self.include_siblings = menu.get_option_by_name('siblings').get_value() self.include_images = menu.get_option_by_name('images').get_value() + self.compact = menu.get_option_by_name('compact').get_value() self.set_locale(menu.get_option_by_name('trans').get_value()) def write_report(self): @@ -108,26 +109,7 @@ def write_report(self): raise ReportError(_("Family %s is not in the Database") % self._pid) - options = ['pref code={\\underline{#1}}', - 'list separators hang', - 'place text={\\newline}{}'] - - if self.include_images: - images = ('if image defined={' - 'add to width=25mm,right=25mm,\n' - 'underlay={\\begin{tcbclipinterior}' - '\\path[fill overzoom image=\\gtrDBimage]\n' - '([xshift=-24mm]interior.south east) ' - 'rectangle (interior.north east);\n' - '\\end{tcbclipinterior}},\n' - '}{},') - box = 'box={halign=left,\\gtrDBsex,%s\n}' % images - else: - box = 'box={halign=left,\\gtrDBsex}' - - options.append(box) - - self.doc.start_tree(options) + self.doc.start_tree(self._build_tree_options()) if self._person_report: family_handle = person.get_main_parents_family_handle() if family_handle: @@ -145,6 +127,43 @@ def write_report(self): self.doc.end_subgraph(0) self.doc.end_tree() + def _build_tree_options(self): + """Assemble the option list for ``\\genealogytree[...]``. + + Kept separate from ``write_report`` so the option-construction + logic can be unit-tested without driving a full report run. + """ + options = ['pref code={\\underline{#1}}', + 'list separators hang', + 'place text={\\newline}{}'] + + if self.include_images: + images = ('if image defined={' + 'add to width=25mm,right=25mm,\n' + 'underlay={\\begin{tcbclipinterior}' + '\\path[fill overzoom image=\\gtrDBimage]\n' + '([xshift=-24mm]interior.south east) ' + 'rectangle (interior.north east);\n' + '\\end{tcbclipinterior}},\n' + '}{},') + box = 'box={halign=left,\\gtrDBsex,%s\n}' % images + else: + box = 'box={halign=left,\\gtrDBsex}' + + options.append(box) + + if self.compact: + # Mantis 10512 / SNoiraud's 2018-12-20 note: the default + # `database` template lays out sandclock trees so widely + # that large trees clip off the page. `database pole + # reduced` gives ~4x more space per page at the cost of + # denser per-node formatting. Appended last so it + # overrides any node-spacing defaults set by treedoc.py's + # built-in keys earlier in the parameter list. + options.append('template=database pole reduced') + + return options + def subgraph_up_parents(self, level, family): for handle in (family.get_father_handle(), family.get_mother_handle()): if handle: @@ -251,4 +270,12 @@ def add_menu_options(self, menu): images.set_help(_("Include images of people in the nodes.")) menu.add_option(category_name, "images", images) + compact = BooleanOption(_("Compact tree layout"), False) + compact.set_help(_( + "Use the genealogytree 'database pole reduced' template, " + "which packs more generations per page. Useful for large " + "sandclock trees that otherwise clip off the rendered page." + )) + menu.add_option(category_name, "compact", compact) + locale_opt = stdoptions.add_localization_option(menu, category_name) diff --git a/GenealogyTree/po/template.pot b/GenealogyTree/po/template.pot index da4ac8e2d..676867599 100644 --- a/GenealogyTree/po/template.pot +++ b/GenealogyTree/po/template.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" +"POT-Creation-Date: 2026-05-27 09:12-0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -57,72 +57,83 @@ msgstr "" msgid "Sandclock tree for a family using LaTeX genealogytree" msgstr "" -#: GenealogyTree/gt_sandclock.py:103 GenealogyTree/gt_descendant.py:96 +#: GenealogyTree/gt_sandclock.py:104 GenealogyTree/gt_descendant.py:96 #: GenealogyTree/gt_ancestor.py:95 #, python-format msgid "Person %s is not in the Database" msgstr "" -#: GenealogyTree/gt_sandclock.py:108 +#: GenealogyTree/gt_sandclock.py:109 #, python-format msgid "Family %s is not in the Database" msgstr "" -#: GenealogyTree/gt_sandclock.py:227 GenealogyTree/gt_grandparent.py:217 +#: GenealogyTree/gt_sandclock.py:246 GenealogyTree/gt_grandparent.py:217 #: GenealogyTree/gt_descendant.py:177 GenealogyTree/gt_ancestor.py:160 msgid "Report Options" msgstr "" -#: GenealogyTree/gt_sandclock.py:230 GenealogyTree/gt_grandparent.py:219 +#: GenealogyTree/gt_sandclock.py:249 GenealogyTree/gt_grandparent.py:219 #: GenealogyTree/gt_descendant.py:179 GenealogyTree/gt_ancestor.py:162 msgid "Center Person" msgstr "" -#: GenealogyTree/gt_sandclock.py:231 GenealogyTree/gt_grandparent.py:220 +#: GenealogyTree/gt_sandclock.py:250 GenealogyTree/gt_grandparent.py:220 #: GenealogyTree/gt_descendant.py:180 GenealogyTree/gt_ancestor.py:163 msgid "The center person for the report" msgstr "" -#: GenealogyTree/gt_sandclock.py:234 +#: GenealogyTree/gt_sandclock.py:253 msgid "Center Family" msgstr "" -#: GenealogyTree/gt_sandclock.py:235 +#: GenealogyTree/gt_sandclock.py:254 msgid "The center family for the report" msgstr "" -#: GenealogyTree/gt_sandclock.py:238 +#: GenealogyTree/gt_sandclock.py:257 msgid "Generations up" msgstr "" -#: GenealogyTree/gt_sandclock.py:239 GenealogyTree/gt_sandclock.py:243 +#: GenealogyTree/gt_sandclock.py:258 GenealogyTree/gt_sandclock.py:262 #: GenealogyTree/gt_grandparent.py:224 GenealogyTree/gt_descendant.py:184 #: GenealogyTree/gt_ancestor.py:167 msgid "The number of generations to include in the tree" msgstr "" -#: GenealogyTree/gt_sandclock.py:242 +#: GenealogyTree/gt_sandclock.py:261 msgid "Generations down" msgstr "" -#: GenealogyTree/gt_sandclock.py:246 +#: GenealogyTree/gt_sandclock.py:265 msgid "Include siblings" msgstr "" -#: GenealogyTree/gt_sandclock.py:247 +#: GenealogyTree/gt_sandclock.py:266 msgid "Include siblings of ancestors." msgstr "" -#: GenealogyTree/gt_sandclock.py:250 GenealogyTree/gt_grandparent.py:231 +#: GenealogyTree/gt_sandclock.py:269 GenealogyTree/gt_grandparent.py:231 #: GenealogyTree/gt_descendant.py:187 GenealogyTree/gt_ancestor.py:170 msgid "Include images" msgstr "" -#: GenealogyTree/gt_sandclock.py:251 GenealogyTree/gt_grandparent.py:232 +#: GenealogyTree/gt_sandclock.py:270 GenealogyTree/gt_grandparent.py:232 #: GenealogyTree/gt_descendant.py:188 GenealogyTree/gt_ancestor.py:171 msgid "Include images of people in the nodes." msgstr "" +#: GenealogyTree/gt_sandclock.py:273 +msgid "Compact tree layout" +msgstr "" + +#: GenealogyTree/gt_sandclock.py:275 +msgid "" +"Use the genealogytree 'database pole reduced' template, which packs more " +"generations per page. Useful for large sandclock trees that otherwise clip " +"off the rendered page." +msgstr "" + #: GenealogyTree/gt_grandparent.py:119 #, python-format msgid "Person %s does not have both a paternal and a maternal grandparent" diff --git a/GenealogyTree/tests/__init__.py b/GenealogyTree/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/GenealogyTree/tests/test_sandclock_compact_template.py b/GenealogyTree/tests/test_sandclock_compact_template.py new file mode 100644 index 000000000..53df18e06 --- /dev/null +++ b/GenealogyTree/tests/test_sandclock_compact_template.py @@ -0,0 +1,143 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# 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 Mantis bug 10512 — the Sandclock Genealogy Tree +report rendered only one page of an arbitrarily-large tree because +genealogytree's default ``database`` template lays the tree out so +widely that big sandclocks clip off the page. + +SNoiraud's 2018-12-20 workaround on the Mantis ticket was to add +``template=database pole reduced`` to the ``\\genealogytree[…]`` +parameter list, which gives ~4x more space per page at the cost of +denser per-node formatting. This PR exposes that as a user-facing +"Compact tree layout" option on the Sandclock report. + +These tests cover the option-list assembly without driving a full +report run (no LaTeX, no Gramps GUI). They instantiate +``SandclockTree`` via ``__new__`` so we never enter ``Report.__init__``, +set just the attributes ``_build_tree_options`` reads, and assert the +template directive is present exactly when ``compact=True``. +""" + +import importlib.util +import os +import unittest + + +_IMPL_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "gt_sandclock.py", +) + + +def _load_impl(): + """Load and return the gt_sandclock module by file path. + + The addon directory is not necessarily on ``sys.path`` from a + bare unit-test invocation, so we load by file location. + """ + spec = importlib.util.spec_from_file_location( + "gt_sandclock_impl", _IMPL_PATH + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class SandclockCompactTemplateTest(unittest.TestCase): + """``_build_tree_options`` emits ``template=database pole reduced`` + if and only if the report's ``compact`` option is True.""" + + @classmethod + def setUpClass(cls): + try: + cls.impl = _load_impl() + except ImportError as err: + raise unittest.SkipTest("Gramps not importable: %s" % err) + cls.SandclockTree = cls.impl.SandclockTree + + def _build_report(self, compact, include_images=False): + """Return a SandclockTree skeleton with just the attributes + ``_build_tree_options`` reads.""" + report = self.SandclockTree.__new__(self.SandclockTree) + report.compact = compact + report.include_images = include_images + return report + + def test_default_layout_has_no_template_directive(self): + """With ``compact=False`` (the default), the option list does + not carry a ``template=`` entry — genealogytree uses its + default ``database`` template.""" + opts = self._build_report(compact=False)._build_tree_options() + for entry in opts: + self.assertFalse( + entry.startswith("template="), + f"unexpected template directive: {entry!r}", + ) + + def test_compact_layout_appends_database_pole_reduced(self): + """With ``compact=True``, the option list contains exactly the + SNoiraud-suggested directive.""" + opts = self._build_report(compact=True)._build_tree_options() + self.assertIn("template=database pole reduced", opts) + + def test_compact_template_is_last_option(self): + """The template directive must come *after* the other genealogy- + tree options so it overrides node-spacing defaults that + treedoc.py's built-in keys may have set earlier in the parameter + list. pgfkeys is order-sensitive: a later ``template=`` resets + the keys the template controls.""" + opts = self._build_report(compact=True)._build_tree_options() + self.assertEqual( + opts[-1], + "template=database pole reduced", + f"template directive must be the LAST option; got {opts!r}", + ) + + def test_compact_layout_preserves_existing_options(self): + """Turning ``compact`` on must not drop the pref-code, list- + separator, place-text, or box options that the default option + list already carries.""" + opts = self._build_report(compact=True)._build_tree_options() + self.assertIn("pref code={\\underline{#1}}", opts) + self.assertIn("list separators hang", opts) + self.assertIn("place text={\\newline}{}", opts) + # Box option is the one before the template directive. + self.assertTrue( + any(entry.startswith("box={") for entry in opts), + f"box directive missing from option list: {opts!r}", + ) + + def test_images_and_compact_coexist(self): + """``include_images=True`` and ``compact=True`` both contribute + their own entries — neither suppresses the other.""" + opts = self._build_report( + compact=True, include_images=True + )._build_tree_options() + self.assertIn("template=database pole reduced", opts) + # The images path produces a longer box option that contains + # the genealogytree image-overlay directive ``\gtrDBimage``. + self.assertTrue( + any("\\gtrDBimage" in entry for entry in opts), + f"image directive missing when include_images=True: {opts!r}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/GenealogyTree/treeplugins.gpr.py b/GenealogyTree/treeplugins.gpr.py index 4a04082de..b9d0f76be 100644 --- a/GenealogyTree/treeplugins.gpr.py +++ b/GenealogyTree/treeplugins.gpr.py @@ -29,7 +29,7 @@ id="gt_ancestor", name=_("Ancestor Tree"), description=_("Ancestor tree using LaTeX genealogytree"), - version = '1.0.23', + version = '1.0.24', gramps_target_version="6.0", status=STABLE, audience=EXPERT, @@ -54,7 +54,7 @@ id="gt_descendant", name=_("Descendant Tree"), description=_("Descendant tree using LaTeX genealogytree"), - version = '1.0.23', + version = '1.0.24', gramps_target_version="6.0", status=STABLE, audience=EXPERT, @@ -79,7 +79,7 @@ id="gt_grandparent", name=_("Grandparent Tree"), description=_("Grandparent tree using LaTeX genealogytree"), - version = '1.0.23', + version = '1.0.24', gramps_target_version="6.0", status=STABLE, audience=EXPERT, @@ -104,7 +104,7 @@ id="gt_sandclock", name=_("Sandclock Tree"), description=_("Sandclock tree using LaTeX genealogytree"), - version = '1.0.23', + version = '1.0.24', gramps_target_version="6.0", status=STABLE, audience=EXPERT, @@ -129,7 +129,7 @@ id="gt_sandclock_family", name=_("Sandclock Tree for a Family"), description=_("Sandclock tree for a family using LaTeX genealogytree"), - version = '1.0.23', + version = '1.0.24', gramps_target_version="6.0", status=STABLE, audience=EXPERT, diff --git a/HasTagSubstr/hastagsubstr.gpr.py b/HasTagSubstr/hastagsubstr.gpr.py new file mode 100644 index 000000000..e7c9f3064 --- /dev/null +++ b/HasTagSubstr/hastagsubstr.gpr.py @@ -0,0 +1,171 @@ +# +# 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. +# +"""Filter rules to match objects with a tag whose name contains a substring.""" + +register( + RULE, + id="PersonHasTagSubstr", + name=_("People with a tag containing "), + description=_("Matches people with a tag whose name contains the given substring"), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="PersonHasTagSubstr", + namespace="Person", +) + +register( + RULE, + id="FamilyHasTagSubstr", + name=_("Families with a tag containing "), + description=_( + "Matches families with a tag whose name contains the given substring" + ), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="FamilyHasTagSubstr", + namespace="Family", +) + +register( + RULE, + id="EventHasTagSubstr", + name=_("Events with a tag containing "), + description=_( + "Matches events with a tag whose name contains the given substring" + ), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="EventHasTagSubstr", + namespace="Event", +) + +register( + RULE, + id="PlaceHasTagSubstr", + name=_("Places with a tag containing "), + description=_( + "Matches places with a tag whose name contains the given substring" + ), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="PlaceHasTagSubstr", + namespace="Place", +) + +register( + RULE, + id="SourceHasTagSubstr", + name=_("Sources with a tag containing "), + description=_( + "Matches sources with a tag whose name contains the given substring" + ), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="SourceHasTagSubstr", + namespace="Source", +) + +register( + RULE, + id="CitationHasTagSubstr", + name=_("Citations with a tag containing "), + description=_( + "Matches citations with a tag whose name contains the given substring" + ), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="CitationHasTagSubstr", + namespace="Citation", +) + +register( + RULE, + id="RepositoryHasTagSubstr", + name=_("Repositories with a tag containing "), + description=_( + "Matches repositories with a tag whose name contains the given substring" + ), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="RepositoryHasTagSubstr", + namespace="Repository", +) + +register( + RULE, + id="MediaHasTagSubstr", + name=_("Media objects with a tag containing "), + description=_( + "Matches media objects with a tag whose name contains the given substring" + ), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="MediaHasTagSubstr", + namespace="Media", +) + +register( + RULE, + id="NoteHasTagSubstr", + name=_("Notes with a tag containing "), + description=_( + "Matches notes with a tag whose name contains the given substring" + ), + version = '1.0.1', + authors=["Doug Blank"], + authors_email=["doug.blank@gmail.com"], + gramps_target_version="6.1", + status=STABLE, + fname="hastagsubstr.py", + ruleclass="NoteHasTagSubstr", + namespace="Note", +) diff --git a/HasTagSubstr/hastagsubstr.py b/HasTagSubstr/hastagsubstr.py new file mode 100644 index 000000000..38dde9b54 --- /dev/null +++ b/HasTagSubstr/hastagsubstr.py @@ -0,0 +1,183 @@ +# +# 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. +# +"""Filter rules to match objects with a tag whose name contains a substring.""" + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gen.filters.rules import Rule +from gramps.gen.const import GRAMPS_LOCALE as glocale + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.gettext + +# ------------------------------------------------------------------------- +# +# Typing modules +# +# ------------------------------------------------------------------------- +from typing import Set +from gramps.gen.lib.primaryobj import PrimaryObject +from gramps.gen.db import Database +from gramps.gen.types import PrimaryObjectHandle + + +# ------------------------------------------------------------------------- +# +# HasTagSubstrBase +# +# ------------------------------------------------------------------------- +class HasTagSubstrBase(Rule): + """Rule that matches objects with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = "Objects with a tag containing " + description = "Matches objects with a tag whose name contains the given substring" + category = _("General filters") + namespace = "" + + def prepare(self, db: Database, user) -> None: + """Build the set of matching object handles once, before filtering begins. + + Scans all tags for names containing the substring, then uses backlinks + to collect the handles of every object in this namespace that carries + at least one of those tags. The optimizer sees self.selected_handles + and skips apply_to_one entirely for handles not in the set. + """ + substring = self.list[0].upper() + self.selected_handles: Set[PrimaryObjectHandle] = set() + for tag_handle in db.get_tag_handles(): + tag = db.get_tag_from_handle(tag_handle) + if tag is not None and substring in tag.get_name().upper(): + for _classname, obj_handle in db.find_backlink_handles( + tag_handle, include_classes=[self.namespace] + ): + self.selected_handles.add(obj_handle) + + def apply_to_one(self, db: Database, obj: PrimaryObject) -> bool: + """Return True if this object's handle is in the pre-built match set.""" + return obj.handle in self.selected_handles + + +# ------------------------------------------------------------------------- +# +# Per-namespace subclasses +# +# ------------------------------------------------------------------------- +class PersonHasTagSubstr(HasTagSubstrBase): + """Matches people with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("People with a tag containing ") + description = _("Matches people with a tag whose name contains the given substring") + namespace = "Person" + + +class FamilyHasTagSubstr(HasTagSubstrBase): + """Matches families with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("Families with a tag containing ") + description = _( + "Matches families with a tag whose name contains the given substring" + ) + namespace = "Family" + + +class EventHasTagSubstr(HasTagSubstrBase): + """Matches events with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("Events with a tag containing ") + description = _( + "Matches events with a tag whose name contains the given substring" + ) + namespace = "Event" + + +class PlaceHasTagSubstr(HasTagSubstrBase): + """Matches places with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("Places with a tag containing ") + description = _( + "Matches places with a tag whose name contains the given substring" + ) + namespace = "Place" + + +class SourceHasTagSubstr(HasTagSubstrBase): + """Matches sources with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("Sources with a tag containing ") + description = _( + "Matches sources with a tag whose name contains the given substring" + ) + namespace = "Source" + + +class CitationHasTagSubstr(HasTagSubstrBase): + """Matches citations with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("Citations with a tag containing ") + description = _( + "Matches citations with a tag whose name contains the given substring" + ) + namespace = "Citation" + + +class RepositoryHasTagSubstr(HasTagSubstrBase): + """Matches repositories with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("Repositories with a tag containing ") + description = _( + "Matches repositories with a tag whose name contains the given substring" + ) + namespace = "Repository" + + +class MediaHasTagSubstr(HasTagSubstrBase): + """Matches media objects with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("Media objects with a tag containing ") + description = _( + "Matches media objects with a tag whose name contains the given substring" + ) + namespace = "Media" + + +class NoteHasTagSubstr(HasTagSubstrBase): + """Matches notes with a tag whose name contains a substring.""" + + labels = [_("Substring:")] + name = _("Notes with a tag containing ") + description = _( + "Matches notes with a tag whose name contains the given substring" + ) + namespace = "Note" diff --git a/HasTagSubstr/po/template.pot b/HasTagSubstr/po/template.pot new file mode 100644 index 000000000..62a6e84ba --- /dev/null +++ b/HasTagSubstr/po/template.pot @@ -0,0 +1,103 @@ +# 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-05-26 10:02-0700\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: HasTagSubstr/hastagsubstr.gpr.py:25 HasTagSubstr/hastagsubstr.py:93 +msgid "People with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:26 HasTagSubstr/hastagsubstr.py:94 +msgid "Matches people with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:40 HasTagSubstr/hastagsubstr.py:102 +msgid "Families with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:42 HasTagSubstr/hastagsubstr.py:104 +msgid "Matches families with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:57 HasTagSubstr/hastagsubstr.py:113 +msgid "Events with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:59 HasTagSubstr/hastagsubstr.py:115 +msgid "Matches events with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:74 HasTagSubstr/hastagsubstr.py:124 +msgid "Places with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:76 HasTagSubstr/hastagsubstr.py:126 +msgid "Matches places with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:91 HasTagSubstr/hastagsubstr.py:135 +msgid "Sources with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:93 HasTagSubstr/hastagsubstr.py:137 +msgid "Matches sources with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:108 HasTagSubstr/hastagsubstr.py:146 +msgid "Citations with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:110 HasTagSubstr/hastagsubstr.py:148 +msgid "Matches citations with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:125 HasTagSubstr/hastagsubstr.py:157 +msgid "Repositories with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:127 HasTagSubstr/hastagsubstr.py:159 +msgid "Matches repositories with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:142 HasTagSubstr/hastagsubstr.py:168 +msgid "Media objects with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:144 HasTagSubstr/hastagsubstr.py:170 +msgid "" +"Matches media objects with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:159 HasTagSubstr/hastagsubstr.py:179 +msgid "Notes with a tag containing " +msgstr "" + +#: HasTagSubstr/hastagsubstr.gpr.py:161 HasTagSubstr/hastagsubstr.py:181 +msgid "Matches notes with a tag whose name contains the given substring" +msgstr "" + +#: HasTagSubstr/hastagsubstr.py:55 HasTagSubstr/hastagsubstr.py:92 +#: HasTagSubstr/hastagsubstr.py:101 HasTagSubstr/hastagsubstr.py:112 +#: HasTagSubstr/hastagsubstr.py:123 HasTagSubstr/hastagsubstr.py:134 +#: HasTagSubstr/hastagsubstr.py:145 HasTagSubstr/hastagsubstr.py:156 +#: HasTagSubstr/hastagsubstr.py:167 HasTagSubstr/hastagsubstr.py:178 +msgid "Substring:" +msgstr "" + +#: HasTagSubstr/hastagsubstr.py:58 +msgid "General filters" +msgstr "" diff --git a/PluginManager/PluginManager.gpr.py b/PluginManager/PluginManager.gpr.py index b37edf8f4..2c125140b 100644 --- a/PluginManager/PluginManager.gpr.py +++ b/PluginManager/PluginManager.gpr.py @@ -29,7 +29,7 @@ id="PluginManager", name=_("Plugin Manager Enhanced"), description=_("An Addon/Plugin Manager with several additional " "capabilities"), - version = '1.2.7', + version = '1.2.8', gramps_target_version="6.0", fname="PluginManagerLoad.py", authors=["Paul Culley"], diff --git a/PluginManager/PluginManager.py b/PluginManager/PluginManager.py index e6e33a3c9..ebf043b1a 100644 --- a/PluginManager/PluginManager.py +++ b/PluginManager/PluginManager.py @@ -650,8 +650,16 @@ def __info(self, pid): reqs = [] info = Requirements().info(addon) for i in range(0, len(info), 2): - label = " " + info[i] req_lst = info[i + 1] + if not req_lst: + # Bug 13979: Requirements.info emits a label + + # empty table when the addon listing has e.g. + # "re": [] (PostgreSQL Enhanced declares + # requires_exe=[]). Skip cleanly - indexing + # req_lst[0] below would raise IndexError, and + # there is nothing meaningful to show. + continue + label = " " + info[i] txt = " ".join(req_lst[0]) for j in range(1, len(req_lst)): txt += ", " + " ".join(req_lst[j]) diff --git a/PluginManager/tests/__init__.py b/PluginManager/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/PluginManager/tests/test_info_empty_requires.py b/PluginManager/tests/test_info_empty_requires.py new file mode 100644 index 000000000..819908612 --- /dev/null +++ b/PluginManager/tests/test_info_empty_requires.py @@ -0,0 +1,173 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# 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 bug 13979: PluginManager Enhanced raised IndexError +on the PostgreSQL Enhanced row. + +The addon's gpr.py declares ``requires_exe=[]`` which lands in the +addons-.json listing as ``"re": []``. gramps core's +:class:`Requirements.info` still emits an Executables label paired with +an empty table for that key, and :meth:`PluginStatus.__info` then tries +``" ".join(req_lst[0])`` on the empty list, raising +``IndexError: list index out of range``. +""" + +# ------------------------ +# Python modules +# ------------------------ +import os +import unittest +from unittest.mock import Mock + + +def _has_gtk_display(): + """ + Return True only if a real Gtk display is available. + + PluginManager.py imports Gtk at module load. Constructing a real + PluginStatus is impossible without a display, and we sidestep that + via ``__new__``-bypass below - but the import alone can still trip + on hosts where Gtk has no backend (CI with GDK_BACKEND=-). + """ + if not os.environ.get("DISPLAY"): + return False + if os.environ.get("GDK_BACKEND") == "-": + return False + try: + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + + return bool(Gtk.init_check([])[0]) + except Exception: # pylint: disable=broad-except + return False + + +_HAS_GTK_DISPLAY = _has_gtk_display() + + +# ------------------------------------------------------------ +# +# TestPluginManagerInfoEmptyRequires +# +# ------------------------------------------------------------ +@unittest.skipUnless( + _HAS_GTK_DISPLAY, + "needs a real Gtk display (run under xvfb-run)", +) +class TestPluginManagerInfoEmptyRequires(unittest.TestCase): + """ + Regression for bug 13979. + """ + + def _make_addon(self): + """ + Listing-shaped dict matching the real PostgreSQL Enhanced entry + in ``addons/gramps61/listings/addons-en.json`` - the unique + trigger of the original crash (the only addon currently shipping + with a present-but-empty ``re`` key). + """ + return { + "n": "PostgreSQL Enhanced", + "i": "postgresqlenhanced", + "t": 12, + "d": "Advanced PostgreSQL backend.", + "v": "1.5.4", + "g": "6.1", + "s": 3, + "z": "PostgreSQLEnhanced.addon.tgz", + "rm": ["psycopg"], + "re": [], + "h": "https://example.invalid/wiki/PostgreSQLEnhanced", + "a": 1, + "_u": "https://example.invalid/download/", + } + + def _make_status(self, addon): + """ + Build a PluginStatus via ``__new__``-bypass, stubbing only the + attributes that ``__info`` touches. + """ + # Import inside the method so the module-level Gtk imports run + # only after the display skip has been evaluated. + from PluginManager.PluginManager import PluginStatus + + status = PluginStatus.__new__(PluginStatus) + status.addons = [addon] + # get_plugin returns None so __info takes the "installed plugins" + # branch where the bug lives. + status._preg = Mock() + status._preg.get_plugin.return_value = None + # _bufin is a Gtk text-buffer mutator; we only care that __info + # gets through it without raising, not what it writes. + status._bufin = Mock() + # __info also consults _pmgr for loaded/failed lists and self.hidden + # after the requirements block. Stub them to return empty. + status._pmgr = Mock() + status._pmgr.get_success_list.return_value = [] + status._pmgr.get_fail_list.return_value = [] + status.hidden = [] + status.help = "" + status.helpname = "" + return status + + def test_info_with_empty_requires_exe_does_not_raise(self): + """ + Pre-fix this raised ``IndexError`` at + ``PluginManager.py:655 txt = " ".join(req_lst[0])`` when + iterating to the Executables entry of the Requirements list + (whose table is empty for PostgreSQL Enhanced). Post-fix the + empty entry is skipped. + """ + status = self._make_status(self._make_addon()) + + try: + # __info is name-mangled on PluginStatus. + status._PluginStatus__info("postgresqlenhanced") + except IndexError as exc: + self.fail( + "Bug 13979: PluginStatus.__info() must not crash on an " + "addon whose listing has a present-but-empty requires " + "key (e.g. PostgreSQL Enhanced's `\"re\": []`). Got: %s" + % exc + ) + + # Sanity: the Python modules requirement still gets rendered. + bufin_labels = [ + call.args[0] for call in status._bufin.call_args_list if call.args + ] + self.assertTrue( + any("Python modules" in label for label in bufin_labels), + "Expected the non-empty Python modules requirement to still " + "be rendered; the fix must only skip empty tables. Got " + "labels: %r" % bufin_labels, + ) + # And the empty Executables entry must NOT be rendered. + self.assertFalse( + any("Executables" in label for label in bufin_labels), + "Empty Executables entry should be skipped, not rendered. " + "Got labels: %r" % bufin_labels, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/PrerequisitesCheckerGramplet/PrerequisitesCheckerGramplet.gpr.py b/PrerequisitesCheckerGramplet/PrerequisitesCheckerGramplet.gpr.py index 929e2d606..7520c3655 100644 --- a/PrerequisitesCheckerGramplet/PrerequisitesCheckerGramplet.gpr.py +++ b/PrerequisitesCheckerGramplet/PrerequisitesCheckerGramplet.gpr.py @@ -28,7 +28,7 @@ id="Prerequisites Checker Gramplet", name=_("Prerequisites Checker"), description=_("Prerequisites Checker Gramplet"), - version = '1.2.9', + version = '1.2.10', gramps_target_version="6.0", status=STABLE, fname="PrerequisitesCheckerGramplet.py", diff --git a/PrerequisitesCheckerGramplet/PrerequisitesCheckerGramplet.py b/PrerequisitesCheckerGramplet/PrerequisitesCheckerGramplet.py index 4ef20b4f7..ca72a2abc 100755 --- a/PrerequisitesCheckerGramplet/PrerequisitesCheckerGramplet.py +++ b/PrerequisitesCheckerGramplet/PrerequisitesCheckerGramplet.py @@ -168,7 +168,12 @@ def main(self): the web, we just yield again if not ready. """ self.count += 1 - if self.uistate.viewmanager.active_page.bottombar: + active_page = self.uistate.viewmanager.active_page + if active_page is None: + # Closing the family tree clears active_page before the + # gramplet framework stops pumping this generator (bug 13966). + return + if active_page.bottombar: # The dashboard has no sidebar and bottombar. # For all other views, the database must be opened if not self.dbstate.db.is_open(): diff --git a/PrerequisitesCheckerGramplet/tests/__init__.py b/PrerequisitesCheckerGramplet/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/PrerequisitesCheckerGramplet/tests/test_main_active_page_none.py b/PrerequisitesCheckerGramplet/tests/test_main_active_page_none.py new file mode 100644 index 000000000..63ea6f23d --- /dev/null +++ b/PrerequisitesCheckerGramplet/tests/test_main_active_page_none.py @@ -0,0 +1,131 @@ +# +# 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 Mantis bug 13966. + +When the family tree is closed while the +``PrerequisitesCheckerGramplet`` gramplet is still being stepped by +the gramplet framework's ``_updater`` / ``next(self._generator)`` +loop, ``self.uistate.viewmanager.active_page`` becomes ``None``. The +gramplet's ``main()`` reads ``…active_page.bottombar`` unguarded and +raises ``AttributeError: 'NoneType' object has no attribute +'bottombar'``. + +This test drives ``main()`` once with a stub ``uistate`` whose +``active_page`` is ``None`` and asserts the generator exits cleanly +(via ``StopIteration``) without ``AttributeError``. +""" + +import importlib.util +import os +import unittest +from unittest import mock + +# The addon's module file (PrerequisitesCheckerGramplet.py) shares its +# basename with the package directory; the dotted-path loader registers +# the directory as a namespace package first, so a plain +# `import PrerequisitesCheckerGramplet` then binds the package rather +# than the submodule (the bug 0012691 trap). Load the module file +# directly to sidestep that. +_addon_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_module_path = os.path.join(_addon_dir, "PrerequisitesCheckerGramplet.py") +_spec = importlib.util.spec_from_file_location( + "PrerequisitesCheckerGramplet_module", _module_path +) +pcg = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(pcg) + + +class TestMainHandlesActivePageNone(unittest.TestCase): + """main() must not raise when viewmanager.active_page is None. + + Bug 13966: on tree close, the framework still pumps the generator + via _gramplet.py:331 _updater → next(self._generator). The gramplet + assumes an active page exists; on close it doesn't. + """ + + def _make_gramplet(self, active_page): + # Build a gramplet instance without running Gramplet.__init__ + # (which constructs Gtk widgets). We only need to exercise + # main() with the minimum attributes it reads. + gramplet = pcg.PrerequisitesCheckerGramplet.__new__( + pcg.PrerequisitesCheckerGramplet + ) + gramplet.count = 0 + gramplet.latest_gramps_version = False + gramplet.has_run = False + + # The crash path: main() reads + # self.uistate.viewmanager.active_page.bottombar + uistate = mock.MagicMock() + uistate.viewmanager.active_page = active_page + gramplet.uistate = uistate + + # dbstate.db.is_open() — only reached when active_page is + # truthy with a bottombar. Stubbed for completeness. + gramplet.dbstate = mock.MagicMock() + gramplet.dbstate.db.is_open.return_value = False + return gramplet + + def test_main_does_not_crash_when_active_page_is_none(self): + """The exact bug 13966 traceback: active_page is None on tree + close, the unguarded ``.bottombar`` raises AttributeError.""" + gramplet = self._make_gramplet(active_page=None) + generator = gramplet.main() + # The framework calls next() on the generator. The bug + # surfaces on the very first next() — before any yield. + # Post-fix: main() must return cleanly (StopIteration), not + # raise AttributeError. + with self.assertRaises(StopIteration): + next(generator) + + def test_main_still_works_on_non_dashboard_view(self): + """Regression guard: when a real page IS active, the existing + bottombar / db-open / count<3 short-circuit chain still runs. + With db closed and the bottombar truthy, main() returns early + (no yield) just as before — same StopIteration on first + next().""" + active_page = mock.MagicMock() + active_page.bottombar = mock.MagicMock() # truthy + gramplet = self._make_gramplet(active_page=active_page) + gramplet.dbstate.db.is_open.return_value = False + generator = gramplet.main() + with self.assertRaises(StopIteration): + next(generator) + + def test_main_on_dashboard_with_pending_version_yields(self): + """Regression guard: on the dashboard (bottombar falsy) with + the upstream-version fetch still in flight (latest_gramps_version + is False), main() yields rather than returning. Bug 13966's + guard must not change this path.""" + active_page = mock.MagicMock() + active_page.bottombar = False # dashboard + gramplet = self._make_gramplet(active_page=active_page) + # latest_gramps_version is False (default) → enters the + # `while … is False: yield True` loop and yields. + generator = gramplet.main() + self.assertTrue(next(generator), + "Dashboard path should yield True while the " + "latest-version fetch is pending") + + +if __name__ == "__main__": + unittest.main() diff --git a/RebuildTypes/RebuildTypes.gpr.py b/RebuildTypes/RebuildTypes.gpr.py deleted file mode 100644 index e6d31780f..000000000 --- a/RebuildTypes/RebuildTypes.gpr.py +++ /dev/null @@ -1,39 +0,0 @@ -# -# Gramps - a GTK+/GNOME based genealogy program -# -# Copyright (C) 2013 Nick Hall -# -# 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: RebuildTypes.gpr.py 2374 2014-05-02 13:29:19Z romjerome $ - -register( - TOOL, - id="rebuild_types", - name=_("Rebuild Gramps Types"), - description=_("Rebuilds Gramps Types"), - version = '1.0.21', - gramps_target_version="6.0", - include_in_listing=False, - status=UNSTABLE, - fname="RebuildTypes.py", - authors=["Nick Hall"], - authors_email=["nick__hall@hotmail.com"], - category=TOOL_DBFIX, - toolclass="RebuildTypes", - optionclass="RebuildTypesOptions", - tool_modes=[TOOL_MODE_GUI, TOOL_MODE_CLI], -) diff --git a/RebuildTypes/RebuildTypes.py b/RebuildTypes/RebuildTypes.py deleted file mode 100644 index 5aecf5e5e..000000000 --- a/RebuildTypes/RebuildTypes.py +++ /dev/null @@ -1,90 +0,0 @@ -# -# Gramps - a GTK+/GNOME based genealogy program -# -# Copyright (C) 2013 Nick Hall -# -# 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: RebuildTypes.py 2228 2013-10-17 16:46:53Z romjerome $ - -"""Tools/Database Processing/Rebuild Types""" - - -#------------------------------------------------------------------------- -# -# GRAMPS modules -# -#------------------------------------------------------------------------- -from gui.plug import tool -from QuestionDialog import OkDialog - -from gramps.gen.const import GRAMPS_LOCALE as glocale -try: - _trans = glocale.get_addon_translator(__file__) -except ValueError: - _trans = glocale.translation -_ = _trans.gettext - -#------------------------------------------------------------------------- -# -# RebuildTypes -# -#------------------------------------------------------------------------- -class RebuildTypes(tool.Tool): - """ - Rebuild Gramps Types - """ - - def __init__(self, dbstate, uistate, options_class, name, callback=None): - - tool.Tool.__init__(self, dbstate, options_class, name) - - if self.db.readonly: - return - - person_event_types = [] - family_event_types = [] - for handle in self.db.get_event_handles(): - event = self.db.get_event_from_handle(handle) - if event.get_type().is_custom(): - links = [x[0] for x in self.db.find_backlink_handles(handle)] - type_str = str(event.get_type()) - if 'Person' in links and type_str not in person_event_types: - person_event_types.append(type_str) - if 'Family' in links and type_str not in family_event_types: - family_event_types.append(type_str) - - self.db.individual_event_names.update(person_event_types) - self.db.family_event_names.update(family_event_types) - - total = len(person_event_types) + len(family_event_types) - - OkDialog(_("Gramps Types rebuilt"), - _('Found %d custom event types') % total, - parent=uistate.window) - -#------------------------------------------------------------------------ -# -# RebuildTypesOptions -# -#------------------------------------------------------------------------ -class RebuildTypesOptions(tool.ToolOptions): - """ - Defines options and provides handling interface. - """ - - def __init__(self, name, person_id=None): - tool.ToolOptions.__init__(self, name, person_id) diff --git a/RebuildTypes/po/da-local.po b/RebuildTypes/po/da-local.po deleted file mode 100644 index 2695e6e62..000000000 --- a/RebuildTypes/po/da-local.po +++ /dev/null @@ -1,28 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-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 "Rebuild Gramps Types" -msgstr "Gendan Gramps typer" - -msgid "Rebuilds Gramps Types" -msgstr "Gendanner Gramps typer" - -msgid "Gramps Types rebuilt" -msgstr "Gramps typer er gendannet" - -#, python-format -msgid "Found %d custom event types" -msgstr "Fandt %d tilpassede hændelsestyper" diff --git a/RebuildTypes/po/de-local.po b/RebuildTypes/po/de-local.po deleted file mode 100644 index a887bd299..000000000 --- a/RebuildTypes/po/de-local.po +++ /dev/null @@ -1,28 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: de\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-0800\n" -"PO-Revision-Date: 2025-10-26 23: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.14.1-dev\n" - -msgid "Rebuild Gramps Types" -msgstr "Gramps-Typen neu erstellen" - -msgid "Rebuilds Gramps Types" -msgstr "Erneuert Gramps-Typen" - -msgid "Gramps Types rebuilt" -msgstr "Gramps-Typen neu erstellt" - -#, python-format -msgid "Found %d custom event types" -msgstr "%d benutzerdefinierte Ereignisarten gefunden" diff --git a/RebuildTypes/po/es-local.po b/RebuildTypes/po/es-local.po deleted file mode 100644 index 8a386a7c8..000000000 --- a/RebuildTypes/po/es-local.po +++ /dev/null @@ -1,28 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: GRAMPS 3.1\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-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 "Rebuild Gramps Types" -msgstr "Reconstruir Tipos de Gramps" - -msgid "Rebuilds Gramps Types" -msgstr "Reconstruye Tipos Gramps" - -msgid "Gramps Types rebuilt" -msgstr "Reconstruir Tipos Gramps" - -#, python-format -msgid "Found %d custom event types" -msgstr "Se encontraron %d tipos de evento personales" diff --git a/RebuildTypes/po/fi-local.po b/RebuildTypes/po/fi-local.po deleted file mode 100644 index 53f3a621a..000000000 --- a/RebuildTypes/po/fi-local.po +++ /dev/null @@ -1,29 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: fi\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-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 "Rebuild Gramps Types" -msgstr "Rakenetaan Gramps-tyypit uudelleen" - -msgid "Rebuilds Gramps Types" -msgstr "Rakenna Gramps-tyypit uudelleen" - -msgid "Gramps Types rebuilt" -msgstr "Gramps-tyypit rakennettu uudelleen" - -#, python-format -msgid "Found %d custom event types" -msgstr "Löytyi %d mukautettua tapahtumatyyppiä" diff --git a/RebuildTypes/po/fr-local.po b/RebuildTypes/po/fr-local.po deleted file mode 100644 index 35f805b13..000000000 --- a/RebuildTypes/po/fr-local.po +++ /dev/null @@ -1,28 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: trunk\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-0800\n" -"PO-Revision-Date: 2025-03-02 13:48+0000\n" -"Last-Translator: Julien Lepiller \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.10.3-dev\n" - -msgid "Rebuild Gramps Types" -msgstr "Reconstruire les types Gramps" - -msgid "Rebuilds Gramps Types" -msgstr "Reconstruit les types Gramps" - -msgid "Gramps Types rebuilt" -msgstr "Les types Gramps reconstruits" - -#, python-format -msgid "Found %d custom event types" -msgstr "Trouvé %d type(s) d'événement personnalisé(s)" diff --git a/RebuildTypes/po/hr-local.po b/RebuildTypes/po/hr-local.po deleted file mode 100644 index 84a01cb7c..000000000 --- a/RebuildTypes/po/hr-local.po +++ /dev/null @@ -1,29 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: Gramps 5.x\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-0800\n" -"PO-Revision-Date: 2026-04-08 15:09+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.17-dev\n" - -msgid "Rebuild Gramps Types" -msgstr "Ponovo izgradi Grampsove vrste" - -msgid "Rebuilds Gramps Types" -msgstr "Ponovo izgrađuje Grampsove vrste" - -msgid "Gramps Types rebuilt" -msgstr "Grampsove vrste su ponovo izgrađene" - -#, python-format -msgid "Found %d custom event types" -msgstr "Broj pronađenih vrsta prilagođenih događaja: %d" diff --git a/RebuildTypes/po/nb-local.po b/RebuildTypes/po/nb-local.po deleted file mode 100644 index 4e4278f53..000000000 --- a/RebuildTypes/po/nb-local.po +++ /dev/null @@ -1,20 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: nb\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-0800\n" -"PO-Revision-Date: 2025-02-26 20:54+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 5.10.2-dev\n" -"Generated-By: pygettext.py 1.4\n" - -#, python-format -msgid "Found %d custom event types" -msgstr "Fant %d tilpassede hendelsestyper" diff --git a/RebuildTypes/po/nl-local.po b/RebuildTypes/po/nl-local.po deleted file mode 100755 index 560c13403..000000000 --- a/RebuildTypes/po/nl-local.po +++ /dev/null @@ -1,28 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: MediaMerge 5.x\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-0800\n" -"PO-Revision-Date: 2025-03-07 06:31+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.10.3-dev\n" - -msgid "Rebuild Gramps Types" -msgstr "Herbouw Gramps-typen" - -msgid "Rebuilds Gramps Types" -msgstr "Herbouwt Gramps-typen" - -msgid "Gramps Types rebuilt" -msgstr "Herbouwde Gramps-typen" - -#, python-format -msgid "Found %d custom event types" -msgstr "%d aangepaste gebeurtenistypen gevonden" diff --git a/RebuildTypes/po/pt_PT-local.po b/RebuildTypes/po/pt_PT-local.po deleted file mode 100644 index 2e0407c47..000000000 --- a/RebuildTypes/po/pt_PT-local.po +++ /dev/null @@ -1,28 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: gramps51\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-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 "Rebuild Gramps Types" -msgstr "Reconstruir tipos do Gramps" - -msgid "Rebuilds Gramps Types" -msgstr "Reconstrói tipos do Gramps" - -msgid "Gramps Types rebuilt" -msgstr "Tipos do Gramps reconstruídos" - -#, python-format -msgid "Found %d custom event types" -msgstr "Encontrados %d tipos de eventos personalizados" diff --git a/RebuildTypes/po/ru-local.po b/RebuildTypes/po/ru-local.po deleted file mode 100644 index b14a851a7..000000000 --- a/RebuildTypes/po/ru-local.po +++ /dev/null @@ -1,30 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: gramps50\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-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 "Rebuild Gramps Types" -msgstr "Перестроение типов Gramps" - -msgid "Rebuilds Gramps Types" -msgstr "Перестраивает типы Gramps" - -msgid "Gramps Types rebuilt" -msgstr "Перестроение типов Gramps" - -#, python-format -msgid "Found %d custom event types" -msgstr "Найдено пользовательских типов событий: %d" diff --git a/RebuildTypes/po/sk-local.po b/RebuildTypes/po/sk-local.po deleted file mode 100644 index 6aea16a5f..000000000 --- a/RebuildTypes/po/sk-local.po +++ /dev/null @@ -1,28 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: GRAMPS 3.1.3\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-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 "Rebuild Gramps Types" -msgstr "Obnovenie typov Gramps" - -msgid "Rebuilds Gramps Types" -msgstr "Obnovuje typy v Gramps" - -msgid "Gramps Types rebuilt" -msgstr "Typy v Gramps boli obnovené" - -#, python-format -msgid "Found %d custom event types" -msgstr "Nájdených %d vlastných typov udalostí" diff --git a/RebuildTypes/po/sv-local.po b/RebuildTypes/po/sv-local.po deleted file mode 100644 index ee452c5e0..000000000 --- a/RebuildTypes/po/sv-local.po +++ /dev/null @@ -1,28 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-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 "Rebuild Gramps Types" -msgstr "Återuppbygg Grampstyper" - -msgid "Rebuilds Gramps Types" -msgstr "Återuppbygger Grampstyper" - -msgid "Gramps Types rebuilt" -msgstr "Grampstyper återuppbyggda" - -#, python-format -msgid "Found %d custom event types" -msgstr "Fann %d anpassade händelsetyper" diff --git a/RebuildTypes/po/template.pot b/RebuildTypes/po/template.pot deleted file mode 100644 index 8c7b7fd98..000000000 --- a/RebuildTypes/po/template.pot +++ /dev/null @@ -1,35 +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-06 08:58-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" - -#: RebuildTypes/RebuildTypes.gpr.py:26 -msgid "Rebuild Gramps Types" -msgstr "" - -#: RebuildTypes/RebuildTypes.gpr.py:27 -msgid "Rebuilds Gramps Types" -msgstr "" - -#: RebuildTypes/RebuildTypes.py:75 -msgid "Gramps Types rebuilt" -msgstr "" - -#: RebuildTypes/RebuildTypes.py:76 -#, python-format -msgid "Found %d custom event types" -msgstr "" diff --git a/RebuildTypes/po/uk-local.po b/RebuildTypes/po/uk-local.po deleted file mode 100644 index 6706084b5..000000000 --- a/RebuildTypes/po/uk-local.po +++ /dev/null @@ -1,29 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-06 08:58-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 "Rebuild Gramps Types" -msgstr "Перебудова типів Gramps" - -msgid "Rebuilds Gramps Types" -msgstr "Перебудовує типи Gramps" - -msgid "Gramps Types rebuilt" -msgstr "Типи Gramps перебудовано" - -#, python-format -msgid "Found %d custom event types" -msgstr "Знайдено %d користувацьких типів подій" diff --git a/WebSearch/.gitignore b/WebSearch/.gitignore index 33c042cf4..649838b41 100644 --- a/WebSearch/.gitignore +++ b/WebSearch/.gitignore @@ -2,4 +2,10 @@ __pycache__ config.ini data -locale \ No newline at end of file +locale +scripts +pyproject.toml +RELEASE_WORKFLOW.local.md +po/*~ +db/* +!db/migrations diff --git a/WebSearch/WebSearch.gpr.py b/WebSearch/WebSearch.gpr.py index 968e254b0..f4567b00c 100644 --- a/WebSearch/WebSearch.gpr.py +++ b/WebSearch/WebSearch.gpr.py @@ -37,7 +37,7 @@ "Person, Place, Family, or Source record" ), status=STABLE, - version = '1.10.9', + version = '1.10.18', fname="WebSearch.py", height=20, detached_width=400, diff --git a/WebSearch/WebSearch.py b/WebSearch/WebSearch.py index fb2bffd1b..acd3529e2 100644 --- a/WebSearch/WebSearch.py +++ b/WebSearch/WebSearch.py @@ -34,6 +34,7 @@ # Standard Python libraries # -------------------------- from functools import partial +import hashlib import json import random import os @@ -62,6 +63,7 @@ from gramps.gen.plug import Gramplet from gramps.gui.display import display_url from gramps.gui.editors import EditObject +from gramps.gui.editors.editurl import EditUrl from gramps.gen.errors import HandleError # -------------------------- @@ -188,6 +190,8 @@ ("saved_attribute_value", str), ("saved_to", str), ("visited_record_id", int), + ("reference_type", str), + ("reference_data_json", str), ] ModelColumns = IntEnum( @@ -248,6 +252,8 @@ def __init__(self, gui): source=None, active_url=None, active_tree_path=None, + active_link_data=None, + database_id=None, last_active_entity_handle=None, last_active_entity_type=None, previous_ai_site_provider=None, @@ -285,6 +291,7 @@ def __init__(self, gui): hide_all=self.builder.get_object("hide_all"), edit_attribute=self.builder.get_object("edit_attribute"), edit_note=self.builder.get_object("edit_note"), + edit_internet=self.builder.get_object("edit_internet"), ), ), ), @@ -356,7 +363,9 @@ def __init__(self, gui): self.website_loader = WebsiteLoader() self.url_formatter = UrlFormatter(self.config_ini_manager) Gramplet.__init__(self, gui) - self.activity_row_generator = ActivityRowGenerator(self.activities_model) + self.activity_row_generator = ActivityRowGenerator( + self.activities_model, self._context.database_id + ) self.refresh_activities_tab() def init_database_models(self): @@ -587,8 +596,8 @@ def getShuffledPendingDomains(self): def refresh_place_history_section(self, place_history_request_data): """Refreshes the section displaying the historical administrative data for a place.""" - place_history_record = self.place_history_model.first_by_field( - "event_handle", place_history_request_data.handle + place_history_record = self.get_current_or_legacy_place_history_record( + place_history_request_data.handle ) if place_history_record: results = PlaceHistoryStorage().load_results_from_file(place_history_record) @@ -643,6 +652,14 @@ def refresh_place_history_section(self, place_history_request_data): daemon=True, ).start() + def get_current_or_legacy_place_history_record(self, event_handle): + """Return a place history record from legacy data or the current database.""" + records = self.place_history_model.get_by_field("event_handle", event_handle) + for record in records: + if self.is_current_or_legacy_record(record, self._context.database_id): + return record + return None + def show_loading_message_in_notes(self): """Displays a loading message in the notes text view.""" self.update_message_in_ai_notes( @@ -769,6 +786,7 @@ def fetch_place_history_in_background(self, place_history_request_data): "place_type": results.get("place_type", None), "latitude": self._context.active_place_latitude, "longitude": self._context.active_place_longitude, + **self.get_database_id_record_part(), } ) @@ -780,6 +798,7 @@ def fetch_place_history_in_background(self, place_history_request_data): ADMINISTRATIVE_DIVISIONS_DIR, filename ), "activity_type": ActivityType.PLACE_HISTORY_LOAD.value, + **self.get_database_id_record_part(), } ) self.refresh_activities_tab() @@ -858,6 +877,7 @@ def on_sites_fetched(self, unused_gramplet, results): def db_changed(self): """Responds to changes in the database and updates the active context accordingly.""" + self._context.database_id = self.get_current_database_id() self.attribute_editor_manager = AttributeEditorManager( self.dbstate, self.gui.uistate, self.activities_model ) @@ -877,8 +897,12 @@ def db_changed(self): saves_model=self.saves_model, hidden_links_model=self.hidden_links_model, activities_model=self.activities_model, + database_id=self._context.database_id, ) ) + self.activity_row_generator = ActivityRowGenerator( + self.activities_model, self._context.database_id + ) self.note_links_loader = NoteLinksLoader(self.dbstate.db) # Connect signals for all supported types @@ -919,6 +943,20 @@ def db_changed(self): if notebook: notebook.connect("switch-page", self.on_category_changed) + def get_current_database_id(self): + """Return a stable, privacy-preserving ID for the current Gramps database.""" + try: + save_path = self.dbstate.db.get_save_path() + except Exception: # pylint: disable=broad-exception-caught + return None + + if not save_path: + return None + + return hashlib.sha256(os.path.abspath(save_path).encode("utf-8")).hexdigest()[ + :16 + ] + def refresh_main_treeview_tab(self, nav_type, obj_handle): """Dispatch refresh logic depending on entity type.""" dispatcher = { @@ -1011,8 +1049,9 @@ def collect_all_websites(self, ctx): def insert_websites_into_model(self, websites, link_context: LinkContext): """Formats each website entry and appends it to the Gtk model.""" for website_data in websites: - model_row = self.model_row_generator.generate(link_context, website_data) - if model_row: + for model_row in self.model_row_generator.generate_many( + link_context, website_data + ): self.model.append([model_row[name] for name, _ in MODEL_SCHEMA]) def on_link_clicked(self, unused_tree_view, path, unused_column): @@ -1063,13 +1102,9 @@ def add_icon_event(self, settings): source_file_path = self.model.get_value( tree_iter, ModelColumns.SOURCE_FILE_PATH.value ) + database_id = self._context.database_id - if ( - not model.query() - .where("link", url) - .where("obj_handle", obj_handle) - .exists() - ): + if not self.has_link_record(model, url, obj_handle, database_id): data = { "link": url, @@ -1078,6 +1113,8 @@ def add_icon_event(self, settings): "obj_gramps_id": obj_gramps_id, "source_file_path": source_file_path, } + if database_id: + data["database_id"] = database_id if saved_to: @@ -1126,6 +1163,79 @@ def add_icon_event(self, settings): except Exception as e: # pylint: disable=broad-exception-caught print(f"❌ Error loading icon: {e}", file=sys.stderr) + def add_saved_link_from_snapshot(self, link_data, settings): + """Records a saved link using row data captured before any DB refresh.""" + if not link_data: + print("❌ Error: No saved link data!", file=sys.stderr) + return + + database_id = self._context.database_id + if self.has_link_record( + self.saves_model, link_data["link"], link_data["obj_handle"], database_id + ): + return + + data = { + "link": link_data["link"], + "nav_type": link_data["nav_type"], + "obj_handle": link_data["obj_handle"], + "obj_gramps_id": link_data["obj_gramps_id"], + "source_file_path": link_data["source_file_path"], + "saved_to": settings.saved_to, + } + if database_id: + data["database_id"] = database_id + + activity_data = { + "link": link_data["link"], + "nav_type": link_data["nav_type"], + "obj_handle": link_data["obj_handle"], + "obj_gramps_id": link_data["obj_gramps_id"], + "source_file_path": link_data["source_file_path"], + } + if database_id: + activity_data["database_id"] = database_id + + if settings.saved_to == SavedTo.NOTE.value: + data["note_gramps_id"] = settings.note_gramps_id + data["note_handle"] = settings.note_handle + activity_data["activity_type"] = ActivityType.LINK_SAVE_TO_NOTE.value + activity_data["note_gramps_id"] = settings.note_gramps_id + activity_data["note_handle"] = settings.note_handle + elif settings.saved_to == SavedTo.ATTRIBUTE.value: + data["attribute_type"] = settings.attribute_type + data["attribute_value"] = settings.attribute_value + activity_data["activity_type"] = ActivityType.LINK_SAVE_TO_ATTRIBUTE.value + activity_data["attribute_type"] = settings.attribute_type + activity_data["attribute_value"] = settings.attribute_value + else: + return + + record = self.saves_model.create(data) + activity_data["saves_record_id"] = record.get("id") + self.activities_model.create(activity_data) + self.refresh_activities_tab() + + def has_link_record(self, model, link, obj_handle, database_id): + """Return True for legacy records or records from the current database.""" + records = model.query().where("link", link).where("obj_handle", obj_handle).get() + return any( + self.is_current_or_legacy_record(record, database_id) + for record in records + ) + + @staticmethod + def is_current_or_legacy_record(record, database_id): + """A legacy record has no database_id and remains visible in every database.""" + record_database_id = record.get("database_id") + return not record_database_id or record_database_id == database_id + + def get_database_id_record_part(self): + """Return database_id field for new local records when a database is open.""" + if not self._context.database_id: + return {} + return {"database_id": self._context.database_id} + def active_person_changed(self, handle): """Handles updates when the active person changes in the GUI.""" self._context.last_active_entity_handle = handle @@ -1544,6 +1654,9 @@ def translate(self): self.ui.context_menus.main.items.edit_note.set_label( _("Edit Note with the Link") ) + self.ui.context_menus.main.items.edit_internet.set_label( + _("Edit Internet link") + ) self.ui.ai_recommendations_label.set_text(_("🔍 AI Suggestions")) @@ -1670,12 +1783,29 @@ def on_button_press(self, widget, event): source_type = self.model.get_value( tree_iter, ModelColumns.SOURCE_TYPE.value ) + reference_type = self.model.get_value( + tree_iter, ModelColumns.REFERENCE_TYPE.value + ) saved_icon_visible = self.model.get_value( tree_iter, ModelColumns.SAVED_ICON_VISIBLE.value ) self._context.active_tree_path = path self._context.active_url = url + self._context.active_link_data = { + "title": self.model.get_value(tree_iter, ModelColumns.TITLE.value), + "link": url, + "nav_type": nav_type, + "obj_handle": self.model.get_value( + tree_iter, ModelColumns.OBJ_HANDLE.value + ), + "obj_gramps_id": self.model.get_value( + tree_iter, ModelColumns.OBJ_GRAMPS_ID.value + ), + "source_file_path": self.model.get_value( + tree_iter, ModelColumns.SOURCE_FILE_PATH.value + ), + } self.ui.context_menus.main.menu.show_all() if ( @@ -1721,18 +1851,37 @@ def on_button_press(self, widget, event): saved_value = self.model.get_value( tree_iter, ModelColumns.SAVED_ATTRIBUTE_VALUE.value ) - if saved_type and saved_value: + if ( + saved_type + and saved_value + or ( + reference_type == SourceTypes.ATTRIBUTE.value + and nav_type + not in [ + SupportedNavTypes.SOURCES.value, + SupportedNavTypes.CITATIONS.value, + ] + ) + ): self.ui.context_menus.main.items.edit_attribute.show() else: self.ui.context_menus.main.items.edit_attribute.hide() # notes saved_to = self.model.get_value(tree_iter, ModelColumns.SAVED_TO.value) - if saved_to == SavedTo.NOTE.value: + if ( + saved_to == SavedTo.NOTE.value + or reference_type == SourceTypes.NOTE.value + ): self.ui.context_menus.main.items.edit_note.show() else: self.ui.context_menus.main.items.edit_note.hide() + if reference_type == SourceTypes.INTERNET.value: + self.ui.context_menus.main.items.edit_internet.show() + else: + self.ui.context_menus.main.items.edit_internet.hide() + self.ui.context_menus.main.menu.popup_at_pointer(event) def on_edit_attribute(self, unused_widget): @@ -1741,6 +1890,32 @@ def on_edit_attribute(self, unused_widget): tree_iter = self.get_active_tree_iter(path) nav_type = self.model.get_value(tree_iter, ModelColumns.NAV_TYPE.value) obj_handle = self.model.get_value(tree_iter, ModelColumns.OBJ_HANDLE.value) + reference_type, reference_data = self.get_source_reference_data(tree_iter) + if reference_type == SourceTypes.ATTRIBUTE.value: + if nav_type in [ + SupportedNavTypes.SOURCES.value, + SupportedNavTypes.CITATIONS.value, + ]: + return + try: + self.attribute_editor_manager.edit_by_obj_handle_and_attr_reference( + SimpleNamespace( + nav_type=nav_type, + obj_handle=obj_handle, + attr_index=reference_data.get("index"), + attr_type=reference_data.get("attribute_type"), + attr_value=reference_data.get("attribute_value"), + callback=partial( + self.on_source_reference_updated, nav_type, obj_handle + ), + ) + ) + except AttributeNotFoundError: + self.show_notification( + _("Attribute no longer matches this WebSearch row") + ) + return + saved_record_id = self.model.get_value( tree_iter, ModelColumns.SAVED_RECORD_ID.value ) @@ -1791,6 +1966,23 @@ def on_edit_note(self, unused_widget): tree_iter = self.get_active_tree_iter(path) nav_type = self.model.get_value(tree_iter, ModelColumns.NAV_TYPE.value) obj_handle = self.model.get_value(tree_iter, ModelColumns.OBJ_HANDLE.value) + reference_type, reference_data = self.get_source_reference_data(tree_iter) + if reference_type == SourceTypes.NOTE.value: + try: + self.note_editor_manager.edit_by_obj_handle_and_note_handle( + SimpleNamespace( + nav_type=nav_type, + obj_handle=obj_handle, + note_handle=reference_data.get("note_handle", ""), + callback=partial( + self.on_source_reference_updated, nav_type, obj_handle + ), + ) + ) + except NoteNotFoundError: + self.show_notification(_("Note no longer exists")) + return + saved_record_id = self.model.get_value( tree_iter, ModelColumns.SAVED_RECORD_ID.value ) @@ -1825,14 +2017,132 @@ def on_note_updated_via_editor_manager(self, saved_record_id): self.saves_model.update(saved_record_id, record) self.refresh_activities_tab() + def on_source_reference_updated(self, nav_type, obj_handle, unused_result=None): + """Refresh WebSearch after editing a source Attribute, Note, or Internet item.""" + self.refresh_main_treeview_tab(nav_type, obj_handle) + self.refresh_activities_tab() + + def get_source_reference_data(self, tree_iter): + """Return source reference type and JSON data stored in hidden row columns.""" + reference_type = self.model.get_value( + tree_iter, ModelColumns.REFERENCE_TYPE.value + ) + reference_data_json = self.model.get_value( + tree_iter, ModelColumns.REFERENCE_DATA_JSON.value + ) + try: + reference_data = json.loads(reference_data_json or "{}") + except json.JSONDecodeError: + reference_data = {} + return reference_type, reference_data + + def on_edit_internet(self, unused_widget): + """Open the Internet Address editor for a URL found in the Internet tab.""" + path = self._context.active_tree_path + tree_iter = self.get_active_tree_iter(path) + nav_type = self.model.get_value(tree_iter, ModelColumns.NAV_TYPE.value) + obj_handle = self.model.get_value(tree_iter, ModelColumns.OBJ_HANDLE.value) + reference_type, reference_data = self.get_source_reference_data(tree_iter) + if reference_type != SourceTypes.INTERNET.value: + return + + obj = self.get_internet_parent_object(nav_type, obj_handle) + if obj is None: + return + + try: + url_index, url_obj = self.find_url_by_reference(obj, reference_data) + except ValueError: + self.show_notification( + _("Internet link no longer matches this WebSearch row") + ) + return + + EditUrl( + self.dbstate, + self.gui.uistate, + [], + "", + url_obj, + callback=partial(self.on_internet_url_edited, nav_type, obj, url_index), + ) + + def find_url_by_reference(self, obj, reference_data): + """Find one URL by saved index first, then by unique path/type/description match.""" + url_list = obj.get_url_list() + index = reference_data.get("index") + if isinstance(index, int) and 0 <= index < len(url_list): + url_obj = url_list[index] + if self.url_matches_reference(url_obj, reference_data): + return index, url_obj + + matches = [ + (i, url_obj) + for i, url_obj in enumerate(url_list) + if self.url_matches_reference(url_obj, reference_data) + ] + if len(matches) != 1: + raise ValueError("URL reference no longer matches uniquely") + return matches[0] + + @staticmethod + def url_matches_reference(url_obj, reference_data): + """Return whether a Gramps Url object still matches stored row metadata.""" + return ( + url_obj.get_full_path() == reference_data.get("path") + and (url_obj.get_type().xml_str() or "").strip() + == (reference_data.get("type") or "").strip() + and (url_obj.get_description() or "").strip() + == (reference_data.get("description") or "").strip() + ) + + def on_internet_url_edited(self, nav_type, obj, url_index, updated_url): + """Save an edited Internet URL back to the parent object.""" + if not updated_url: + return + + url_list = obj.get_url_list() + if not (0 <= url_index < len(url_list)): + return + url_list[url_index] = updated_url + + with DbTxn("Edit Internet Link", self.dbstate.db) as trans: + obj.set_url_list(url_list) + self.commit_internet_parent_object(nav_type, obj, trans) + + self.refresh_main_treeview_tab(nav_type, obj.get_handle()) + + def get_internet_parent_object(self, nav_type, obj_handle): + """Resolve an object that can own Internet links.""" + lookup = { + SupportedNavTypes.PEOPLE.value: self.dbstate.db.get_person_from_handle, + SupportedNavTypes.PLACES.value: self.dbstate.db.get_place_from_handle, + SupportedNavTypes.REPOSITORIES.value: ( + self.dbstate.db.get_repository_from_handle + ), + } + getter = lookup.get(nav_type) + return getter(obj_handle) if getter else None + + def commit_internet_parent_object(self, nav_type, obj, trans): + """Commit an object that owns Internet links.""" + lookup = { + SupportedNavTypes.PEOPLE.value: self.dbstate.db.commit_person, + SupportedNavTypes.PLACES.value: self.dbstate.db.commit_place, + SupportedNavTypes.REPOSITORIES.value: self.dbstate.db.commit_repository, + } + commit = lookup.get(nav_type) + if commit: + commit(obj, trans) + def on_add_note(self, unused_widget): """Adds the current selected URL as a note to the person record.""" - if not self._context.active_tree_path: - print("❌ Error: No saved path to the iterator!", file=sys.stderr) + link_data = self._context.active_link_data + if not link_data: + print("❌ Error: No saved link data!", file=sys.stderr) return note = Note() - tree_iter = self.get_active_tree_iter(self._context.active_tree_path) note.set( _( "📌 This '{title}' web link was archived for future reference by the " @@ -1841,14 +2151,14 @@ def on_add_note(self, unused_widget): "You can use this link to revisit the source and verify the information " "related to this entity." ).format( - title=self.model.get_value(tree_iter, ModelColumns.TITLE.value), + title=link_data["title"], version=self.version, - url=self._context.active_url, + url=link_data["link"], ) ) note.set_privacy(True) - nav_type = self.model.get_value(tree_iter, ModelColumns.NAV_TYPE.value) + nav_type = link_data["nav_type"] note_handle = None with DbTxn("Add Web Link Note", self.dbstate.db) as trans: @@ -1900,21 +2210,16 @@ def on_add_note(self, unused_widget): self._context.media.add_note(note_handle) self.dbstate.db.commit_media(self._context.media, trans) - tree_iter = self.get_active_tree_iter(self._context.active_tree_path) - self.add_icon_event( + self.add_saved_link_from_snapshot( + link_data, SimpleNamespace( - icon_path=ICON_SAVED_PATH, - tree_iter=tree_iter, - model_icon_pos=ModelColumns.SAVED_ICON.value, - model_visibility_pos=ModelColumns.SAVED_ICON_VISIBLE.value, - model=self.saves_model, note_handle=note_handle, note_gramps_id=note.get_gramps_id(), saved_to=SavedTo.NOTE.value, - ) + ), ) - handle = self.model.get_value(tree_iter, ModelColumns.OBJ_HANDLE.value) + handle = link_data["obj_handle"] self.refresh_main_treeview_tab(nav_type, handle) try: @@ -1952,33 +2257,45 @@ def on_hide_link_for_selected_item(self, unused_widget): model, tree_iter = selection.get_selected() if tree_iter is not None: url_pattern = model[tree_iter][ModelColumns.URL_PATTERN.value] + final_url = model[tree_iter][ModelColumns.FINAL_URL.value] obj_handle = model[tree_iter][ModelColumns.OBJ_HANDLE.value] obj_gramps_id = model[tree_iter][ModelColumns.OBJ_GRAMPS_ID.value] nav_type = model[tree_iter][ModelColumns.NAV_TYPE.value] - if not ( # pylint: disable=duplicate-code + existing_records = ( self.hidden_links_model.query() .where("url_pattern", url_pattern) + .where("final_url", final_url) .where("obj_handle", obj_handle) .where("nav_type", nav_type) .where("scope", HiddenLinksScope.OBJECT.value) - .exists() + .get() + ) + if not any( + self.is_current_or_legacy_record( + record, self._context.database_id + ) + for record in existing_records ): self.hidden_links_model.create( { "url_pattern": url_pattern, + "final_url": final_url, "obj_handle": obj_handle, "obj_gramps_id": obj_gramps_id, "nav_type": nav_type, "scope": HiddenLinksScope.OBJECT.value, + **self.get_database_id_record_part(), } ) self.activities_model.create( { "url_pattern": url_pattern, + "final_url": final_url, "nav_type": nav_type, "obj_handle": obj_handle, "obj_gramps_id": obj_gramps_id, "activity_type": ActivityType.HIDE_LINK_FOR_OBJECT.value, + **self.get_database_id_record_part(), } ) self.refresh_activities_tab() @@ -1991,12 +2308,18 @@ def on_hide_link_for_all_items(self, unused_widget): if tree_iter is not None: url_pattern = model[tree_iter][ModelColumns.URL_PATTERN.value] nav_type = model[tree_iter][ModelColumns.NAV_TYPE.value] - if not ( # pylint: disable=duplicate-code + existing_records = ( self.hidden_links_model.query() .where("url_pattern", url_pattern) .where("nav_type", nav_type) .where("scope", HiddenLinksScope.ALL.value) - .exists() + .get() + ) + if not any( + self.is_current_or_legacy_record( + record, self._context.database_id + ) + for record in existing_records ): self.hidden_links_model.create( { @@ -2004,6 +2327,7 @@ def on_hide_link_for_all_items(self, unused_widget): "obj_handle": None, "nav_type": nav_type, "scope": HiddenLinksScope.ALL.value, + **self.get_database_id_record_part(), } ) self.activities_model.create( @@ -2011,6 +2335,7 @@ def on_hide_link_for_all_items(self, unused_widget): "url_pattern": url_pattern, "nav_type": nav_type, "activity_type": ActivityType.HIDE_LINK_FOR_ALL.value, + **self.get_database_id_record_part(), } ) self.refresh_activities_tab() @@ -2037,12 +2362,12 @@ def get_active_tree_iter(self, path): def on_add_attribute(self, unused_widget): """(Unused) Adds the selected URL as an attribute to the person.""" - if not self._context.active_tree_path: - print("❌ Error. No saved path to the iterator!", file=sys.stderr) + link_data = self._context.active_link_data + if not link_data: + print("❌ Error: No saved link data!", file=sys.stderr) return - tree_iter = self.get_active_tree_iter(self._context.active_tree_path) - nav_type = self.model.get_value(tree_iter, ModelColumns.NAV_TYPE.value) + nav_type = link_data["nav_type"] attribute = None @@ -2064,7 +2389,7 @@ def on_add_attribute(self, unused_widget): return attribute_type = _("WebSearch Link") - attribute_value = self._context.active_url + attribute_value = link_data["link"] attribute.set_type(attribute_type) attribute.set_value(attribute_value) attribute.set_privacy(True) @@ -2091,23 +2416,18 @@ def on_add_attribute(self, unused_widget): else: return - tree_iter = self.get_active_tree_iter(self._context.active_tree_path) - self.add_icon_event( + self.add_saved_link_from_snapshot( + link_data, SimpleNamespace( - icon_path=ICON_SAVED_PATH, - tree_iter=tree_iter, - model_icon_pos=ModelColumns.SAVED_ICON.value, - model_visibility_pos=ModelColumns.SAVED_ICON_VISIBLE.value, - model=self.saves_model, saved_to=SavedTo.ATTRIBUTE.value, attribute_type=attribute_type, attribute_value=attribute_value, - ) + ), ) self.show_notification(_("Attribute has been successfully added")) - handle = self.model.get_value(tree_iter, ModelColumns.OBJ_HANDLE.value) + handle = link_data["obj_handle"] self.refresh_main_treeview_tab(nav_type, handle) def on_query_tooltip(self, widget, x, y, unused_keyboard_mode, tooltip): diff --git a/WebSearch/activity_row_generator.py b/WebSearch/activity_row_generator.py index 8af2e009d..9a1e7cf21 100644 --- a/WebSearch/activity_row_generator.py +++ b/WebSearch/activity_row_generator.py @@ -50,9 +50,10 @@ class ActivityRowGenerator: This class processes activity log entries and returns data rows for display in the tree view. """ - def __init__(self, activities_model): + def __init__(self, activities_model, database_id=None): """Initialize the ActivityRowGenerator with the activities DB model.""" self.activities_model = activities_model + self.database_id = database_id self._detail_builders = { ActivityType.LINK_VISIT.value: self._build_link_visit_details, ActivityType.LINK_SAVE_TO_NOTE.value: self._build_link_save_details, @@ -70,7 +71,11 @@ def generate_rows(self): Generates up to 1000 recent activity rows. Each row contains: activity_type, created_at, and a details string. """ - records = self.activities_model.order_by("id", reverse=True)[:1000] + records = [ + record + for record in self.activities_model.order_by("id", reverse=True) + if self._is_current_or_legacy_record(record) + ][:1000] rows = [] for record in records: @@ -93,6 +98,11 @@ def generate_rows(self): return rows + def _is_current_or_legacy_record(self, record): + """Legacy activity records have no database_id and remain visible.""" + record_database_id = record.get("database_id") + return not record_database_id or record_database_id == self.database_id + def _format_activity_type(self, activity_type: str) -> str: """Convert snake_case activity type to title case.""" return activity_type.replace("_", " ").title() diff --git a/WebSearch/assets/csv/uid-links.csv b/WebSearch/assets/csv/uid-links.csv index 8223a20e4..06977eb07 100644 --- a/WebSearch/assets/csv/uid-links.csv +++ b/WebSearch/assets/csv/uid-links.csv @@ -17,4 +17,5 @@ People,LinkedIn,1,https://www.linkedin.com/in/%(LinkedIn.ID)s, People,Instagram,1,https://www.instagram.com/%(Instagram.ID)s, People,Gedbas,1,https://gedbas.de/uid/%(Gedbas.ID)s, People,WikiTree,1,https://www.wikitree.com/wiki/%(WikiTree.ID)s, +People,RootsSearch WikiTree,1,https://apps.wikitree.com/apps/roots-search/?profile=%(WikiTree.ID)s, People,WikiTree Connection,1,https://www.wikitree.com/index.php?title=Special:Connection&action=connect&person1Name=%(WikiTree.ID)s&person2Name=%(HomePerson.WikiTree.ID)s diff --git a/WebSearch/attribute_editor_manager.py b/WebSearch/attribute_editor_manager.py index 5cc18c8b6..6e12ea03c 100644 --- a/WebSearch/attribute_editor_manager.py +++ b/WebSearch/attribute_editor_manager.py @@ -43,7 +43,8 @@ from gettext import gettext as _ from types import SimpleNamespace -from gramps.gui.editors.editattribute import EditAttribute +from gramps.gui.editors.editattribute import EditAttribute, EditSrcAttribute +from gramps.gen.lib import SrcAttribute from gramps.gen.db import DbTxn from constants import ActivityType from helpers import get_attribute_name, get_handle_lookup @@ -76,6 +77,18 @@ def edit_by_obj_handle_and_attr_name(self, ctx: SimpleNamespace): self._edit(ctx, obj, attr_obj, index) return True + def edit_by_obj_handle_and_attr_reference(self, ctx: SimpleNamespace): + """Edit attribute using the source row reference captured from the parent object.""" + obj = self._get_object_by_handle(ctx.nav_type, ctx.obj_handle) + if obj is None: + print(f"❌ Error. Object by handle '{ctx.obj_handle}' not found") + return False + index, attr_obj = self._find_attribute_by_reference( + obj, ctx.attr_index, ctx.attr_type, ctx.attr_value + ) + self._edit(ctx, obj, attr_obj, index) + return True + def edit_by_obj_handle_and_attr_obj(self, ctx: SimpleNamespace, attr_obj): """Edit attribute by object handle and attribute object.""" obj = self._get_object_by_handle(ctx.nav_type, ctx.obj_handle) @@ -129,11 +142,38 @@ def _find_attributes_by_name(self, obj, attr_name, attr_value=None): return matches + def _find_attribute_by_reference(self, obj, attr_index, attr_name, attr_value): + """Find one attribute by saved index first, then by unique type/value match.""" + attr_list = obj.get_attribute_list() + if isinstance(attr_index, int) and 0 <= attr_index < len(attr_list): + attr = attr_list[attr_index] + if ( + get_attribute_name(attr.get_type()) == attr_name + and attr.get_value() == attr_value + ): + return attr_index, attr + + matches = [ + (i, attr) + for i, attr in enumerate(attr_list) + if get_attribute_name(attr.get_type()) == attr_name + and attr.get_value() == attr_value + ] + + if len(matches) != 1: + raise AttributeNotFoundError( + f"Attribute {attr_name} with value {attr_value} no longer matches uniquely" + ) + + return matches[0] + def _edit(self, ctx: SimpleNamespace, obj, attr, index): old_attr_name = get_attribute_name(attr.get_type()) old_attr_value = attr.get_value() - EditAttribute( + editor_class = EditSrcAttribute if isinstance(attr, SrcAttribute) else EditAttribute + + editor_class( self.dbstate, self.uistate, [], @@ -143,7 +183,7 @@ def _edit(self, ctx: SimpleNamespace, obj, attr, index): if hasattr(obj, "get_primary_name") else "" ), - self.dbstate.db.get_person_attribute_types(), + self._get_attribute_types(ctx.nav_type), callback=partial( self._on_attribute_edited, ctx, @@ -202,6 +242,25 @@ def _on_attribute_edited( if ctx.callback: ctx.callback((refreshed_attr, name, value)) + def _get_attribute_types(self, nav_type): + """Return custom attribute type names for the edited parent object type.""" + db = self.dbstate.db + lookup = { + "People": db.get_person_attribute_types, + "Families": db.get_family_attribute_types, + "Events": db.get_event_attribute_types, + "Media": db.get_media_attribute_types, + "Sources": db.get_source_attribute_types, + "Citations": db.get_source_attribute_types, + "Person": db.get_person_attribute_types, + "Family": db.get_family_attribute_types, + "Event": db.get_event_attribute_types, + "Source": db.get_source_attribute_types, + "Citation": db.get_source_attribute_types, + } + getter = lookup.get(nav_type) + return getter() if getter else [] + class AttributeNotFoundError(Exception): """Raised when an expected attribute is no longer present in the object.""" diff --git a/WebSearch/attribute_links_loader.py b/WebSearch/attribute_links_loader.py index f3945896d..72132bcca 100644 --- a/WebSearch/attribute_links_loader.py +++ b/WebSearch/attribute_links_loader.py @@ -49,7 +49,7 @@ def get_links_from_attributes(self, obj, nav_type): if not hasattr(obj, "get_attribute_list"): return links - for attr in obj.get_attribute_list(): + for index, attr in enumerate(obj.get_attribute_list()): attr_name = get_attribute_name(attr.get_type()) if not attr_name: @@ -72,6 +72,12 @@ def get_links_from_attributes(self, obj, nav_type): comment=None, is_custom_file=False, source_file_path=None, + reference_type=SourceTypes.ATTRIBUTE.value, + reference_data={ + "index": index, + "attribute_type": attr_name, + "attribute_value": attr_value, + }, ) ) diff --git a/WebSearch/attribute_mapping_loader.py b/WebSearch/attribute_mapping_loader.py index 7324a2689..4ed219288 100644 --- a/WebSearch/attribute_mapping_loader.py +++ b/WebSearch/attribute_mapping_loader.py @@ -118,3 +118,37 @@ def add_matching_keys_to_data(self, uids_data, url_pattern): print(f"❌ Error adding matching keys: {e}", file=sys.stderr) return filtered_uids_data + + def get_matching_key_values_for_url(self, uids_data, url_pattern): + """ + Return all matching UID values for a URL pattern, grouped by substitution key. + + The existing add_matching_keys_to_data method keeps the historical one-value + behavior. This method is used only when the caller needs to decide whether a + UID link can be expanded into multiple rows. + """ + grouped_uids_data = {} + try: + for uid_entry in uids_data: + if not re.match(uid_entry["url_regex"], url_pattern, re.IGNORECASE): + continue + + context = uid_entry.get( + "context", UIDAttributeContext.ACTIVE_PERSON.value + ) + key_name = uid_entry["key_name"] + value = uid_entry["value"] + + keys = [f"{context}.{key_name}"] + if context == UIDAttributeContext.ACTIVE_PERSON.value: + keys.append(key_name) + + for key in keys: + grouped_uids_data.setdefault(key, []) + if value not in grouped_uids_data[key]: + grouped_uids_data[key].append(value) + + except Exception as e: # pylint: disable=broad-exception-caught + print(f"❌ Error grouping matching keys: {e}", file=sys.stderr) + + return grouped_uids_data diff --git a/WebSearch/configs/attribute_mapping.json b/WebSearch/configs/attribute_mapping.json index 35f31fa39..e5bfd8a8c 100644 --- a/WebSearch/configs/attribute_mapping.json +++ b/WebSearch/configs/attribute_mapping.json @@ -29,6 +29,12 @@ "url_regex": ".*wikitree\\.com/index\\.php.*Special:Connection.*", "key_name": "WikiTree.ID" }, + { + "nav_type": "Person", + "attribute_name": "WikiTreeID", + "url_regex": ".*apps\\.wikitree\\.com/apps/roots-search/.*", + "key_name": "WikiTree.ID" + }, { "nav_type": "Person", "attribute_name": "GeniID", diff --git a/WebSearch/constants.py b/WebSearch/constants.py index e70f9f4d0..de42eb197 100644 --- a/WebSearch/constants.py +++ b/WebSearch/constants.py @@ -345,11 +345,13 @@ class UIDAttributeContext(Enum): VIEW_IDS_MAPPING = { "dashboardview": None, + "personview": "Person", "personlistview": "Person", "relview": None, "familyview": "Family", "family_tree_view": None, "eventview": "Event", + "placelistview": "Place", "placetreeview": "Place", "sourceview": "Source", "citationlistview": "Citation", diff --git a/WebSearch/interface.xml b/WebSearch/interface.xml index 140ad5023..1c801ecbb 100644 --- a/WebSearch/interface.xml +++ b/WebSearch/interface.xml @@ -316,5 +316,11 @@ + + + Edit Internet link + + + diff --git a/WebSearch/internet_links_loader.py b/WebSearch/internet_links_loader.py index e6f8fd214..808f8baed 100644 --- a/WebSearch/internet_links_loader.py +++ b/WebSearch/internet_links_loader.py @@ -40,10 +40,11 @@ def get_links_from_internet_objects(self, obj, nav_type): """Extracts formatted URLs from an object's 'Internet' tab.""" links = [] url_list = obj.get_url_list() - for url_obj in url_list: + for index, url_obj in enumerate(url_list): full_path = url_obj.get_full_path() url_type = url_obj.get_type() title = self.get_url_title(url_type) + description = (url_obj.get_description() or "").strip() # pylint: disable=duplicate-code url = UrlUtils.extract_url(full_path, self.url_regex) if url: @@ -55,9 +56,16 @@ def get_links_from_internet_objects(self, obj, nav_type): title=(title or "").strip(), is_enabled=True, url_pattern=UrlUtils.clean_url(url), - comment=(url_obj.get_description() or "").strip(), + comment=description, is_custom_file=False, source_file_path=None, + reference_type=SourceTypes.INTERNET.value, + reference_data={ + "index": index, + "path": full_path, + "type": title, + "description": description, + }, ) ) diff --git a/WebSearch/model_row_generator.py b/WebSearch/model_row_generator.py index 2e275336d..5198ba0a4 100644 --- a/WebSearch/model_row_generator.py +++ b/WebSearch/model_row_generator.py @@ -83,23 +83,34 @@ def __init__(self, deps): self.saves_model = deps.saves_model self.hidden_links_model = deps.hidden_links_model self.activities_model = deps.activities_model + self.database_id = getattr(deps, "database_id", None) self._display_icons = [] def generate(self, link_context: LinkContext, website_data: WebsiteEntry): """Generates a structured data row for the ListStore model.""" + rows = self.generate_many(link_context, website_data) + return rows[0] if rows else None + + def generate_many(self, link_context: LinkContext, website_data: WebsiteEntry): + """Generates one or more structured data rows for the ListStore model.""" # pylint: disable=too-many-locals try: if website_data.nav_type != link_context.nav_type or not is_true( website_data.is_enabled ): - return None + return [] obj_handle = link_context.obj.get_handle() obj_gramps_id = link_context.obj.get_gramps_id() if self.should_be_hidden_link( - website_data.url_pattern, link_context.nav_type, obj_handle + website_data.url_pattern, + website_data.url_pattern, + link_context.nav_type, + obj_handle, ): - return None + return [] + + rows = [] if website_data.source_type in SOURCE_TYPES_WITH_FIXED_LINKS: final_url = formatted_url = website_data.url_pattern @@ -109,28 +120,87 @@ def generate(self, link_context: LinkContext, website_data: WebsiteEntry): replaced_keys_count, total_keys_count, ) = self.get_empty_keys() + row = self._build_model_row( + link_context, + website_data, + website_data.source_type, + obj_handle, + obj_gramps_id, + final_url, + formatted_url, + pattern_keys_info, + pattern_keys_json, + replaced_keys_count, + total_keys_count, + ) + return [row] if row else [] else: - ( + for ( combined_keys, matched_attribute_keys, pattern_keys_info, pattern_keys_json, - ) = self.prepare_data_keys( + ) in self.prepare_data_key_variants( link_context.core_keys, link_context.attribute_keys, website_data.url_pattern, - ) - - final_url, formatted_url = self.prepare_urls( - website_data.url_pattern, combined_keys, pattern_keys_info - ) - - website_data.source_type, should_skip = self.evaluate_uid_source_type( - website_data.source_type, pattern_keys_info, matched_attribute_keys - ) - if should_skip: - return None - + ): + + final_url, formatted_url = self.prepare_urls( + website_data.url_pattern, combined_keys, pattern_keys_info + ) + + source_type, should_skip = self.evaluate_uid_source_type( + website_data.source_type, + pattern_keys_info, + matched_attribute_keys, + ) + if should_skip: + continue + + if self.should_be_hidden_link( + website_data.url_pattern, + final_url, + link_context.nav_type, + obj_handle, + ): + continue + + row = self._build_model_row( + link_context, + website_data, + source_type, + obj_handle, + obj_gramps_id, + final_url, + formatted_url, + pattern_keys_info, + pattern_keys_json, + ) + if row: + rows.append(row) + + return rows + except Exception: # pylint: disable=broad-exception-caught + print(traceback.format_exc(), file=sys.stderr) + return [] + + def _build_model_row( + self, + link_context, + website_data, + source_type, + obj_handle, + obj_gramps_id, + final_url, + formatted_url, + pattern_keys_info, + pattern_keys_json, + replaced_keys_count=None, + total_keys_count=None, + ): + """Build one model row after the URL and key data have been prepared.""" + try: icon_name = CATEGORY_ICON.get(link_context.nav_type, DEFAULT_CATEGORY_ICON) visited_icon, visited_icon_visible, visited_record_id = ( self.get_visited_icon_data(final_url, obj_handle) @@ -147,23 +217,23 @@ def generate(self, link_context: LinkContext, website_data: WebsiteEntry): website_data.is_custom_file ) file_identifier_icon, file_identifier_icon_visible = ( - self.get_file_identifier_icon_data( - website_data.country_code, website_data.source_type - ) + self.get_file_identifier_icon_data(website_data.country_code, source_type) ) - replaced_keys_count = len(pattern_keys_info["replaced_keys"]) - total_keys_count = self.get_total_keys_count(pattern_keys_info) + if replaced_keys_count is None: + replaced_keys_count = len(pattern_keys_info["replaced_keys"]) + if total_keys_count is None: + total_keys_count = self.get_total_keys_count(pattern_keys_info) keys_color = self.get_keys_color(replaced_keys_count, total_keys_count) file_identifier_text = self.get_file_identifier_text( - website_data.country_code, website_data.source_type + website_data.country_code, source_type ) - display_keys_count = self.get_display_keys_count(website_data.source_type) + display_keys_count = self.get_display_keys_count(source_type) file_identifier_sort = self.get_file_identifier_sort( - website_data.country_code, website_data.source_type + website_data.country_code, source_type ) if self.is_incomplete_uid_link( - website_data.source_type, replaced_keys_count, total_keys_count + source_type, replaced_keys_count, total_keys_count ): return None @@ -193,7 +263,7 @@ def generate(self, link_context: LinkContext, website_data: WebsiteEntry): "file_identifier_icon": file_identifier_icon, "file_identifier_icon_visible": file_identifier_icon_visible, "file_identifier_sort": file_identifier_sort, - "source_type": website_data.source_type, + "source_type": source_type, "country_code": website_data.country_code, "source_file_path": website_data.source_file_path, "saved_record_id": saved_record_id, @@ -201,6 +271,8 @@ def generate(self, link_context: LinkContext, website_data: WebsiteEntry): "saved_attribute_value": saved_attribute_value, "saved_to": saved_to, "visited_record_id": visited_record_id, + "reference_type": website_data.reference_type, + "reference_data_json": json.dumps(website_data.reference_data or {}), } except Exception: # pylint: disable=broad-exception-caught print(traceback.format_exc(), file=sys.stderr) @@ -218,30 +290,47 @@ def is_incomplete_uid_link( and replaced_keys_count < total_keys_count ) - def should_be_hidden_link(self, url_pattern, nav_type, obj_handle): + def should_be_hidden_link(self, url_pattern, final_url, nav_type, obj_handle): """Determine if a link should be skipped based on hidden hash entries.""" - if ( + all_scope_records = ( self.hidden_links_model.query() .where("url_pattern", url_pattern) .where("nav_type", nav_type) .where("scope", HiddenLinksScope.ALL.value) - .exists() + .get() + ) + if any( + self.hidden_record_matches(record, final_url) + for record in all_scope_records ): return True - if ( + object_scope_records = ( self.hidden_links_model.query() .where("url_pattern", url_pattern) .where("obj_handle", obj_handle) .where("nav_type", nav_type) .where("scope", HiddenLinksScope.OBJECT.value) - .exists() + .get() + ) + if any( + self.hidden_record_matches(record, final_url) + for record in object_scope_records ): return True return False + def hidden_record_matches(self, record, final_url): + """Match exact-url hidden records, or legacy pattern-only records.""" + record_database_id = record.get("database_id") + if record_database_id and record_database_id != self.database_id: + return False + + hidden_final_url = record.get("final_url") + return not hidden_final_url or hidden_final_url == final_url + def prepare_data_keys(self, core_keys, attribute_keys, url_pattern): """ Combines core entity keys with matched attribute keys relevant to the URL pattern. @@ -262,6 +351,72 @@ def prepare_data_keys(self, core_keys, attribute_keys, url_pattern): pattern_keys_json, ) + def prepare_data_key_variants(self, core_keys, attribute_keys, url_pattern): + """ + Build key sets for a URL pattern, expanding repeated UID values only when safe. + """ + grouped_attribute_keys = ( + self.attribute_loader.get_matching_key_values_for_url( + attribute_keys, url_pattern + ) + ) + pattern_keys = list(dict.fromkeys(re.findall(r"%\((.*?)\)s", url_pattern))) + uid_pattern_keys = [ + key for key in pattern_keys if key in grouped_attribute_keys + ] + + if not uid_pattern_keys: + return [self.prepare_data_keys(core_keys, attribute_keys, url_pattern)] + + uid_values = { + key: [ + value + for value in grouped_attribute_keys.get(key, []) + if value not in (None, "") + ] + for key in uid_pattern_keys + } + + if any(not values for values in uid_values.values()): + return [] + + if len(uid_pattern_keys) > 1 and any( + len(values) > 1 for values in uid_values.values() + ): + return [] + + if len(uid_pattern_keys) == 1: + key = uid_pattern_keys[0] + values = uid_values[key] + else: + values = [None] + + variants = [] + for value in values: + combined_keys = core_keys.copy() + matched_attribute_keys = {} + + for key in uid_pattern_keys: + selected_value = ( + value if len(uid_pattern_keys) == 1 else uid_values[key][0] + ) + combined_keys[key] = selected_value + matched_attribute_keys[key] = selected_value + + pattern_keys_info = self.url_formatter.check_pattern_keys( + url_pattern, combined_keys + ) + variants.append( + ( + combined_keys, + matched_attribute_keys, + pattern_keys_info, + json.dumps(pattern_keys_info), + ) + ) + + return variants + def prepare_urls(self, url_pattern, combined_keys, keys): """Generate final and formatted URLs using combined keys and pattern keys info.""" final_url = self.safe_percent_format(url_pattern, combined_keys) @@ -448,11 +603,8 @@ def get_visited_icon_data(self, final_url, obj_handle): if not self.display_icon("visited"): return visited_icon, visited_icon_visible, visited_record_id - record = ( - self.visits_model.query() - .where("link", final_url) - .where("obj_handle", obj_handle) - .first() + record = self.get_current_or_legacy_link_record( + self.visits_model, final_url, obj_handle ) if record: @@ -485,11 +637,8 @@ def get_saved_icon_data(self, final_url, obj_handle): saved_to, ) - record = ( - self.saves_model.query() - .where("link", final_url) - .where("obj_handle", obj_handle) - .first() + record = self.get_current_or_legacy_link_record( + self.saves_model, final_url, obj_handle ) if record: @@ -513,6 +662,20 @@ def get_saved_icon_data(self, final_url, obj_handle): saved_to, ) + def get_current_or_legacy_link_record(self, model, final_url, obj_handle): + """Return the first legacy record or record matching the current database.""" + records = ( + model.query() + .where("link", final_url) + .where("obj_handle", obj_handle) + .get() + ) + for record in records: + record_database_id = record.get("database_id") + if not record_database_id or record_database_id == self.database_id: + return record + return None + def display_icon(self, icon_name): """Check if the given icon is in the list of display icons.""" self.update_display_icons() diff --git a/WebSearch/models.py b/WebSearch/models.py index b3c5bcc5b..8f4896a70 100644 --- a/WebSearch/models.py +++ b/WebSearch/models.py @@ -54,6 +54,8 @@ class WebsiteEntry: comment: Optional[str] is_custom_file: bool source_file_path: Optional[str] + reference_type: Optional[str] = None + reference_data: Optional[dict] = None @dataclass diff --git a/WebSearch/note_links_loader.py b/WebSearch/note_links_loader.py index df09de503..fe635c786 100644 --- a/WebSearch/note_links_loader.py +++ b/WebSearch/note_links_loader.py @@ -49,12 +49,15 @@ def get_links_from_notes(self, obj, nav_type): for note_handle in obj.get_note_list(): note_obj = self.get_note_object(note_handle) if note_obj: - links.extend(self.get_links_from_note_obj(note_obj, nav_type)) + links.extend( + self.get_links_from_note_obj(note_obj, nav_type, note_handle) + ) elif isinstance(obj, Note): - links.extend(self.get_links_from_note_obj(obj, nav_type)) + note_handle = obj.get_handle() if hasattr(obj, "get_handle") else None + links.extend(self.get_links_from_note_obj(obj, nav_type, note_handle)) return links - def get_links_from_note_obj(self, note_obj, nav_type): + def get_links_from_note_obj(self, note_obj, nav_type, note_handle=None): """Extract links from a single note object.""" links = [] parsed_links = self.parse_links_from_text(note_obj.get()) @@ -72,12 +75,14 @@ def get_links_from_note_obj(self, note_obj, nav_type): comment=None, is_custom_file=False, source_file_path=None, + reference_type=SourceTypes.NOTE.value, + reference_data={"note_handle": note_handle}, ) links.append(link_data) existing_links.add(url) for link in note_obj.get_links(): - link_data = self.create_existing_link_data(nav_type, link) + link_data = self.create_existing_link_data(nav_type, link, note_handle) if link_data: links.append(link_data) @@ -106,7 +111,7 @@ def get_note_object(self, note_handle): except Exception: # pylint: disable=broad-exception-caught return None - def create_existing_link_data(self, nav_type, link): + def create_existing_link_data(self, nav_type, link, note_handle=None): """Creates structured data for a note's existing link.""" if len(link) != 4: return None @@ -132,4 +137,6 @@ def create_existing_link_data(self, nav_type, link): comment=None, is_custom_file=False, source_file_path=None, + reference_type=SourceTypes.NOTE.value, + reference_data={"note_handle": note_handle}, ) diff --git a/WebSearch/notification.py b/WebSearch/notification.py index d7d1f467b..0dc90697f 100644 --- a/WebSearch/notification.py +++ b/WebSearch/notification.py @@ -63,7 +63,7 @@ def __init__(self, message): self.set_decorated(False) self.set_accept_focus(False) - self.set_size_request(200, -1) + self.set_size_request(600, -1) self.set_keep_above(True) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) @@ -73,9 +73,11 @@ def __init__(self, message): box.set_margin_end(10) label = Gtk.Label(label=message) + label.set_name("NotificationLabel") label.set_line_wrap(True) - label.set_max_width_chars(10) - label.set_ellipsize(Pango.EllipsizeMode.NONE) + label.set_max_width_chars(42) + label.set_lines(4) + label.set_ellipsize(Pango.EllipsizeMode.END) label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) box.pack_start(label, True, True, 0) @@ -92,7 +94,7 @@ def __init__(self, message): y = 10 self.move(x, y) - GObject.timeout_add(2000, self.close_window) + GObject.timeout_add(4000, self.close_window) def close_window(self): """Close the notification window after a timeout.""" @@ -109,6 +111,10 @@ def apply_css(self): border-radius: 10px; padding: 10px; } + #NotificationLabel { + color: #ffffff; + font-weight: 600; + } """ ) context = Gtk.StyleContext() diff --git a/WebSearch/po/template.pot b/WebSearch/po/template.pot index f2c1df659..7929cf3de 100644 --- a/WebSearch/po/template.pot +++ b/WebSearch/po/template.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: gramps\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-06-01 14:55+0300\n" +"POT-Creation-Date: 2026-05-22 17:55+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -131,105 +131,105 @@ msgid "" "Family, or Source record" msgstr "" -#: activity_row_generator.py:110 +#: activity_row_generator.py:120 #, python-format msgid "Visited: %s" msgstr "" -#: activity_row_generator.py:114 activity_row_generator.py:129 +#: activity_row_generator.py:124 activity_row_generator.py:139 #, python-format msgid "Link: %s" msgstr "" -#: activity_row_generator.py:116 +#: activity_row_generator.py:126 #, python-format msgid "Attribute: %s" msgstr "" -#: activity_row_generator.py:118 +#: activity_row_generator.py:128 #, python-format msgid "Value: %s" msgstr "" -#: activity_row_generator.py:123 +#: activity_row_generator.py:133 #, python-format msgid "Loaded from file: %s" msgstr "" -#: activity_row_generator.py:126 +#: activity_row_generator.py:136 #, python-format msgid "Domain: %s" msgstr "" -#: activity_row_generator.py:131 +#: activity_row_generator.py:141 #, python-format msgid "Object: %s" msgstr "" -#: activity_row_generator.py:135 +#: activity_row_generator.py:145 #, python-format msgid "Pattern: %s" msgstr "" -#: activity_row_generator.py:138 +#: activity_row_generator.py:148 #, python-format msgid "Object Gramps ID: %s" msgstr "" -#: activity_row_generator.py:142 +#: activity_row_generator.py:152 #, python-format msgid "%s: %s → %s: %s" msgstr "" -#: activity_row_generator.py:152 +#: activity_row_generator.py:162 msgid "Link visited" msgstr "" -#: activity_row_generator.py:153 +#: activity_row_generator.py:163 msgid "Link saved to Note" msgstr "" -#: activity_row_generator.py:154 +#: activity_row_generator.py:164 msgid "Link saved to Attribute" msgstr "" -#: activity_row_generator.py:155 +#: activity_row_generator.py:165 msgid "Place history loaded" msgstr "" -#: activity_row_generator.py:156 +#: activity_row_generator.py:166 msgid "Domain skipped" msgstr "" -#: activity_row_generator.py:157 +#: activity_row_generator.py:167 msgid "Link hidden for object" msgstr "" -#: activity_row_generator.py:158 +#: activity_row_generator.py:168 msgid "Link hidden for all objects" msgstr "" -#: activity_row_generator.py:159 +#: activity_row_generator.py:169 msgid "Attribute updated" msgstr "" -#: activity_row_generator.py:160 +#: activity_row_generator.py:170 msgid "Note updated" msgstr "" -#: note_links_loader.py:69 +#: note_links_loader.py:72 msgid "Note Link (parsed)" msgstr "" -#: note_links_loader.py:124 +#: note_links_loader.py:125 msgid "Note Link (internal)" msgstr "" -#: note_links_loader.py:127 +#: note_links_loader.py:128 msgid "Note Link (external)" msgstr "" -#: internet_links_loader.py:70 internet_links_loader.py:71 +#: internet_links_loader.py:78 internet_links_loader.py:79 msgid "No title" msgstr "" @@ -380,71 +380,71 @@ msgstr "" msgid "🧩 WebSearch Gramplet version: `%s`" msgstr "" -#: constants.py:390 +#: constants.py:392 msgid "Column - Icons" msgstr "" -#: constants.py:391 +#: constants.py:393 msgid "Column - Source Types (flags)" msgstr "" -#: constants.py:392 +#: constants.py:394 msgid "Column - Keys" msgstr "" -#: constants.py:393 +#: constants.py:395 msgid "Column - Title" msgstr "" -#: constants.py:394 +#: constants.py:396 msgid "Column - Website Url" msgstr "" -#: constants.py:395 +#: constants.py:397 msgid "Column - Comment" msgstr "" -#: constants.py:424 +#: constants.py:426 msgid "Icon - Visited URLs (checkmark)" msgstr "" -#: constants.py:425 +#: constants.py:427 msgid "Icon - Saved URLs (floppy disk)" msgstr "" -#: constants.py:426 +#: constants.py:428 msgid "Icon - URLs linked to UID attributes (UID badge)" msgstr "" -#: constants.py:427 +#: constants.py:429 msgid "Icon - URLs from regional CSV files (flag)" msgstr "" -#: constants.py:428 +#: constants.py:430 msgid "Icon - URLs from static CSV files (red pin)" msgstr "" -#: constants.py:429 +#: constants.py:431 msgid "Icon - URLs from common CSV files (earth)" msgstr "" -#: constants.py:430 +#: constants.py:432 msgid "Icon - URLs from cross CSV files (shuffle arrows)" msgstr "" -#: constants.py:431 +#: constants.py:433 msgid "Icon - URLs from custom user directory (spreadsheet icon)" msgstr "" -#: constants.py:432 +#: constants.py:434 msgid "Icon - URLs from the 'Attributes' tab ('A' icon)" msgstr "" -#: constants.py:433 +#: constants.py:435 msgid "Icon - URLs from the 'Internet' tab ('I' icon)" msgstr "" -#: constants.py:434 +#: constants.py:436 msgid "Icon - URLs from the 'Notes' tab ('N' icon)" msgstr "" @@ -508,105 +508,121 @@ msgstr "" msgid "Unknown" msgstr "" -#: qr_window.py:88 +#: qr_window.py:75 msgid "QR-code" msgstr "" -#: qr_window.py:119 qr_window.py:122 +#: qr_window.py:106 qr_window.py:109 msgid "⚠ Missing dependency \"qrcode\"" msgstr "" -#: qr_window.py:133 +#: qr_window.py:120 msgid "" "⚠ Error generating QR code:\n" "Original error: “{}”" msgstr "" -#: WebSearch.py:468 +#: WebSearch.py:477 msgid "Save Coordinates to the Place" msgstr "" -#: WebSearch.py:609 +#: WebSearch.py:618 msgid "AI provider is disabled" msgstr "" -#: WebSearch.py:613 +#: WebSearch.py:622 msgid "No AI API key provided" msgstr "" -#: WebSearch.py:619 +#: WebSearch.py:628 msgid "AI-generated historical place data is currently disabled" msgstr "" -#: WebSearch.py:629 +#: WebSearch.py:638 msgid "AI provider is unknown. Please check your AI provider settings." msgstr "" -#: WebSearch.py:649 +#: WebSearch.py:666 msgid "⏳ Generating historical place data, please wait..." msgstr "" -#: WebSearch.py:1521 +#: WebSearch.py:1630 msgid "Keys" msgstr "" -#: WebSearch.py:1522 +#: WebSearch.py:1631 msgid "Title" msgstr "" -#: WebSearch.py:1523 +#: WebSearch.py:1632 msgid "Website URL" msgstr "" -#: WebSearch.py:1524 +#: WebSearch.py:1633 msgid "Comment" msgstr "" -#: WebSearch.py:1526 +#: WebSearch.py:1635 msgid "Add link to note" msgstr "" -#: WebSearch.py:1528 +#: WebSearch.py:1637 msgid "Add link to attribute" msgstr "" -#: WebSearch.py:1530 +#: WebSearch.py:1639 msgid "Show QR-code" msgstr "" -#: WebSearch.py:1532 +#: WebSearch.py:1641 msgid "Copy link to clipboard" msgstr "" -#: WebSearch.py:1535 +#: WebSearch.py:1644 msgid "Hide link for selected item" msgstr "" -#: WebSearch.py:1538 +#: WebSearch.py:1647 msgid "Hide link for all items" msgstr "" -#: WebSearch.py:1542 +#: WebSearch.py:1651 msgid "Edit Attribute with the Link" msgstr "" -#: WebSearch.py:1546 +#: WebSearch.py:1655 msgid "Edit Note with the Link" msgstr "" -#: WebSearch.py:1549 +#: WebSearch.py:1658 +msgid "Edit Internet link" +msgstr "" + +#: WebSearch.py:1661 msgid "🔍 AI Suggestions" msgstr "" -#: WebSearch.py:1769 +#: WebSearch.py:1915 +msgid "Attribute no longer matches this WebSearch row" +msgstr "" + +#: WebSearch.py:1942 msgid "Attribute no longer exists. The extra icon is removed" msgstr "" -#: WebSearch.py:1815 +#: WebSearch.py:1983 +msgid "Note no longer exists" +msgstr "" + +#: WebSearch.py:2005 msgid "Note no longer exists. The extra icon is removed" msgstr "" -#: WebSearch.py:1840 +#: WebSearch.py:2057 +msgid "Internet link no longer matches this WebSearch row" +msgstr "" + +#: WebSearch.py:2148 #, python-brace-format msgid "" "📌 This '{title}' web link was archived for future reference by the " @@ -618,43 +634,43 @@ msgid "" "related to this entity." msgstr "" -#: WebSearch.py:1926 +#: WebSearch.py:2229 #, python-format msgid "Note #%(id)s has been successfully added" msgstr "" -#: WebSearch.py:1929 +#: WebSearch.py:2232 msgid "Error creating note" msgstr "" -#: WebSearch.py:1949 +#: WebSearch.py:2252 msgid "URL is copied to the Clipboard" msgstr "" -#: WebSearch.py:2068 +#: WebSearch.py:2391 msgid "WebSearch Link" msgstr "" -#: WebSearch.py:2110 +#: WebSearch.py:2428 msgid "Attribute has been successfully added" msgstr "" -#: WebSearch.py:2142 +#: WebSearch.py:2460 #, python-brace-format msgid "Title: {title}" msgstr "" -#: WebSearch.py:2146 +#: WebSearch.py:2464 #, python-brace-format msgid "Replaced: {keys}" msgstr "" -#: WebSearch.py:2149 +#: WebSearch.py:2467 #, python-brace-format msgid "Empty: {keys}" msgstr "" -#: WebSearch.py:2151 +#: WebSearch.py:2469 #, python-brace-format msgid "Comment: {comment}" msgstr "" diff --git a/WebSearch/qr_window.py b/WebSearch/qr_window.py index cd66cd851..f1773a21d 100644 --- a/WebSearch/qr_window.py +++ b/WebSearch/qr_window.py @@ -34,7 +34,6 @@ try: import qrcode - QR_AVAILABLE = True except ImportError: QR_AVAILABLE = False diff --git a/lxml/lxmlGramplet.py b/lxml/lxmlGramplet.py index b0c3afa57..1888339c6 100644 --- a/lxml/lxmlGramplet.py +++ b/lxml/lxmlGramplet.py @@ -661,20 +661,20 @@ def parse_xml(self, tree): _('\t{number} note'), _('\t{number} note') surnames_string = ngettext( '\t{number} surname', - '\t{number} surnames; no frequency yet\n', - nb_surnames).format(number=nb_surnames) + '\t{number} surnames; no frequency yet', + nb_surnames).format(number=nb_surnames) + '\n' places_string = ngettext( '\t{number} place', - '\t{number} places\n', - nb_pnames).format(number=nb_pnames) + '\t{number} places', + nb_pnames).format(number=nb_pnames) + '\n' notes_string = ngettext( '\t{number} note', - '\t{number} notes\n', - nb_notes).format(number=nb_notes) + '\t{number} notes', + nb_notes).format(number=nb_notes) + '\n' sources_string = ngettext( '\t{number} source', - '\t{number} sources\n', - nb_sources).format(number=nb_sources) + '\t{number} sources', + nb_sources).format(number=nb_sources) + '\n' counters = surnames_string + places_string + notes_string + sources_string diff --git a/lxml/po/template.pot b/lxml/po/template.pot index 352fcb6db..bd46da5f4 100644 --- a/lxml/po/template.pot +++ b/lxml/po/template.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-16 15:10-0800\n" +"POT-Creation-Date: 2026-05-26 09:33-0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -16,8 +16,9 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -#: lxml/lxmlGramplet.gpr.py:10 lxml/lxmlGramplet.gpr.py:19 +#: lxml/lxmlGramplet.gpr.py:10 lxml/lxmlGramplet.gpr.py:20 msgid "lxml" msgstr "" @@ -25,7 +26,7 @@ msgstr "" msgid "Gramplet for testing lxml and XSLT" msgstr "" -#: lxml/etreeGramplet.gpr.py:10 lxml/etreeGramplet.gpr.py:19 +#: lxml/etreeGramplet.gpr.py:10 lxml/etreeGramplet.gpr.py:20 msgid "etree" msgstr "" @@ -33,69 +34,73 @@ msgstr "" msgid "Gramplet for testing etree with Gramps XML" msgstr "" -#: lxml/etreeGramplet.py:85 lxml/lxmlGramplet.py:110 +#: lxml/etreeGramplet.py:83 lxml/lxmlGramplet.py:115 msgid "Invalid timestamp" msgstr "" -#: lxml/etreeGramplet.py:86 lxml/lxmlGramplet.py:111 -msgid "Unknown" +#: lxml/etreeGramplet.py:119 lxml/lxmlGramplet.py:162 +msgid "Select file" msgstr "" -#: lxml/etreeGramplet.py:139 -msgid "No file parsed..." +#: lxml/etreeGramplet.py:130 lxml/lxmlGramplet.py:173 +msgid "" +"Select a Gramps XML file and\n" +" click on the Run button." msgstr "" -#: lxml/etreeGramplet.py:146 lxml/lxmlGramplet.py:171 +#: lxml/etreeGramplet.py:139 lxml/lxmlGramplet.py:182 msgid "Run" msgstr "" -#: lxml/etreeGramplet.py:205 lxml/etreeGramplet.py:210 -msgid "Number of editions back" -msgstr "" - -#: lxml/etreeGramplet.py:220 lxml/lxmlGramplet.py:235 -msgid "Space character on filename" +#: lxml/etreeGramplet.py:155 lxml/lxmlGramplet.py:197 +msgid "No file loaded..." msgstr "" -#: lxml/etreeGramplet.py:220 lxml/lxmlGramplet.py:235 -#, python-format -msgid "Please fix space on \"%s\"" +#: lxml/etreeGramplet.py:203 lxml/etreeGramplet.py:209 +msgid "Number of additions and modifications back" msgstr "" -#: lxml/etreeGramplet.py:245 lxml/lxmlGramplet.py:265 -msgid "Sorry, no support for your OS yet!" +#: lxml/etreeGramplet.py:205 lxml/etreeGramplet.py:210 +msgid "Print more informations on console" msgstr "" -#: lxml/etreeGramplet.py:254 lxml/lxmlGramplet.py:275 +#: lxml/etreeGramplet.py:254 lxml/lxmlGramplet.py:334 msgid "Is it a compressed .gramps?" msgstr "" -#: lxml/etreeGramplet.py:254 lxml/lxmlGramplet.py:275 +#: lxml/etreeGramplet.py:254 #, python-format msgid "Cannot uncompress \"%s\"" msgstr "" -#: lxml/etreeGramplet.py:256 lxml/etreeGramplet.py:263 lxml/lxmlGramplet.py:278 -#: lxml/lxmlGramplet.py:286 -#, python-format -msgid "" -"From:\n" -" \"%(file1)s\"\n" -" to:\n" -" \"%(file2)s\".\n" +#: lxml/etreeGramplet.py:265 lxml/lxmlGramplet.py:348 +msgid "Is it a .gramps?" msgstr "" -#: lxml/etreeGramplet.py:261 lxml/lxmlGramplet.py:283 +#: lxml/etreeGramplet.py:265 #, python-format msgid "Cannot copy \"%s\"" msgstr "" -#: lxml/etreeGramplet.py:418 +#: lxml/etreeGramplet.py:282 +msgid "Sorry, no support for your OS yet!" +msgstr "" + +#: lxml/etreeGramplet.py:296 lxml/lxmlGramplet.py:420 +msgid "Parsing issue" +msgstr "" + +#: lxml/etreeGramplet.py:296 lxml/lxmlGramplet.py:420 +#, python-format +msgid "Cannot parse content of \"%(file)s\"" +msgstr "" + +#: lxml/etreeGramplet.py:498 #, python-format -msgid "XML: Last %s editions since %s, were at/on :\n" +msgid "XML: Last %s additions and modifications since %s, were on :\n" msgstr "" -#: lxml/etreeGramplet.py:471 +#: lxml/etreeGramplet.py:554 #, python-format msgid "" "\n" @@ -103,373 +108,394 @@ msgid "" "\n" msgstr "" -#: lxml/etreeGramplet.py:474 +#: lxml/etreeGramplet.py:557 #, python-format msgid "" "Number of tags : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:476 +#: lxml/etreeGramplet.py:559 #, python-format msgid "" "Number of tags : \n" -"\t\t\t%s\n" +"\t\t\t%06s\n" msgstr "" -#: lxml/etreeGramplet.py:478 +#: lxml/etreeGramplet.py:561 #, python-format msgid "" "Number of events : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:481 +#: lxml/etreeGramplet.py:564 #, python-format msgid "" "Number of persons : \n" -"\t\t\t%s\t|\t(%s) and (%s)* surnames\n" +"\t\t\t%06s\t|\t(%06s) and (%06s)* surnames\n" msgstr "" -#: lxml/etreeGramplet.py:483 +#: lxml/etreeGramplet.py:566 #, python-format msgid "" "Number of persons : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:484 +#: lxml/etreeGramplet.py:567 #, python-format msgid "" "Number of families : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:485 +#: lxml/etreeGramplet.py:568 #, python-format msgid "" "Number of sources : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:487 +#: lxml/etreeGramplet.py:570 #, python-format msgid "" "Number of citations : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:490 +#: lxml/etreeGramplet.py:573 #, python-format msgid "" "Number of places : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:491 +#: lxml/etreeGramplet.py:574 #, python-format msgid "" "Number of media objects : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:492 +#: lxml/etreeGramplet.py:575 #, python-format msgid "" "Number of repositories : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:493 +#: lxml/etreeGramplet.py:576 #, python-format msgid "" "Number of notes : \n" -"\t\t\t%s\t|\t(%s)*\n" +"\t\t\t%06s\t|\t(%06s)*\n" msgstr "" -#: lxml/etreeGramplet.py:498 +#: lxml/etreeGramplet.py:581 #, python-format msgid "" "\n" "XML: Number of additional records and relations: \t%s\n" msgstr "" -#: lxml/etreeGramplet.py:502 +#: lxml/etreeGramplet.py:585 #, python-format msgid "" "* loaded Family Tree base:\n" " \"%s\"\n" msgstr "" -#: lxml/lxmlGramplet.py:67 +#: lxml/lxmlGramplet.py:72 msgid "Where is gzip?" msgstr "" -#: lxml/lxmlGramplet.py:67 +#: lxml/lxmlGramplet.py:72 msgid "\"gzip\" is missing" msgstr "" -#: lxml/lxmlGramplet.py:89 +#: lxml/lxmlGramplet.py:94 msgid "Missing python3 lxml" msgstr "" -#: lxml/lxmlGramplet.py:89 +#: lxml/lxmlGramplet.py:94 msgid "Please, try to install \"python3 lxml\" package." msgstr "" -#: lxml/lxmlGramplet.py:164 -msgid "No file loaded..." +#: lxml/lxmlGramplet.py:116 +msgid "Unknown" msgstr "" -#: lxml/lxmlGramplet.py:298 -msgid "XSD validation (lxml)" +#: lxml/lxmlGramplet.py:255 lxml/lxmlGramplet.py:273 lxml/lxmlGramplet.py:749 +msgid "xmllint options" msgstr "" -#: lxml/lxmlGramplet.py:298 -#, python-format -msgid "Cannot validate \"%(file)s\" !" +#: lxml/lxmlGramplet.py:264 lxml/lxmlGramplet.py:282 +msgid "debug places" msgstr "" -#: lxml/lxmlGramplet.py:306 -#, python-format -msgid "xmllint: skip DTD validation for \"%(file)s\"" +#: lxml/lxmlGramplet.py:265 lxml/lxmlGramplet.py:283 +msgid "debug xml" msgstr "" -#: lxml/lxmlGramplet.py:318 -#, python-format -msgid "xmllint: skip RelaxNG validation for \"%(file)s\"" +#: lxml/lxmlGramplet.py:382 +msgid "XSD validation (lxml)" msgstr "" -#: lxml/lxmlGramplet.py:329 -msgid "Parsing issue" +#: lxml/lxmlGramplet.py:390 +#, python-format +msgid "xmllint: skip DTD validation for \"%(file)s\"" msgstr "" -#: lxml/lxmlGramplet.py:329 +#: lxml/lxmlGramplet.py:407 #, python-format -msgid "Cannot parse content of \"%(file)s\"" +msgid "xmllint: skip RelaxNG validation for \"%(file)s\"" msgstr "" -#: lxml/lxmlGramplet.py:333 -msgid "Gramps version" +#: lxml/lxmlGramplet.py:423 +msgid "Custom \"test.xml\" file" msgstr "" -#: lxml/lxmlGramplet.py:333 -#, python-format -msgid "" -"Wrong namespace\n" -"Need: %s" +#: lxml/lxmlGramplet.py:423 +msgid "Please try to fix \"test.xml\"" msgstr "" -#: lxml/lxmlGramplet.py:337 +#: lxml/lxmlGramplet.py:432 msgid "RelaxNG validation" msgstr "" -#: lxml/lxmlGramplet.py:337 +#: lxml/lxmlGramplet.py:432 #, python-format msgid "Cannot validate \"%(file)s\" via RelaxNG schema" msgstr "" -#: lxml/lxmlGramplet.py:341 +#: lxml/lxmlGramplet.py:445 msgid "File issue" msgstr "" -#: lxml/lxmlGramplet.py:341 +#: lxml/lxmlGramplet.py:445 #, python-format msgid "Cannot parse \"%(file)s\" via etree" msgstr "" -#: lxml/lxmlGramplet.py:359 +#: lxml/lxmlGramplet.py:484 msgid "Parsing file..." msgstr "" -#: lxml/lxmlGramplet.py:456 +#: lxml/lxmlGramplet.py:569 +#, python-format +msgid " - (%(lang)s)" +msgstr "" + +#: lxml/lxmlGramplet.py:572 +#, python-format +msgid " - (? or %(lang)s)" +msgstr "" + +#: lxml/lxmlGramplet.py:609 msgid "Missing header" msgstr "" -#: lxml/lxmlGramplet.py:456 +#: lxml/lxmlGramplet.py:609 lxml/lxmlGramplet.py:719 msgid "" "Not a valid .gramps.\n" "Cannot run the gramplet...\n" "Please, try to use a .gramps\n" -"generated by Gramps 4.x." +"generated by Gramps 6.x." msgstr "" -#: lxml/lxmlGramplet.py:469 lxml/lxmlGramplet.py:474 lxml/lxmlGramplet.py:479 -#: lxml/lxmlGramplet.py:484 +#: lxml/lxmlGramplet.py:622 lxml/lxmlGramplet.py:627 lxml/lxmlGramplet.py:632 +#: lxml/lxmlGramplet.py:637 msgid "0" msgstr "" -#: lxml/lxmlGramplet.py:495 +#: lxml/lxmlGramplet.py:652 msgid "File parsed with" msgstr "" -#: lxml/lxmlGramplet.py:498 +#: lxml/lxmlGramplet.py:654 msgid "File was generated on " msgstr "" -#: lxml/lxmlGramplet.py:498 +#: lxml/lxmlGramplet.py:654 msgid " by Gramps " msgstr "" -#: lxml/lxmlGramplet.py:500 +#: lxml/lxmlGramplet.py:656 msgid "Period: " msgstr "" -#: lxml/lxmlGramplet.py:502 -msgid " entries for surname(s); no frequency yet" -msgstr "" - -#: lxml/lxmlGramplet.py:503 -msgid " entries for place(s)" -msgstr "" - -#: lxml/lxmlGramplet.py:504 -msgid " note(s)" -msgstr "" - -#: lxml/lxmlGramplet.py:505 -msgid " source(s)" -msgstr "" - -#: lxml/lxmlGramplet.py:521 lxml/lxmlGramplet.py:717 +#: lxml/lxmlGramplet.py:659 lxml/lxmlGramplet.py:663 +#, python-brace-format +msgid "\t{number} surname" +msgid_plural "\t{number} surnames; no frequency yet" +msgstr[0] "" +msgstr[1] "" + +#: lxml/lxmlGramplet.py:659 +#, python-brace-format +msgid "\t{number} surname; no frequency yet" +msgstr "" + +#: lxml/lxmlGramplet.py:660 lxml/lxmlGramplet.py:667 +#, python-brace-format +msgid "\t{number} place" +msgid_plural "\t{number} places" +msgstr[0] "" +msgstr[1] "" + +#: lxml/lxmlGramplet.py:661 lxml/lxmlGramplet.py:671 +#, python-brace-format +msgid "\t{number} note" +msgid_plural "\t{number} notes" +msgstr[0] "" +msgstr[1] "" + +#: lxml/lxmlGramplet.py:675 +#, python-brace-format +msgid "\t{number} source" +msgid_plural "\t{number} sources" +msgstr[0] "" +msgstr[1] "" + +#: lxml/lxmlGramplet.py:694 lxml/lxmlGramplet.py:919 msgid "Gallery.html" msgstr "" -#: lxml/lxmlGramplet.py:522 +#: lxml/lxmlGramplet.py:695 #, python-format -msgid "2. Has generated a media index on \"%(file)s\".\n" +msgid "1. Has generated a media index on \"%(file)s\".\n" msgstr "" -#: lxml/lxmlGramplet.py:525 -#, python-format -msgid "3. Has written entries into \"%(file)s\".\n" +#: lxml/lxmlGramplet.py:719 +msgid "XML SyntaxError" msgstr "" -#: lxml/lxmlGramplet.py:544 +#: lxml/lxmlGramplet.py:723 msgid "Matches XSD schema." msgstr "" -#: lxml/lxmlGramplet.py:565 +#: lxml/lxmlGramplet.py:763 msgid "xmllint: skip DTD validation" msgstr "" -#: lxml/lxmlGramplet.py:589 +#: lxml/lxmlGramplet.py:787 msgid "I am looking at ..." msgstr "" -#: lxml/lxmlGramplet.py:590 +#: lxml/lxmlGramplet.py:788 msgid "Content generated by Gramps" msgstr "" -#: lxml/lxmlGramplet.py:591 +#: lxml/lxmlGramplet.py:789 msgid "Surnames" msgstr "" -#: lxml/lxmlGramplet.py:592 +#: lxml/lxmlGramplet.py:790 msgid "Places" msgstr "" -#: lxml/lxmlGramplet.py:593 +#: lxml/lxmlGramplet.py:791 msgid "List of sources" msgstr "" -#: lxml/lxmlGramplet.py:611 +#: lxml/lxmlGramplet.py:810 msgid "Australia" msgstr "" -#: lxml/lxmlGramplet.py:612 +#: lxml/lxmlGramplet.py:811 msgid "Brazil" msgstr "" -#: lxml/lxmlGramplet.py:613 +#: lxml/lxmlGramplet.py:812 msgid "Bulgaria" msgstr "" -#: lxml/lxmlGramplet.py:614 +#: lxml/lxmlGramplet.py:813 msgid "Canada" msgstr "" -#: lxml/lxmlGramplet.py:615 +#: lxml/lxmlGramplet.py:814 msgid "Chile" msgstr "" -#: lxml/lxmlGramplet.py:616 +#: lxml/lxmlGramplet.py:815 msgid "China" msgstr "" -#: lxml/lxmlGramplet.py:617 +#: lxml/lxmlGramplet.py:816 msgid "Croatia" msgstr "" -#: lxml/lxmlGramplet.py:618 +#: lxml/lxmlGramplet.py:817 msgid "Czech Republic" msgstr "" -#: lxml/lxmlGramplet.py:619 +#: lxml/lxmlGramplet.py:818 msgid "England" msgstr "" -#: lxml/lxmlGramplet.py:620 +#: lxml/lxmlGramplet.py:819 msgid "Finland" msgstr "" -#: lxml/lxmlGramplet.py:621 +#: lxml/lxmlGramplet.py:820 msgid "France" msgstr "" -#: lxml/lxmlGramplet.py:622 +#: lxml/lxmlGramplet.py:821 msgid "Germany" msgstr "" -#: lxml/lxmlGramplet.py:623 +#: lxml/lxmlGramplet.py:822 msgid "India" msgstr "" -#: lxml/lxmlGramplet.py:624 +#: lxml/lxmlGramplet.py:823 msgid "Japan" msgstr "" -#: lxml/lxmlGramplet.py:625 +#: lxml/lxmlGramplet.py:824 msgid "Norway" msgstr "" -#: lxml/lxmlGramplet.py:626 +#: lxml/lxmlGramplet.py:825 msgid "Portugal" msgstr "" -#: lxml/lxmlGramplet.py:627 +#: lxml/lxmlGramplet.py:826 msgid "Russia" msgstr "" -#: lxml/lxmlGramplet.py:628 +#: lxml/lxmlGramplet.py:827 msgid "Sweden" msgstr "" -#: lxml/lxmlGramplet.py:629 +#: lxml/lxmlGramplet.py:828 msgid "United States of America" msgstr "" -#: lxml/lxmlGramplet.py:633 +#: lxml/lxmlGramplet.py:832 msgid "Name" msgstr "" -#: lxml/lxmlGramplet.py:634 +#: lxml/lxmlGramplet.py:833 msgid "Country" msgstr "" -#: lxml/lxmlGramplet.py:695 +#: lxml/lxmlGramplet.py:896 #, python-format -msgid "1. Has generated \"%s\".\n" +msgid "2. Has generated \"%s\".\n" msgstr "" -#: lxml/lxmlGramplet.py:696 +#: lxml/lxmlGramplet.py:898 #, python-format msgid "" "Try to open\n" " \"%s\"\n" -" into your prefered web navigator ..." +" into your preferred web navigator ..." msgstr "" -#: lxml/lxmlGramplet.py:715 +#: lxml/lxmlGramplet.py:917 msgid "Gallery" msgstr "" diff --git a/make.py b/make.py index 9931ec053..560f396a3 100755 --- a/make.py +++ b/make.py @@ -973,55 +973,69 @@ def register(ptype, **kwargs): print(" ignoring '%s'" % (p["name"])) # Write out new listing: output = [] + listings_path = ( + f"../addons/{gramps_version}/listings/" + ("addons-%s.json" % lang) + ) + if cmd_arg != "all" and not listings and os.path.isfile(listings_path): + # Bug 13694: a single-addon `listing ` whose source yields + # no eligible plugin (include_in_listing=False, or no .tgz built + # yet) used to overwrite the listings file with [], wiping every + # previously listed addon. Leave the file untouched and tell the + # user how to remove an existing entry on purpose. + print( + " '%s' produced no listing entry; leaving %s untouched. " + "Use 'make.py %s unlist %s' to remove an existing entry." + % (cmd_arg, os.path.basename(listings_path), gramps_version, cmd_arg) + ) + continue if cmd_arg == "all": # Replace it! for plugin in sorted(listings, key=lambda p: (p["t"], p["i"])): output.append(plugin) - elif not os.path.isfile( - f"../addons/{gramps_version}/listings/" + ("addons-%s.json" % lang) - ): + elif not os.path.isfile(listings_path): for plugin in sorted(listings, key=lambda p: (p["t"], p["i"])): output.append(plugin) else: - # just update the lines from these addons: - for plugin in sorted(listings, key=lambda p: (p["t"], p["i"])): - already_added = [] - fp_in = open( - f"../addons/{gramps_version}/listings/" + ("addons-%s.json" % lang), - "r", - encoding="utf-8", - ) - added = False - for line in json.load(fp_in): - if line["i"] in already_added: - continue - if ( - cmd_arg + ".addon.tgz" == line["z"] - and plugin["t"] == line["t"] - and not added - ): - # print("UPDATED") - output.append(plugin) - added = True - already_added.append(line["i"]) - elif ( - (plugin["t"], plugin["i"]) < (line["t"], line["i"]) - ) and not added: - # print("ADDED in middle") - output.append(plugin) - added = True - output.append(line) - already_added.append(line["i"]) - else: - output.append(line) - already_added.append(line["i"]) - if not added: - if plugin["i"] not in already_added: - # print("ADDED at end") - output.append(plugin) + # Single-addon update: replace every existing entry that + # belongs to cmd_arg with the fresh plugins from `listings`, + # then merge the result with the rest of the file. Reading + # the existing file ONCE (rather than once per plugin) is + # what fixes the multi-gpr corruption flagged on PR 915: + # addons like Form ship multiple .gpr.py files / register() + # calls in one directory, and the per-plugin restart of the + # outer iteration used to append the whole existing file + # once per plugin, producing N copies of every other addon. + with open(listings_path, "r", encoding="utf-8") as fp_in: + existing = json.load(fp_in) + cmd_tgz = cmd_arg + ".addon.tgz" + new_keys = {(p["t"], p["i"]) for p in listings} + # Carry over only entries that do NOT belong to cmd_arg and + # whose (t, i) does not collide with one of the fresh + # plugins. Dropping all cmd_arg entries also cleans up + # stale rows (e.g. a register() removed from a gpr.py since + # the last build). + kept = [ + line + for line in existing + if line.get("z") != cmd_tgz + and (line["t"], line["i"]) not in new_keys + ] + # Merge kept (already sorted by (t, i) - the canonical + # listing order) with the fresh plugins (sorted here). + new = sorted(listings, key=lambda p: (p["t"], p["i"])) + i = j = 0 + while i < len(kept) and j < len(new): + if (kept[i]["t"], kept[i]["i"]) < (new[j]["t"], new[j]["i"]): + output.append(kept[i]) + i += 1 + else: + output.append(new[j]) + j += 1 + output.extend(kept[i:]) + output.extend(new[j:]) mkdir(f"../addons/{gramps_version}/listings") fp_out = open( - f"../addons/{gramps_version}/listings/" + ("addons-%s.json" % lang), + listings_path, "w", encoding="utf-8", newline="", diff --git a/tests/test_make_listing.py b/tests/test_make_listing.py new file mode 100644 index 000000000..6e2d95886 --- /dev/null +++ b/tests/test_make_listing.py @@ -0,0 +1,417 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# 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. +# + +""" +Integration tests for the ``make.py`` listing command. + +Tests run ``make.py`` as a subprocess against a synthetic addon tree, so the +command-line parsing, glob, and listings-file I/O are exercised end-to-end. +""" + +# ------------------------ +# Python modules +# ------------------------ +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ADDONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MAKE_PY = os.path.join(ADDONS_ROOT, "make.py") + + +def _detect_gramps_version() -> tuple[str, str]: + """ + Return ``(gramps_version_dir, gramps_target_version)`` from the + installed gramps. The directory form is ``"gramps"`` + (e.g. ``"gramps60"``) — what make.py expects as its first + positional argument. The target form is ``"."`` + (e.g. ``"6.0"``) — what a .gpr.py ``gramps_target_version`` must + match for the addon to be eligible for listing. + + Detected at runtime so the same test passes against either + addons-source/maintenance/gramps60 (gramps 6.0 install) or + addons-source/maintenance/gramps61 (gramps 6.1 install). + """ + import gramps.version # local import: gramps may not be on path at module-load time + + major, minor = gramps.version.VERSION_TUPLE[:2] + return "gramps%d%d" % (major, minor), "%d.%d" % (major, minor) + + +GRAMPS_VERSION, GRAMPS_TARGET_VERSION = _detect_gramps_version() + + +_GPR_TEMPLATE = """\ +register(GRAMPLET, + id='{name}', + name='{name}', + description='Synthetic test addon', + version='1.0.0', + gramps_target_version='{target}', + status=STABLE, + fname='{name}.py', + height=200, + gramplet='{name}', + gramplet_title='{name}', + include_in_listing={include_in_listing}, +) +""" + +_GPR_GRAMPLET_REGISTER = """\ +register(GRAMPLET, + id='{plugin_id}', + name='{plugin_id}', + description='Synthetic test gramplet', + version='1.0.0', + gramps_target_version='{target}', + status=STABLE, + fname='{plugin_id}.py', + height=200, + gramplet='{plugin_id}', + gramplet_title='{plugin_id}', +) +""" + +_GPR_QUICKREPORT_REGISTER = """\ +register(QUICKREPORT, + id='{plugin_id}', + name='{plugin_id}', + description='Synthetic test quickreport', + version='1.0.0', + gramps_target_version='{target}', + status=STABLE, + fname='{plugin_id}.py', + category=CATEGORY_QR_PERSON, + runfunc='run', +) +""" + + +# ------------------------------------------------------------ +# +# MakeListingTest +# +# ------------------------------------------------------------ +class MakeListingTest(unittest.TestCase): + """ + Tests that ``make.py listing `` does not corrupt the + listings file when the targeted addon is not eligible for listing. + """ + + def setUp(self) -> None: + self.workdir = tempfile.mkdtemp(prefix="make_listing_test_") + self.addons_source = os.path.join(self.workdir, "addons-source") + self.addons = os.path.join(self.workdir, "addons") + os.makedirs(self.addons_source) + os.makedirs(os.path.join(self.addons, GRAMPS_VERSION, "download")) + os.makedirs(os.path.join(self.addons, GRAMPS_VERSION, "listings")) + + shutil.copy(MAKE_PY, self.addons_source) + + # Seed addons-en.json with a real-looking entry that must survive. + self.listings_path = os.path.join( + self.addons, GRAMPS_VERSION, "listings", "addons-en.json" + ) + self.seed_entries = [ + { + "n": "ExistingAddon", + "i": "ExistingAddon", + "t": 3, + "d": "Seeded entry that must not be wiped", + "v": "1.0.0", + "g": GRAMPS_TARGET_VERSION, + "s": 3, + "z": "ExistingAddon.addon.tgz", + } + ] + with open(self.listings_path, "w", encoding="utf-8") as fp: + json.dump(self.seed_entries, fp, indent=0) + + def tearDown(self) -> None: + shutil.rmtree(self.workdir, ignore_errors=True) + + def _make_addon(self, name: str, include_in_listing: bool) -> None: + addon_dir = os.path.join(self.addons_source, name) + os.makedirs(addon_dir) + with open( + os.path.join(addon_dir, name + ".gpr.py"), "w", encoding="utf-8" + ) as fp: + fp.write( + _GPR_TEMPLATE.format( + name=name, + target=GRAMPS_TARGET_VERSION, + include_in_listing="True" if include_in_listing else "False", + ) + ) + # make.py only emits an entry when the .tgz already exists. + tgz_path = os.path.join( + self.addons, GRAMPS_VERSION, "download", name + ".addon.tgz" + ) + with open(tgz_path, "wb") as fp: + fp.write(b"placeholder") + + def _make_multi_gpr_addon( + self, name: str, gpr_files: dict[str, list[tuple[str, str]]] + ) -> None: + """ + Build an addon directory that contains multiple .gpr.py files + and/or multiple register() calls per file. + + ``gpr_files`` maps a base .gpr.py filename (without suffix) to a + list of ``(plugin_id, plugin_type)`` tuples to register inside + that file. ``plugin_type`` is "gramplet" or "quickreport". + """ + addon_dir = os.path.join(self.addons_source, name) + os.makedirs(addon_dir) + for gpr_base, registers in gpr_files.items(): + gpr_path = os.path.join(addon_dir, gpr_base + ".gpr.py") + with open(gpr_path, "w", encoding="utf-8") as fp: + for plugin_id, plugin_type in registers: + if plugin_type == "gramplet": + fp.write( + _GPR_GRAMPLET_REGISTER.format( + plugin_id=plugin_id, + target=GRAMPS_TARGET_VERSION, + ) + ) + elif plugin_type == "quickreport": + fp.write( + _GPR_QUICKREPORT_REGISTER.format( + plugin_id=plugin_id, + target=GRAMPS_TARGET_VERSION, + ) + ) + else: + raise ValueError( + "unknown plugin_type: %r" % (plugin_type,) + ) + tgz_path = os.path.join( + self.addons, GRAMPS_VERSION, "download", name + ".addon.tgz" + ) + with open(tgz_path, "wb") as fp: + fp.write(b"placeholder") + + def _run_listing(self, addon_name: str) -> subprocess.CompletedProcess: + env = os.environ.copy() + # make.py imports gramps.gen.const / gramps.gen.plug from GRAMPSPATH. + if "GRAMPSPATH" not in env: + import gramps # noqa: WPS433 + + env["GRAMPSPATH"] = os.path.dirname(os.path.dirname(gramps.__file__)) + return subprocess.run( + [sys.executable, "make.py", GRAMPS_VERSION, "listing", addon_name], + cwd=self.addons_source, + env=env, + capture_output=True, + text=True, + check=False, + ) + + def test_listing_excluded_addon_does_not_wipe_listings(self) -> None: + """ + Regression for bug 13694. + + Running ``make.py listing `` on an addon whose .gpr.py + declares ``include_in_listing=False`` must not overwrite the + ``addons-.json`` listings file with ``[]``. Pre-fix, the + single-addon update path produced an empty ``output`` list when no + plugin was eligible and then wrote that empty list, wiping every + previously listed addon. + """ + self._make_addon("ExcludedAddon", include_in_listing=False) + + result = self._run_listing("ExcludedAddon") + self.assertEqual( + result.returncode, + 0, + "make.py exited %s\nstdout:\n%s\nstderr:\n%s" + % (result.returncode, result.stdout, result.stderr), + ) + + with open(self.listings_path, "r", encoding="utf-8") as fp: + after = json.load(fp) + + self.assertEqual( + after, + self.seed_entries, + "Bug 13694: listing an include_in_listing=False addon must not " + "wipe the existing addons-.json. Got %r." % (after,), + ) + + def test_listing_multi_gpr_addon_does_not_duplicate_other_entries( + self, + ) -> None: + """ + Regression for the PR 915 follow-up flagged by GaryGriffin: an + addon that ships multiple ``.gpr.py`` files (or multiple + ``register()`` calls in one file) used to corrupt the listings + file with N copies of every existing entry. + + Pre-fix, the merge path's outer ``for plugin in sorted(listings...)`` + loop re-read the entire listings file on each iteration while + accumulating into a shared ``output``. With three new plugins + (two .gpr.py files - one with a single register, one with two) + every existing entry ended up duplicated three times. + + Post-fix, the merge reads the existing file once, drops every + row that belongs to cmd_arg, and inserts the fresh plugins at + their sorted (t, i) positions. Result: exactly one entry per + existing addon plus one per new plugin. + """ + # Seed the listings file with one existing entry that belongs + # to the multi-gpr addon (to verify replacement) and a few + # unrelated entries (to verify they are preserved exactly). + unrelated_a = { + "n": "Other A", + "i": "otheraddon_a", + "t": 0, + "d": "Unrelated entry", + "v": "1.0.0", + "g": GRAMPS_TARGET_VERSION, + "s": 3, + "z": "OtherAddonA.addon.tgz", + } + unrelated_b = { + "n": "Other B", + "i": "otheraddon_b", + "t": 5, + "d": "Unrelated entry", + "v": "1.0.0", + "g": GRAMPS_TARGET_VERSION, + "s": 3, + "z": "OtherAddonB.addon.tgz", + } + # Existing entry for our multi-gpr addon's gramplet, with a + # stale version that the new listing must overwrite. + stale_gramplet = { + "n": "MyForm Gramplet", + "i": "myform_gramplet", + "t": 5, + "d": "Stale entry from a prior build", + "v": "0.9.0", + "g": GRAMPS_TARGET_VERSION, + "s": 3, + "z": "MyForm.addon.tgz", + } + # Existing entry for cmd_arg that is no longer registered by + # any .gpr.py - the merge should drop it. + stale_dropped = { + "n": "MyForm Removed", + "i": "myform_removed", + "t": 5, + "d": "register() was removed from a .gpr.py - should be dropped", + "v": "1.0.0", + "g": GRAMPS_TARGET_VERSION, + "s": 3, + "z": "MyForm.addon.tgz", + } + # Write the existing file sorted by (t, i) - canonical order. + seed = sorted( + [unrelated_a, unrelated_b, stale_gramplet, stale_dropped], + key=lambda p: (p["t"], p["i"]), + ) + with open(self.listings_path, "w", encoding="utf-8") as fp: + json.dump(seed, fp, indent=0) + + # Build MyForm with two .gpr.py files: one registers a single + # gramplet, the other registers two quickreports. Mirrors the + # real Form addon's layout (formgramplet.gpr.py + + # CensusCheckQuickview.gpr.py). + self._make_multi_gpr_addon( + "MyForm", + { + "myform": [("myform_gramplet", "gramplet")], + "censuscheck": [ + ("myform_census", "quickreport"), + ("myform_censusup", "quickreport"), + ], + }, + ) + + result = self._run_listing("MyForm") + self.assertEqual( + result.returncode, + 0, + "make.py exited %s\nstdout:\n%s\nstderr:\n%s" + % (result.returncode, result.stdout, result.stderr), + ) + + with open(self.listings_path, "r", encoding="utf-8") as fp: + after = json.load(fp) + + ids = [e["i"] for e in after] + # No duplicates anywhere - the core symptom Gary flagged. + self.assertEqual( + len(ids), + len(set(ids)), + "Multi-gpr listing must not duplicate entries. Got ids: %r" + % (ids,), + ) + + # Unrelated entries preserved exactly. + after_by_id = {e["i"]: e for e in after} + self.assertIn("otheraddon_a", after_by_id) + self.assertIn("otheraddon_b", after_by_id) + self.assertEqual(after_by_id["otheraddon_a"], unrelated_a) + self.assertEqual(after_by_id["otheraddon_b"], unrelated_b) + + # Three new plugins from MyForm present, in MyForm.addon.tgz. + myform_entries = [e for e in after if e.get("z") == "MyForm.addon.tgz"] + myform_ids = sorted(e["i"] for e in myform_entries) + self.assertEqual( + myform_ids, + ["myform_census", "myform_censusup", "myform_gramplet"], + "Expected the three MyForm plugins; got %r" % (myform_ids,), + ) + + # Stale entry for the removed plugin must be dropped. + self.assertNotIn( + "myform_removed", + after_by_id, + "An existing entry for cmd_arg that is no longer registered " + "by any .gpr.py must be dropped during the merge.", + ) + + # Stale gramplet entry must have been replaced with the fresh + # version (post-fix v=1.0.0 from the new register). + self.assertEqual( + after_by_id["myform_gramplet"]["v"], + "1.0.0", + "Existing myform_gramplet entry should have been replaced " + "with the fresh v=1.0.0; got %r" + % (after_by_id["myform_gramplet"],), + ) + + # File is sorted by (t, i). + keys = [(e["t"], e["i"]) for e in after] + self.assertEqual( + keys, + sorted(keys), + "Output must remain sorted by (t, i); got %r" % (keys,), + ) + + +if __name__ == "__main__": + unittest.main()