From 53a1682330a78d30a87eaedd36242f7e6fa940db Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Mon, 25 May 2026 13:07:07 +0200 Subject: [PATCH 01/30] Delete RebuildTypes addon per author's request `RebuildTypes/RebuildTypes.gpr.py` declares `include_in_listing=False`, `status=UNSTABLE`, `category=TOOL_DBFIX` and `authors=["Nick Hall"]` - it is not published to users and is flagged as a database-modifying tool that may corrupt data. The addon also carried two stale Gramps-3 era pre-namespace imports (`from gui.plug import tool`, `from QuestionDialog import OkDialog`) that broke plugin registration on Python 3, so a load-time fix would have been needed to revive it. On PR https://github.com/gramps-project/addons-source/pull/877 (closed 2026-05-15, the prior port attempt) the addon's author and the project maintainer agreed to drop it rather than port: Nick-Hall: "The RebuildTypes addon was either just an example or written for a particular person to fix a bug. An export followed by an import will achieve the same result. The Type Cleanup tool may also be a better option. I am happy for this addon to be deleted." GaryGriffin: "Seeing TOOL_DBFIX and UNSTABLE together is scary for a released addon." Replace the dead/dangerous addon with its absence. Users needing the same outcome have two supported alternatives Nick named: - export / re-import (rebuilds the type catalogue as a side effect of the round-trip) - the Type Cleanup tool (which is published and maintained) No listing impact - the addon was already `include_in_listing=False`, so `addons/gramps*/listings/addons-*.json` did not reference it; no other tree references it either (`grep -rn RebuildTypes` on `upstream/maintenance/gramps60` returns hits only inside the `RebuildTypes/` directory itself). Co-Authored-By: Claude Opus 4.7 (1M context) --- RebuildTypes/RebuildTypes.gpr.py | 39 -------------- RebuildTypes/RebuildTypes.py | 90 -------------------------------- RebuildTypes/po/da-local.po | 28 ---------- RebuildTypes/po/de-local.po | 28 ---------- RebuildTypes/po/es-local.po | 28 ---------- RebuildTypes/po/fi-local.po | 29 ---------- RebuildTypes/po/fr-local.po | 28 ---------- RebuildTypes/po/hr-local.po | 29 ---------- RebuildTypes/po/nb-local.po | 20 ------- RebuildTypes/po/nl-local.po | 28 ---------- RebuildTypes/po/pt_PT-local.po | 28 ---------- RebuildTypes/po/ru-local.po | 30 ----------- RebuildTypes/po/sk-local.po | 28 ---------- RebuildTypes/po/sv-local.po | 28 ---------- RebuildTypes/po/template.pot | 35 ------------- RebuildTypes/po/uk-local.po | 29 ---------- 16 files changed, 525 deletions(-) delete mode 100644 RebuildTypes/RebuildTypes.gpr.py delete mode 100644 RebuildTypes/RebuildTypes.py delete mode 100644 RebuildTypes/po/da-local.po delete mode 100644 RebuildTypes/po/de-local.po delete mode 100644 RebuildTypes/po/es-local.po delete mode 100644 RebuildTypes/po/fi-local.po delete mode 100644 RebuildTypes/po/fr-local.po delete mode 100644 RebuildTypes/po/hr-local.po delete mode 100644 RebuildTypes/po/nb-local.po delete mode 100755 RebuildTypes/po/nl-local.po delete mode 100644 RebuildTypes/po/pt_PT-local.po delete mode 100644 RebuildTypes/po/ru-local.po delete mode 100644 RebuildTypes/po/sk-local.po delete mode 100644 RebuildTypes/po/sv-local.po delete mode 100644 RebuildTypes/po/template.pot delete mode 100644 RebuildTypes/po/uk-local.po 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 користувацьких типів подій" From 577a3ee54237289cf7541c73511353162e20b8fa Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Thu, 21 May 2026 20:43:36 +0200 Subject: [PATCH 02/30] lxml: keep the trailing newline out of ngettext counter strings The four ngettext() counter calls embedded a trailing \n in the plural form but not the singular. gettext requires a msgid and its msgid_plural to agree on the trailing newline, so msgfmt rejected every catalog that translated these strings and `make.py build lxml` aborted with fatal errors (Mantis bug 14234). Append the layout newline in code instead -- as the pre-39fbcdd version did -- so it is no longer part of the translatable string. The singular case (count == 1) still gets its line break. The per-language catalogs are not touched here: make.py extract-po regenerates them from the corrected template, so the stale \n drops out of de/hr/nl/pt_PT/sk through the normal Weblate pipeline rather than by hand-editing build output. Co-Authored-By: Claude Opus 4.7 (1M context) --- lxml/lxmlGramplet.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 From 38552a3fa0e9e89dd80fe37e0eb9b88517d257c4 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Tue, 26 May 2026 09:34:44 -0700 Subject: [PATCH 03/30] lxml: Update template.pot after string change --- lxml/po/template.pot | 340 +++++++++++++++++++++++-------------------- 1 file changed, 183 insertions(+), 157 deletions(-) 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 "" From 7f443e7ecc295bd646fe114f79910d7f9086925a Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Fri, 22 May 2026 06:53:17 -0700 Subject: [PATCH 04/30] Add HasTagSubstr filter addon for all object namespaces. Provides nine filter rules (Person, Family, Event, Place, Source, Citation, Repository, Media, Note) that match objects whose tag names contain a given substring (case-insensitive). Uses the selected_handles optimizer pattern with find_backlink_handles so only objects of the target namespace are fetched from the database. Co-Authored-By: Claude Sonnet 4.6 --- HasTagSubstr/hastagsubstr.gpr.py | 171 +++++++++++++++++++++++++++++ HasTagSubstr/hastagsubstr.py | 183 +++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 HasTagSubstr/hastagsubstr.gpr.py create mode 100644 HasTagSubstr/hastagsubstr.py diff --git a/HasTagSubstr/hastagsubstr.gpr.py b/HasTagSubstr/hastagsubstr.gpr.py new file mode 100644 index 000000000..ee67c738b --- /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.0", + 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.0", + 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.0", + 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.0", + 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.0", + 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.0", + 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.0", + 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.0", + 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.0", + 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" From 34821b0a5808782513286c64fd3c7d283773e205 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Tue, 26 May 2026 10:02:53 -0700 Subject: [PATCH 05/30] HasTagSubstr: add template.pot --- HasTagSubstr/po/template.pot | 103 +++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 HasTagSubstr/po/template.pot 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 "" From 698d776f0857fc22c1430c6d8f969d4d9bbc4abb Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Tue, 26 May 2026 10:06:34 -0700 Subject: [PATCH 06/30] Merge Add HasTagSubstr filter addon for all object namespaces#908 --- HasTagSubstr/hastagsubstr.gpr.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/HasTagSubstr/hastagsubstr.gpr.py b/HasTagSubstr/hastagsubstr.gpr.py index ee67c738b..e7c9f3064 100644 --- a/HasTagSubstr/hastagsubstr.gpr.py +++ b/HasTagSubstr/hastagsubstr.gpr.py @@ -24,7 +24,7 @@ id="PersonHasTagSubstr", name=_("People with a tag containing "), description=_("Matches people with a tag whose name contains the given substring"), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", @@ -41,7 +41,7 @@ description=_( "Matches families with a tag whose name contains the given substring" ), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", @@ -58,7 +58,7 @@ description=_( "Matches events with a tag whose name contains the given substring" ), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", @@ -75,7 +75,7 @@ description=_( "Matches places with a tag whose name contains the given substring" ), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", @@ -92,7 +92,7 @@ description=_( "Matches sources with a tag whose name contains the given substring" ), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", @@ -109,7 +109,7 @@ description=_( "Matches citations with a tag whose name contains the given substring" ), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", @@ -126,7 +126,7 @@ description=_( "Matches repositories with a tag whose name contains the given substring" ), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", @@ -143,7 +143,7 @@ description=_( "Matches media objects with a tag whose name contains the given substring" ), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", @@ -160,7 +160,7 @@ description=_( "Matches notes with a tag whose name contains the given substring" ), - version="1.0.0", + version = '1.0.1', authors=["Doug Blank"], authors_email=["doug.blank@gmail.com"], gramps_target_version="6.1", From e138920b67b16c934ccf4ffafe46d25f405a0d24 Mon Sep 17 00:00:00 2001 From: jurchello Date: Fri, 22 May 2026 18:06:24 +0300 Subject: [PATCH 07/30] Update WebSearch to version 1.10.18 for Gramps 6.0 --- WebSearch/.gitignore | 8 +- WebSearch/WebSearch.gpr.py | 2 +- WebSearch/WebSearch.py | 412 ++++++++++++++++++++--- WebSearch/activity_row_generator.py | 14 +- WebSearch/assets/csv/uid-links.csv | 1 + WebSearch/attribute_editor_manager.py | 65 +++- WebSearch/attribute_links_loader.py | 8 +- WebSearch/attribute_mapping_loader.py | 34 ++ WebSearch/configs/attribute_mapping.json | 6 + WebSearch/constants.py | 2 + WebSearch/interface.xml | 6 + WebSearch/internet_links_loader.py | 12 +- WebSearch/model_row_generator.py | 247 +++++++++++--- WebSearch/models.py | 2 + WebSearch/note_links_loader.py | 17 +- WebSearch/notification.py | 14 +- WebSearch/po/template.pot | 166 ++++----- WebSearch/qr_window.py | 1 - 18 files changed, 834 insertions(+), 183 deletions(-) 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..b520e6623 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.17", 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 From 03cfbed4f78f696adbf1263a9164b223f5a21e66 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Tue, 26 May 2026 10:11:41 -0700 Subject: [PATCH 08/30] Merge Update WebSearch to version 1.10.18 for Gramps 6.0#909 --- WebSearch/WebSearch.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebSearch/WebSearch.gpr.py b/WebSearch/WebSearch.gpr.py index b520e6623..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.17", + version = '1.10.18', fname="WebSearch.py", height=20, detached_width=400, From b86b123531e63e151937514a46b057901be7af04 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Fri, 22 May 2026 18:42:10 +0200 Subject: [PATCH 09/30] CombinedView: clear the view when the database is closed Closing a family tree emits 'database-changed'; change_db() clears the history and calls redraw(), which -- finding no active person -- calls change_object(None). That returned immediately, before the code that rebuilds header/stack, so the view kept showing the closed tree's people. Their edit buttons still held handles from the closed database, and clicking one raised gramps.gen.errors.HandleError inside get_person_from_handle() (Mantis bug 12572; the underlying stale-view symptom is Mantis bug 14226). On the no-active-object path, clear header/stack and disable the per-page actions instead of returning early, mirroring the built-in RelationshipView whose change_db() clears its container directly. edit_active() is guarded against the now-possible active_page is None. Co-Authored-By: Claude Opus 4.7 (1M context) --- CombinedView/combinedview.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 From d24f1eea4ea7f849d53fda7f6353b16bc315e41d Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Tue, 26 May 2026 12:30:08 -0700 Subject: [PATCH 10/30] Merge CombinedView: clear the view when the database is closed (bug 12572)#911 --- CombinedView/combinedview.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 7edfbec1d75a1dd309a936ce7cfef39b9d99c685 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sat, 23 May 2026 10:04:50 +0200 Subject: [PATCH 11/30] GenealogyTree: compact-template option for the Sandclock report (bug 10512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Root cause The Sandclock Genealogy Tree report rendered only one page of an arbitrarily-large tree. The LaTeX `genealogytree` package's default `database` template lays the tree out so widely that big sandclocks clip off the page; the package itself does not support multi-page output for that layout (confirmed by SNoiraud in note ~0046612 on the Mantis ticket). ## Fix Expose SNoiraud's 2018-12-20 workaround as a user-facing report option. A new "Compact tree layout" boolean on the Sandclock report appends `template=database pole reduced` to the `\genealogytree[…]` parameter list when enabled, giving ~4x more space per page at the cost of denser per-node formatting. The directive is appended last so it overrides any node-spacing defaults that `gramps/gen/plug/docgen/treedoc.py`'s built-in keys (`level distance`, `node size`, `level size`) set earlier in the parameter list — pgfkeys is order-sensitive, so a later `template=` re-sets the keys the template controls. Option-list assembly was extracted into a `_build_tree_options()` method so the option-construction logic can be unit-tested without driving a full report run. ## Verified against - `GenealogyTree/gt_sandclock.py:148` — `_build_tree_options` body - `GenealogyTree/gt_sandclock.py:96-128` — `write_report` calls into it - `GenealogyTree/treeplugins.gpr.py` — unchanged; per addons-source convention the maintainer manages the `.gpr.py` version, not the contributor ## Test `GenealogyTree/tests/test_sandclock_compact_template.py` — five focused unit tests exercising the new option-list builder via `SandclockTree.__new__(...)` skeleton (no Gramps GUI, no LaTeX, no DB). Cover: * Default layout omits any `template=` directive. * `compact=True` appends exactly `template=database pole reduced`. * Directive lands LAST in the option list (pgfkeys order). * Other default options (`pref code=`, `list separators hang`, `place text=`, `box=`) remain intact when compact is on. * `include_images` and `compact` coexist without either suppressing the other. All 5 pass via `gramps-testbed/scripts/ubuntu/run-addon-unit.sh GenealogyTree`. Fixes #10512 Co-Authored-By: Claude Opus 4.7 --- GenealogyTree/gt_sandclock.py | 67 +++++--- GenealogyTree/tests/__init__.py | 0 .../tests/test_sandclock_compact_template.py | 143 ++++++++++++++++++ 3 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 GenealogyTree/tests/__init__.py create mode 100644 GenealogyTree/tests/test_sandclock_compact_template.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/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() From f3c2c2b8fd0a89b3ef248c7c040547307bc8115e Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 27 May 2026 09:13:14 -0700 Subject: [PATCH 12/30] GenealogyTree update template.pot --- GenealogyTree/po/template.pot | 41 ++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 15 deletions(-) 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" From 638f9b8cf94b094383a1aea20fca416b4300eb5a Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 27 May 2026 09:14:54 -0700 Subject: [PATCH 13/30] Merge GenealogyTree: compact-template option for the Sandclock report (bug 10512)#912 --- GenealogyTree/treeplugins.gpr.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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, From 70ce73504e9949ed47bf6b99ef2c0f382ebc1869 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sat, 23 May 2026 17:48:43 +0200 Subject: [PATCH 14/30] PrerequisitesCheckerGramplet: guard active_page on tree close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main() reads `self.uistate.viewmanager.active_page.bottombar` unguarded, but the gramplet framework keeps stepping the generator via `_updater` → `next(self._generator)` after the family tree is closed. At that point `active_page` is None, so the read raises AttributeError: 'NoneType' object has no attribute 'bottombar'. Pull active_page into a local, return early if it's None, then proceed with the existing bottombar/db-open/count<3 short-circuit chain. No behaviour change while a tree is open. Regression test exercises main() with a stub uistate whose active_page is None and asserts the generator exits cleanly (StopIteration) instead of raising. Two further cases lock the existing dashboard / non-dashboard short-circuit paths so the new guard does not regress them. Fixes #13966 --- .../PrerequisitesCheckerGramplet.py | 7 +- .../tests/__init__.py | 0 .../tests/test_main_active_page_none.py | 131 ++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 PrerequisitesCheckerGramplet/tests/__init__.py create mode 100644 PrerequisitesCheckerGramplet/tests/test_main_active_page_none.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() From e647850446e0d6e074f0904e9caa8b1db08e4915 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 27 May 2026 09:37:54 -0700 Subject: [PATCH 15/30] Merge PrerequisitesCheckerGramplet: guard active_page on tree close (bug 13966)#913 --- .../PrerequisitesCheckerGramplet.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From c9ff8a1ecb9aec28360a45601c07bf430b4cf499 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sat, 23 May 2026 17:44:50 +0200 Subject: [PATCH 16/30] DetailedDescendantBookReport: fix index crash when omit-duplicates is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-pass guarded by `if self.dubperson:` in write_report() is the only path that fills `self.report_app_ref`. When that option is off but Index of Dates / Places / Names is on, append_event still runs from write_person_info / __write_family_events for every event and raises against the empty / missing table — KeyError on the current handle (this report), or AttributeError on older addon versions before the partial fix for 12857/12859 added the unconditional init. Fall back inside append_event to the current writing-pass coordinates (report_count, generation+1, dnumber[phandle], person's name). That is the natural Ref for an index entry anyway — the location in the document where the event is being narrated. When report_app_ref is populated (the omit-duplicates path), the original `[0]` read is preserved, so behaviour there is unchanged. Regression test exercises append_event in isolation with an empty report_app_ref and asserts (a) it does not raise and (b) the index entry references current state. Fixes #14051 --- .../DetailedDescendantBookReport.py | 27 ++- DescendantBooks/tests/__init__.py | 0 ...end_event_index_without_omit_duplicates.py | 183 ++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 DescendantBooks/tests/__init__.py create mode 100644 DescendantBooks/tests/test_append_event_index_without_omit_duplicates.py diff --git a/DescendantBooks/DetailedDescendantBookReport.py b/DescendantBooks/DetailedDescendantBookReport.py index 0ac2cec21..a09339fe1 100644 --- a/DescendantBooks/DetailedDescendantBookReport.py +++ b/DescendantBooks/DetailedDescendantBookReport.py @@ -788,7 +788,32 @@ 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). Fall back to the current writing-pass coordinates, which + # are the natural Ref for an index entry: the location where the + # event is being narrated. + if (self.phandle in self.report_app_ref + and self.report_app_ref[self.phandle]): + (repno, gen, per, mate, name) = \ + self.report_app_ref[self.phandle][0] + else: + repno = self.report_count + gen = self.generation + 1 + 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() + mate = False 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..81286a391 --- /dev/null +++ b/DescendantBooks/tests/test_append_event_index_without_omit_duplicates.py @@ -0,0 +1,183 @@ +# +# 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 current writing-pass coordinates (report_count, + # generation+1, dnumber[phandle]) — i.e. "3 2 1" — not with a + # tuple sourced from a stale / missing report_app_ref entry. + self.assertIn(1850, report.index_of_dates, + "Year 1850 should be indexed") + self.assertIn("Dublin", report.index_of_places, + "Place 'Dublin' should be indexed") + + # Spot-check the Ref text uses current state. + 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 current report context " + "(report_count=3, generation+1=2, dnumber[phandle]=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) + + +if __name__ == "__main__": + unittest.main() From 82d7999a2ae0f9aac1a42df842fd51dce21711e0 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sat, 23 May 2026 18:28:01 +0200 Subject: [PATCH 17/30] DetailedDescendantBookReport: pin Ref to first encounter, not current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the previous commit. The earlier fallback computed the Ref tuple from the CURRENT writing-pass coordinates each time append_event ran. With omit-duplicates off and a person appearing in multiple per-ascendant reports, append_event runs once per encounter, and `self.index_of_dates[year][date]` / index_of_places overwrites on every call — so the surviving entry ended up tagged with the LAST encounter's coordinates, not the first. The omit-duplicates path emits the FIRST-encounter coordinates (report_app_ref[phandle][0]). Populating that entry on first call in append_event — rather than re-deriving on every call — pins the Ref to the first encounter and makes the two paths semantically equivalent. Verified empirically on example.gramps (I00016): 1365 / 1372 common (place, date) index entries have identical Ref tuples between omit-duplicates ON and OFF; the 7 mismatches all carry `date="0000-00-00"`, a pre-existing key collision in index_of_places[place][date] where unrelated empty-date events overwrite each other (independent of this fix). Place coverage in OFF is slightly higher (658 vs 656) because OFF doesn't early-return duplicates, so events on duplicated people are indexed too. New test TestRefSemanticsParityWithOmitDuplicates locks first- encounter semantics by simulating a multi-encounter scenario. --- .../DetailedDescendantBookReport.py | 25 ++-- ...end_event_index_without_omit_duplicates.py | 127 +++++++++++++++++- 2 files changed, 134 insertions(+), 18 deletions(-) diff --git a/DescendantBooks/DetailedDescendantBookReport.py b/DescendantBooks/DetailedDescendantBookReport.py index a09339fe1..a91b7b213 100644 --- a/DescendantBooks/DetailedDescendantBookReport.py +++ b/DescendantBooks/DetailedDescendantBookReport.py @@ -794,16 +794,15 @@ def append_event(self, event_ref, family = False): # 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). Fall back to the current writing-pass coordinates, which - # are the natural Ref for an index entry: the location where the - # event is being narrated. - if (self.phandle in self.report_app_ref - and self.report_app_ref[self.phandle]): - (repno, gen, per, mate, name) = \ - self.report_app_ref[self.phandle][0] - else: - repno = self.report_count - gen = self.generation + 1 + # 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 \ @@ -813,7 +812,11 @@ def append_event(self, event_ref, family = False): per = "?" person = self.database.get_person_from_handle(self.phandle) name = person.get_primary_name().get_name() - mate = False + 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/test_append_event_index_without_omit_duplicates.py b/DescendantBooks/tests/test_append_event_index_without_omit_duplicates.py index 81286a391..6077c4a67 100644 --- a/DescendantBooks/tests/test_append_event_index_without_omit_duplicates.py +++ b/DescendantBooks/tests/test_append_event_index_without_omit_duplicates.py @@ -141,20 +141,20 @@ def test_append_event_does_not_crash_without_prepopulated_table(self): report.append_event(event_ref) # The indexes should have been populated with an entry tagged - # with the current writing-pass coordinates (report_count, - # generation+1, dnumber[phandle]) — i.e. "3 2 1" — not with a - # tuple sourced from a stale / missing report_app_ref entry. + # 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") - # Spot-check the Ref text uses current state. 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 current report context " - "(report_count=3, generation+1=2, dnumber[phandle]=1); " - "got %r" % 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. @@ -179,5 +179,118 @@ def test_append_event_for_mate_without_dnumber(self): 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() From a776dcc8f1fe6eb89fe6576dde59549db465e635 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 27 May 2026 09:58:54 -0700 Subject: [PATCH 18/30] Merge DetailedDescendantBookReport: (bug 14051) #914 --- DescendantBooks/DescendantBooks.gpr.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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", From 7a0cbdcf1986e4c39d53c7eb6a12355cd5e1fc86 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sun, 24 May 2026 14:48:47 +0200 Subject: [PATCH 19/30] Fix IndexError when an addon declares an empty requires_exe. PluginStatus.__info iterates Requirements().info(addon) as [label, table] pairs and joins the first row with " ".join(req_lst[0]). If the addon listing carries a present-but-empty requires key - e.g. PostgreSQL Enhanced's gpr.py declares `requires_exe=[]`, which ends up as `"re": []` in addons-.json - gramps core's Requirements.info still emits an "Executables" label paired with an empty table, and the join raises `IndexError: list index out of range`. Skip empty tables cleanly: indexing fails on them and there is nothing useful to render anyway. PostgreSQL Enhanced is currently the only addon in gramps61 listings with a present-but-empty requires key, which matches the reported "only the PostgreSQL Enhanced row crashes" symptom. Resolves #13979. --- PluginManager/PluginManager.py | 10 +- PluginManager/tests/__init__.py | 0 .../tests/test_info_empty_requires.py | 173 ++++++++++++++++++ 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 PluginManager/tests/__init__.py create mode 100644 PluginManager/tests/test_info_empty_requires.py 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() From 6ece30905de217268fe3b7e0fd66bd9fd55e71e7 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 27 May 2026 10:24:33 -0700 Subject: [PATCH 20/30] Merge PluginManager Enhanced (bug 13979)#916 --- PluginManager/PluginManager.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"], From ca6f0c2179c3f654bc99962ffedc3d251f7ab7a8 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sun, 24 May 2026 14:01:41 +0200 Subject: [PATCH 21/30] Fix make.py listing wiping addons-.json for excluded addon. Running `make.py listing ` against an addon whose .gpr.py declares `include_in_listing=False` (or whose .addon.tgz has not been built yet) caused the listings file to be overwritten with `[]`, wiping every previously listed addon for that language. The single-addon update path builds `listings` per language, then either replaces the file (for "listing all") or merges entries from the existing file with the new ones. When the targeted addon yielded no eligible plugin, `listings` was empty, the merge loop did not iterate, and `output` was written as `[]`. Guard the single-addon path: when `listings` is empty and a listings file already exists, skip the write and tell the user that the targeted addon is not eligible and how to remove an existing entry on purpose (`make.py unlist `). Resolves #13694. --- make.py | 23 +++-- tests/__init__.py | 0 tests/test_make_listing.py | 173 +++++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_make_listing.py diff --git a/make.py b/make.py index 9931ec053..d0e07a13d 100755 --- a/make.py +++ b/make.py @@ -973,13 +973,26 @@ 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: @@ -987,7 +1000,7 @@ def register(ptype, **kwargs): 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), + listings_path, "r", encoding="utf-8", ) @@ -1021,7 +1034,7 @@ def register(ptype, **kwargs): output.append(plugin) 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/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_make_listing.py b/tests/test_make_listing.py new file mode 100644 index 000000000..936f7f17f --- /dev/null +++ b/tests/test_make_listing.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. +# + +""" +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") +GRAMPS_VERSION = "gramps61" + + +_GPR_TEMPLATE = """\ +register(GRAMPLET, + id='{name}', + name='{name}', + description='Synthetic test addon', + version='1.0.0', + gramps_target_version='6.1', + status=STABLE, + fname='{name}.py', + height=200, + gramplet='{name}', + gramplet_title='{name}', + include_in_listing={include_in_listing}, +) +""" + + +# ------------------------------------------------------------ +# +# 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": "6.1", + "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, + 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 _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,), + ) + + +if __name__ == "__main__": + unittest.main() From 1cf301138708de56838d4a3dc84dd40ccd884409 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sun, 24 May 2026 19:25:21 +0200 Subject: [PATCH 22/30] make.py listing: also fix multi-gpr corruption (PR 915 follow-up) GaryGriffin flagged on PR 915 that the listings file still corrupts for addons that ship multiple .gpr.py files (or multiple register() calls per .gpr.py). The Form addon is the canonical example: running `make.py gramps61 listing Form` ballooned addons-en.json from 170 entries to 510 - every existing addon appearing three times, one per Form-registered plugin. Root cause: the merge path's outer `for plugin in sorted(listings, ...)` loop re-read the entire existing listings file on each iteration while accumulating into a shared `output`. With N new plugins for cmd_arg the existing N-1 unrelated entries got appended N times. Replace the per-plugin re-read with a single-pass merge: read the existing file once, drop every row that belongs to cmd_arg (matched by .z) or whose (t, i) collides with one of the fresh plugins, then merge the kept rows with the new plugins in sorted (t, i) order. This also tightens behaviour in two ways: - Stale entries for cmd_arg (e.g. a register() removed from a gpr.py since the last build) are dropped, not preserved. - The "first match wins" ambiguity of the old z+t matcher is gone; matching is per-plugin by (t, i). Tests in `tests/test_make_listing.py` now cover both the 13694 case (include_in_listing=False addon) and a multi-gpr addon shaped like the real Form. The multi-gpr test fails on the pre-fix code with "14 != 6" - exactly the symptom Gary saw - and passes after. Resolves #13694. --- make.py | 73 +++++------ tests/test_make_listing.py | 250 ++++++++++++++++++++++++++++++++++++- 2 files changed, 284 insertions(+), 39 deletions(-) diff --git a/make.py b/make.py index d0e07a13d..560f396a3 100755 --- a/make.py +++ b/make.py @@ -996,42 +996,43 @@ def register(ptype, **kwargs): 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( - listings_path, - "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( listings_path, diff --git a/tests/test_make_listing.py b/tests/test_make_listing.py index 936f7f17f..6e2d95886 100644 --- a/tests/test_make_listing.py +++ b/tests/test_make_listing.py @@ -39,7 +39,28 @@ ADDONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MAKE_PY = os.path.join(ADDONS_ROOT, "make.py") -GRAMPS_VERSION = "gramps61" + + +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 = """\ @@ -48,7 +69,7 @@ name='{name}', description='Synthetic test addon', version='1.0.0', - gramps_target_version='6.1', + gramps_target_version='{target}', status=STABLE, fname='{name}.py', height=200, @@ -58,6 +79,35 @@ ) """ +_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', +) +""" + # ------------------------------------------------------------ # @@ -91,7 +141,7 @@ def setUp(self) -> None: "t": 3, "d": "Seeded entry that must not be wiped", "v": "1.0.0", - "g": "6.1", + "g": GRAMPS_TARGET_VERSION, "s": 3, "z": "ExistingAddon.addon.tgz", } @@ -111,6 +161,7 @@ def _make_addon(self, name: str, include_in_listing: bool) -> None: fp.write( _GPR_TEMPLATE.format( name=name, + target=GRAMPS_TARGET_VERSION, include_in_listing="True" if include_in_listing else "False", ) ) @@ -121,6 +172,47 @@ def _make_addon(self, name: str, include_in_listing: bool) -> None: 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. @@ -168,6 +260,158 @@ def test_listing_excluded_addon_does_not_wipe_listings(self) -> None: "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() From b3b5dad7d5c99958e53c45d7c0779ebc20f7f61b Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Mon, 25 May 2026 14:33:32 +0200 Subject: [PATCH 23/30] FRWebConnectPack: update Geneanet search URL to current individus form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Geneanet entry in WEBSITES (FRWebPack.py:45) pointed at the deprecated `https://search.geneanet.org/result.php?lang=fr&name=...` URL, which no longer returns useful results, and the template only carried the surname. Replace with the current individus search the reporter on Mantis 14145 supplied: https://www.geneanet.org/fonds/individus/?go=1&nom=%(surname)s&prenom=%(given)s The two `%(...)s` placeholders match the file's existing convention (see lines 41, 46, 48) and the libwebconnect URL builder at libwebconnect/libwebconnect.py:182, which formats the pattern with the dict returned by make_person_dict — both `surname` and `given` keys are populated there. callmedave's note 4 on the tracker recommends the WebSearch Gramplet as a longer-term replacement for the Web Connect Pack family of addons, BUT his note 5 explicitly confirms the bug for FrWebConnectPack — so the live addon is still in scope and this is not a wontfix. Add a regression test in `FRWebConnectPack/tests/` that pulls the Geneanet pattern from WEBSITES and applies the same `pattern % dict` formatting libwebconnect uses. Pure string assertion — no network, no display, no Gtk. The test covers both halves of the fix: the URL must include both name parts (the given name was discarded pre-fix) and must target the current host/path/params (the old `search.geneanet.org/result.php` form is asserted absent). Verified via the testbed's `run-addon-unit.sh FRWebConnectPack`: Before fix: FAILED (failures=2) After fix: 2 tests, OK Fixes #14145 Co-Authored-By: Claude Opus 4.7 (1M context) --- FRWebConnectPack/FRWebPack.py | 2 +- FRWebConnectPack/tests/__init__.py | 0 FRWebConnectPack/tests/test_geneanet_url.py | 127 ++++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 FRWebConnectPack/tests/__init__.py create mode 100644 FRWebConnectPack/tests/test_geneanet_url.py 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() From 70e3cb1a0609f829dcc8537cc44479404f0ef6c2 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 27 May 2026 11:53:25 -0700 Subject: [PATCH 24/30] Merge FRWebConnectPack: (bug 14145)#919 --- FRWebConnectPack/FRWebPack.gpr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From ad3924fccfb1127b7ed9a7ed2fe1792acdb30009 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Fri, 8 May 2026 17:32:28 -0700 Subject: [PATCH 25/30] Initial Form Add IE 1926 Form and reference --- Form/form.py | 1 + Form/form_ie.xml | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 Form/form_ie.xml 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..f29d64ed2 --- /dev/null +++ b/Form/form_ie.xml @@ -0,0 +1,106 @@ + + +
+ + <_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>Persons + + + <_attribute>Males + + + <_attribute>Females + + + <_attribute>Rooms + +
+ + <_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 18 Living + 4 + +
+
+
From 75149f876eb7d31ad6ab93011b7881eeaed73770 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 13 May 2026 07:57:03 -0700 Subject: [PATCH 26/30] Fix form for age and street address --- Form/form_ie.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Form/form_ie.xml b/Form/form_ie.xml index f29d64ed2..86c57caa1 100644 --- a/Form/form_ie.xml +++ b/Form/form_ie.xml @@ -14,7 +14,7 @@ <_attribute>Urban Dist. Town or Village - <_attribute>Street etc with No of House + <_attribute>Street etc with No. of House <_attribute>No. of Schedule @@ -98,7 +98,7 @@ <_longname>Children born alive to present marriage - <_attribute>Total Children Under 18 Living + <_attribute>Total Children Under 16 Living 4 From b10aa4c413a3c279d41c52eb905689a1af7e8089 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 13 May 2026 11:39:24 -0700 Subject: [PATCH 27/30] Add B/M/D forms B/M/D forms from https://gramps-project.org/bugs/view.php?id=13977 Removed Marriage/Signed field --- Form/form_ie.xml | 265 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/Form/form_ie.xml b/Form/form_ie.xml index 86c57caa1..3bde1521f 100644 --- a/Form/form_ie.xml +++ b/Form/form_ie.xml @@ -103,4 +103,269 @@ +
+ + <_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 + +
+
From 8c89ff55229f9981a2715742fbc1fb337bd8f59b Mon Sep 17 00:00:00 2001 From: OscarBrownbread Date: Thu, 14 May 2026 11:32:48 +0200 Subject: [PATCH 28/30] Added new headings and changed order of headings. Added new headings for additional attributes in the form. Added new headings and reordered them. --- Form/form_ie.xml | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/Form/form_ie.xml b/Form/form_ie.xml index 3bde1521f..3e540f0d2 100644 --- a/Form/form_ie.xml +++ b/Form/form_ie.xml @@ -1,36 +1,48 @@
- - <_attribute>County or Co. Borough + + <_attribute>Total Area in Statute Acres + + + <_attribute>Signature of Head of Household + + + <_attribute>Signature of Enumerator + + + <_attribute>Persons - <_attribute>District Electoral Division or Ward + <_attribute>Males - <_attribute>Townland + <_attribute>Females - <_attribute>Urban Dist. Town or Village + <_attribute>Rooms - - <_attribute>Street etc with No. of House + + <_attribute>County or Co. Borough - <_attribute>No. of Schedule + <_attribute>District Electoral Division or Ward - <_attribute>Persons + <_attribute>Townland - <_attribute>Males + <_attribute>Urban Dist. Town or Village - <_attribute>Females + <_attribute>Street etc with No. of House - <_attribute>Rooms + <_attribute>No. of Schedule + + <_attribute>Name of Public Institution +
<_attribute>Name From 8a849d89249d2f48c061b9ab6ee910be3ab11f63 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Tue, 26 May 2026 09:42:45 -0700 Subject: [PATCH 29/30] Update form_ie.xml --- Form/form_ie.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Form/form_ie.xml b/Form/form_ie.xml index 3e540f0d2..4c22b470d 100644 --- a/Form/form_ie.xml +++ b/Form/form_ie.xml @@ -115,7 +115,7 @@
-
+ <_attribute>(1.) Entry no. @@ -135,10 +135,10 @@ <_attribute>County - <_attribute> (10.) Signature of Registrar + <_attribute>(10.) Signature of Registrar - <_attribute> (11.) Baptismal Name if added after Registration of Birth, and Date + <_attribute>(11.) Baptismal Name if added after Registration of Birth, and Date
From 0143ea14d055cf717f95ab9633d7f9b6c5997585 Mon Sep 17 00:00:00 2001 From: GaryGriffin Date: Wed, 27 May 2026 12:01:21 -0700 Subject: [PATCH 30/30] Merge Forms: IE form PR842 --- Form/CensusCheckQuickview.gpr.py | 4 ++-- Form/formgramplet.gpr.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Form/CensusCheckQuickview.gpr.py b/Form/CensusCheckQuickview.gpr.py index 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/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",