gedcom-navigator 1.9.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gedcom_navigator/__init__.py +3 -0
- gedcom_navigator/_scripts/gedcom_config.py +227 -0
- gedcom_navigator/_scripts/gedcom_core.py +42 -0
- gedcom_navigator/_scripts/gedcom_data_model.py +200 -0
- gedcom_navigator/_scripts/gedcom_debug.py +206 -0
- gedcom_navigator/_scripts/gedcom_display.py +26 -0
- gedcom_navigator/_scripts/gedcom_family_tree.py +871 -0
- gedcom_navigator/_scripts/gedcom_graph_export.py +510 -0
- gedcom_navigator/_scripts/gedcom_gui_appearance.py +821 -0
- gedcom_navigator/_scripts/gedcom_gui_background.py +218 -0
- gedcom_navigator/_scripts/gedcom_gui_dialogs.py +999 -0
- gedcom_navigator/_scripts/gedcom_gui_family_tree_render.py +490 -0
- gedcom_navigator/_scripts/gedcom_gui_graph_common.py +876 -0
- gedcom_navigator/_scripts/gedcom_gui_graph_layout.py +1708 -0
- gedcom_navigator/_scripts/gedcom_gui_graph_render.py +14 -0
- gedcom_navigator/_scripts/gedcom_gui_help_dialogs.py +344 -0
- gedcom_navigator/_scripts/gedcom_gui_path_graph.py +874 -0
- gedcom_navigator/_scripts/gedcom_gui_person_dialog.py +965 -0
- gedcom_navigator/_scripts/gedcom_gui_results.py +689 -0
- gedcom_navigator/_scripts/gedcom_gui_search.py +694 -0
- gedcom_navigator/_scripts/gedcom_i18n.py +96 -0
- gedcom_navigator/_scripts/gedcom_markdown.py +289 -0
- gedcom_navigator/_scripts/gedcom_name_search.py +149 -0
- gedcom_navigator/_scripts/gedcom_navigator_cli.py +290 -0
- gedcom_navigator/_scripts/gedcom_navigator_gui.py +857 -0
- gedcom_navigator/_scripts/gedcom_parser.py +406 -0
- gedcom_navigator/_scripts/gedcom_platform.py +24 -0
- gedcom_navigator/_scripts/gedcom_relationship.py +868 -0
- gedcom_navigator/_scripts/gedcom_search.py +985 -0
- gedcom_navigator/_scripts/gedcom_shortcuts.py +135 -0
- gedcom_navigator/_scripts/gedcom_strings.py +542 -0
- gedcom_navigator/_scripts/gedcom_theme.py +75 -0
- gedcom_navigator/_scripts/gedcom_tooltip.py +362 -0
- gedcom_navigator/_scripts/gedcom_transliteration.py +232 -0
- gedcom_navigator/_scripts/gedcom_update.py +108 -0
- gedcom_navigator/_scripts/gedcom_zoom.py +87 -0
- gedcom_navigator/cli.py +40 -0
- gedcom_navigator/docs/CHANGELOG.md +453 -0
- gedcom_navigator/docs/DEVELOPMENT.md +138 -0
- gedcom_navigator/docs/HELP.md +52 -0
- gedcom_navigator/docs/KEYBOARD_SHORTCUTS.md +36 -0
- gedcom_navigator/docs/LICENSE.md +13 -0
- gedcom_navigator/docs/LINUX_ISSUES.md +52 -0
- gedcom_navigator/docs/MAC_SECURITY.md +6 -0
- gedcom_navigator/docs/OLD-MAC-SECURITY.md +13 -0
- gedcom_navigator/docs/PRIVACY_POLICY.md +63 -0
- gedcom_navigator/docs/README-translate-po-deepl.md +45 -0
- gedcom_navigator/docs/README.md +68 -0
- gedcom_navigator/docs/TECHNICAL.md +229 -0
- gedcom_navigator/docs/TODO.md +3 -0
- gedcom_navigator/docs/WINDOWS_SECURITY.md +6 -0
- gedcom_navigator/docs/screenshots/App Store Screen Recording.mp4 +0 -0
- gedcom_navigator/docs/screenshots/App Store Screenshot.png +0 -0
- gedcom_navigator/docs/screenshots/GEDCOM Navigator Windows Screenshot.png +0 -0
- gedcom_navigator/docs/screenshots/downloaded-from-internet.png +0 -0
- gedcom_navigator/docs/screenshots/gedcom-navigator-screenshot-for-app-store.png +0 -0
- gedcom_navigator/docs/screenshots/main-window.png +0 -0
- gedcom_navigator/docs/screenshots/old_screen_recording.gif +0 -0
- gedcom_navigator/docs/screenshots/open_anyway.png +0 -0
- gedcom_navigator/docs/screenshots/relationship-path.png +0 -0
- gedcom_navigator/docs/screenshots/results-pane.png +0 -0
- gedcom_navigator/docs/screenshots/screen_recording.gif +0 -0
- gedcom_navigator/docs/screenshots/screen_recording.mp4 +0 -0
- gedcom_navigator/docs/screenshots/windows-security.png +0 -0
- gedcom_navigator/docs/transliterated-name-search-plan.md +61 -0
- gedcom_navigator/gui.py +43 -0
- gedcom_navigator/icons/family_tree-old.png +0 -0
- gedcom_navigator/icons/family_tree.ico +0 -0
- gedcom_navigator/icons/family_tree.png +0 -0
- gedcom_navigator/icons/family_tree_appstore.pdn +2553 -14
- gedcom_navigator/icons/family_tree_appstore.png +0 -0
- gedcom_navigator-1.9.4.dist-info/METADATA +102 -0
- gedcom_navigator-1.9.4.dist-info/RECORD +75 -0
- gedcom_navigator-1.9.4.dist-info/WHEEL +4 -0
- gedcom_navigator-1.9.4.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gedcom_config.py
|
|
4
|
+
|
|
5
|
+
Typed persistence layer for settings.json — no GUI imports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from gedcom_debug import log_exception
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigManager:
|
|
16
|
+
"""Read/write a single settings.json file; all I/O is isolated here."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config_path: Path):
|
|
19
|
+
"""Create a manager backed by the given settings file path."""
|
|
20
|
+
self._path = config_path
|
|
21
|
+
|
|
22
|
+
# ------------------------------------------------------------------
|
|
23
|
+
# Generic key/value accessors
|
|
24
|
+
# ------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def load_value(self, key, default=None):
|
|
27
|
+
"""Return a saved value for key, or default if it is missing or unreadable."""
|
|
28
|
+
try:
|
|
29
|
+
data = json.loads(self._path.read_text(encoding='utf-8'))
|
|
30
|
+
return data.get(key, default)
|
|
31
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
32
|
+
log_exception(f"loading setting {key!r} from {self._path}")
|
|
33
|
+
return default
|
|
34
|
+
|
|
35
|
+
def save_value(self, key, value):
|
|
36
|
+
"""Persist a single setting value while preserving other saved settings."""
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(self._path.read_text(encoding='utf-8'))
|
|
39
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
40
|
+
log_exception(f"reading settings before saving {key!r} to {self._path}")
|
|
41
|
+
data = {}
|
|
42
|
+
data[key] = value
|
|
43
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
self._path.write_text(json.dumps(data, indent=2), encoding='utf-8')
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Typed accessors
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def get_recent_files(self):
|
|
51
|
+
"""Return saved recent GEDCOM file paths, discarding non-string entries."""
|
|
52
|
+
raw = self.load_value('recent_files', [])
|
|
53
|
+
return [p for p in raw if isinstance(p, str)]
|
|
54
|
+
|
|
55
|
+
def set_recent_files(self, files):
|
|
56
|
+
"""Save the ordered list of recent GEDCOM file paths."""
|
|
57
|
+
self.save_value('recent_files', files)
|
|
58
|
+
|
|
59
|
+
def get_home_person(self, gedcom_path):
|
|
60
|
+
"""Return the saved home person ID for a GEDCOM path, if one exists."""
|
|
61
|
+
return self.load_value('home_persons', {}).get(gedcom_path)
|
|
62
|
+
|
|
63
|
+
def set_home_person(self, gedcom_path, indi_id):
|
|
64
|
+
"""Save the home person ID associated with a GEDCOM path."""
|
|
65
|
+
home_persons = self.load_value('home_persons', {})
|
|
66
|
+
home_persons[gedcom_path] = indi_id
|
|
67
|
+
self.save_value('home_persons', home_persons)
|
|
68
|
+
|
|
69
|
+
def get_font_preference(self, valid_sizes):
|
|
70
|
+
"""Return a saved font size preference, falling back to medium if invalid."""
|
|
71
|
+
pref = self.load_value('font_size', 'medium')
|
|
72
|
+
return pref if pref in valid_sizes else 'medium'
|
|
73
|
+
|
|
74
|
+
def set_font_preference(self, size_name):
|
|
75
|
+
"""Save the selected font size preference name."""
|
|
76
|
+
self.save_value('font_size', size_name)
|
|
77
|
+
|
|
78
|
+
def get_theme_preference(self, valid_themes):
|
|
79
|
+
"""Return a saved theme preference, falling back to System if invalid."""
|
|
80
|
+
pref = self.load_value('theme', 'System')
|
|
81
|
+
return pref if pref in valid_themes else 'System'
|
|
82
|
+
|
|
83
|
+
def set_theme_preference(self, theme_name):
|
|
84
|
+
"""Save the selected theme preference name."""
|
|
85
|
+
self.save_value('theme', theme_name)
|
|
86
|
+
|
|
87
|
+
def get_window_geometry(self, key):
|
|
88
|
+
"""Return saved window geometry for the given key, if present."""
|
|
89
|
+
return self.load_value(key)
|
|
90
|
+
|
|
91
|
+
def set_window_geometry(self, key, geometry):
|
|
92
|
+
"""Save window geometry under the given settings key."""
|
|
93
|
+
self.save_value(key, geometry)
|
|
94
|
+
|
|
95
|
+
def get_top_n(self, default=3):
|
|
96
|
+
"""Return the positive number of closest matches to show."""
|
|
97
|
+
val = self.load_value('top_n', default)
|
|
98
|
+
try:
|
|
99
|
+
return max(1, int(val))
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
return default
|
|
102
|
+
|
|
103
|
+
def set_top_n(self, value):
|
|
104
|
+
"""Save the number of closest matches to show."""
|
|
105
|
+
self.save_value('top_n', int(value))
|
|
106
|
+
|
|
107
|
+
def get_max_depth(self, default=10):
|
|
108
|
+
"""Return the positive maximum ancestor search depth."""
|
|
109
|
+
val = self.load_value('max_depth', default)
|
|
110
|
+
try:
|
|
111
|
+
return max(1, int(val))
|
|
112
|
+
except (TypeError, ValueError):
|
|
113
|
+
return default
|
|
114
|
+
|
|
115
|
+
def set_max_depth(self, value):
|
|
116
|
+
"""Save the maximum ancestor search depth."""
|
|
117
|
+
self.save_value('max_depth', int(value))
|
|
118
|
+
|
|
119
|
+
def get_fuzzy_threshold(self, default=0.72):
|
|
120
|
+
"""Return the fuzzy match threshold clamped to the inclusive range 0.0 to 1.0."""
|
|
121
|
+
val = self.load_value('fuzzy_threshold', default)
|
|
122
|
+
try:
|
|
123
|
+
return min(1.0, max(0.0, float(val)))
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
return default
|
|
126
|
+
|
|
127
|
+
def set_fuzzy_threshold(self, value):
|
|
128
|
+
"""Save the fuzzy match threshold as a floating-point value."""
|
|
129
|
+
self.save_value('fuzzy_threshold', float(value))
|
|
130
|
+
|
|
131
|
+
def get_max_display(self, default=2000):
|
|
132
|
+
"""Return the maximum number of people shown in the search results list."""
|
|
133
|
+
val = self.load_value('max_display', default)
|
|
134
|
+
try:
|
|
135
|
+
return max(1, int(val))
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return default
|
|
138
|
+
|
|
139
|
+
def set_max_display(self, value):
|
|
140
|
+
"""Save the maximum number of people shown in the search results list."""
|
|
141
|
+
self.save_value('max_display', int(value))
|
|
142
|
+
|
|
143
|
+
def get_show_ids(self):
|
|
144
|
+
"""Return whether individual IDs should be shown in the UI."""
|
|
145
|
+
return bool(self.load_value('show_ids', False))
|
|
146
|
+
|
|
147
|
+
def set_show_ids(self, value):
|
|
148
|
+
"""Save whether individual IDs should be shown in the UI."""
|
|
149
|
+
self.save_value('show_ids', bool(value))
|
|
150
|
+
|
|
151
|
+
def get_show_full_gedcom(self):
|
|
152
|
+
"""Return whether the Full GEDCOM Record section should appear in the Profile window."""
|
|
153
|
+
return bool(self.load_value('show_full_gedcom', False))
|
|
154
|
+
|
|
155
|
+
def set_show_full_gedcom(self, value):
|
|
156
|
+
"""Save whether the Full GEDCOM Record section should appear in the Profile window."""
|
|
157
|
+
self.save_value('show_full_gedcom', bool(value))
|
|
158
|
+
|
|
159
|
+
def get_name_order(self):
|
|
160
|
+
"""Return the saved display name order, defaulting to first-name first."""
|
|
161
|
+
val = self.load_value('name_order', 'first_last')
|
|
162
|
+
return val if val in ('first_last', 'last_first') else 'first_last'
|
|
163
|
+
|
|
164
|
+
def set_name_order(self, value):
|
|
165
|
+
"""Save the display name order preference."""
|
|
166
|
+
self.save_value('name_order', value)
|
|
167
|
+
|
|
168
|
+
def get_hide_tooltips(self):
|
|
169
|
+
"""Return whether tooltips should be suppressed."""
|
|
170
|
+
return bool(self.load_value('hide_tooltips', False))
|
|
171
|
+
|
|
172
|
+
def set_hide_tooltips(self, value):
|
|
173
|
+
"""Save whether tooltips should be suppressed."""
|
|
174
|
+
self.save_value('hide_tooltips', bool(value))
|
|
175
|
+
|
|
176
|
+
def get_default_display(self):
|
|
177
|
+
"""Return the startup Display Pane mode."""
|
|
178
|
+
val = self.load_value('default_display', 'profile')
|
|
179
|
+
return val if val in ('profile', 'matches', 'paths') else 'profile'
|
|
180
|
+
|
|
181
|
+
def set_default_display(self, value):
|
|
182
|
+
"""Save the startup Display Pane mode."""
|
|
183
|
+
self.save_value('default_display', value)
|
|
184
|
+
|
|
185
|
+
def get_tag_keyword(self, default="DNA"):
|
|
186
|
+
"""Return the saved DNA tag keyword."""
|
|
187
|
+
val = self.load_value('tag_keyword', default)
|
|
188
|
+
return val if isinstance(val, str) else default
|
|
189
|
+
|
|
190
|
+
def set_tag_keyword(self, value):
|
|
191
|
+
"""Save the DNA tag keyword."""
|
|
192
|
+
self.save_value('tag_keyword', str(value))
|
|
193
|
+
|
|
194
|
+
def get_page_marker(self, default="AncestryDNA Match"):
|
|
195
|
+
"""Return the saved DNA page marker keyword."""
|
|
196
|
+
val = self.load_value('page_marker', default)
|
|
197
|
+
return val if isinstance(val, str) else default
|
|
198
|
+
|
|
199
|
+
def set_page_marker(self, value):
|
|
200
|
+
"""Save the DNA page marker keyword."""
|
|
201
|
+
self.save_value('page_marker', str(value))
|
|
202
|
+
|
|
203
|
+
def get_language(self):
|
|
204
|
+
"""Return the saved language code (e.g., 'en', 'fr') or 'sys' for system default."""
|
|
205
|
+
return self.load_value('language', 'sys')
|
|
206
|
+
|
|
207
|
+
def set_language(self, lang_code):
|
|
208
|
+
"""Save the selected language code."""
|
|
209
|
+
self.save_value('language', lang_code)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Platform default path
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def default_path():
|
|
217
|
+
"""Return the platform-specific default settings.json path."""
|
|
218
|
+
if sys.platform == 'win32':
|
|
219
|
+
import os
|
|
220
|
+
base = Path(os.environ.get('APPDATA', Path.home()))
|
|
221
|
+
elif sys.platform == 'darwin':
|
|
222
|
+
base = Path.home() / 'Library' / 'Application Support'
|
|
223
|
+
else:
|
|
224
|
+
import os
|
|
225
|
+
base = Path(os.environ.get(
|
|
226
|
+
'XDG_CONFIG_HOME', Path.home() / '.config'))
|
|
227
|
+
return base / 'gedcom-navigator' / 'settings.json'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gedcom_core.py
|
|
4
|
+
|
|
5
|
+
Compatibility facade for the GEDCOM parser, display formatting, and graph search
|
|
6
|
+
modules used by the CLI and GUI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from gedcom_display import describe, lifespan
|
|
10
|
+
from gedcom_parser import (
|
|
11
|
+
LINE_RE,
|
|
12
|
+
ZIP_MAX_BYTES,
|
|
13
|
+
apply_dna_flags,
|
|
14
|
+
build_model,
|
|
15
|
+
extract_ged_from_zip,
|
|
16
|
+
extract_year,
|
|
17
|
+
iter_records,
|
|
18
|
+
iter_records_checked,
|
|
19
|
+
)
|
|
20
|
+
from gedcom_search import (
|
|
21
|
+
SearchCancelled,
|
|
22
|
+
bfs_find_all_paths,
|
|
23
|
+
bfs_find_dna_matches,
|
|
24
|
+
neighbors,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
'LINE_RE',
|
|
29
|
+
'SearchCancelled',
|
|
30
|
+
'ZIP_MAX_BYTES',
|
|
31
|
+
'apply_dna_flags',
|
|
32
|
+
'bfs_find_all_paths',
|
|
33
|
+
'bfs_find_dna_matches',
|
|
34
|
+
'build_model',
|
|
35
|
+
'describe',
|
|
36
|
+
'extract_ged_from_zip',
|
|
37
|
+
'extract_year',
|
|
38
|
+
'iter_records',
|
|
39
|
+
'iter_records_checked',
|
|
40
|
+
'lifespan',
|
|
41
|
+
'neighbors',
|
|
42
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gedcom_data_model.py
|
|
4
|
+
|
|
5
|
+
Data layer: GEDCOM loading, JSON caching, and BFS search.
|
|
6
|
+
Isolated from all GUI concerns — no tkinter imports.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from gedcom_parser import apply_dna_flags, build_model
|
|
15
|
+
from gedcom_relationship import find_common_ancestors
|
|
16
|
+
from gedcom_search import bfs_find_all_paths, bfs_find_dna_matches
|
|
17
|
+
from gedcom_debug import log_exception
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _positive_int(value, name):
|
|
21
|
+
"""Return value as a positive integer or raise ValueError."""
|
|
22
|
+
try:
|
|
23
|
+
parsed = int(value)
|
|
24
|
+
except (TypeError, ValueError) as exc:
|
|
25
|
+
raise ValueError(f"{name} must be a positive integer.") from exc
|
|
26
|
+
if parsed < 1:
|
|
27
|
+
raise ValueError(f"{name} must be a positive integer.")
|
|
28
|
+
return parsed
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GedcomDataModel:
|
|
32
|
+
"""Owns the parsed GEDCOM state and all I/O against it."""
|
|
33
|
+
|
|
34
|
+
# Bump this whenever the cached individual/family schema changes so that
|
|
35
|
+
# stale cache files are automatically discarded and reparsed.
|
|
36
|
+
_CACHE_VERSION = 6
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.individuals = {}
|
|
40
|
+
self.families = {}
|
|
41
|
+
self.tag_records = {}
|
|
42
|
+
self.married_name_index = {}
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Public API
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def load(self, gedcom_path, dna_keyword, page_marker, cache_dir):
|
|
49
|
+
"""Parse (or restore from cache) a GEDCOM file.
|
|
50
|
+
|
|
51
|
+
Returns (from_cache, encoding_warning, model_error).
|
|
52
|
+
encoding_warning is a string or None; always None when loaded from cache.
|
|
53
|
+
model_error is a string or None when parsing did not produce a usable model.
|
|
54
|
+
"""
|
|
55
|
+
cached = self._load_from_cache(gedcom_path, cache_dir)
|
|
56
|
+
if cached is not None:
|
|
57
|
+
self.individuals, self.families, self.tag_records = cached
|
|
58
|
+
apply_dna_flags(self.individuals, self.tag_records, dna_keyword, page_marker)
|
|
59
|
+
self.married_name_index = self._build_married_name_index()
|
|
60
|
+
return True, None, None
|
|
61
|
+
|
|
62
|
+
(
|
|
63
|
+
self.individuals,
|
|
64
|
+
self.families,
|
|
65
|
+
self.tag_records,
|
|
66
|
+
warning,
|
|
67
|
+
model_error,
|
|
68
|
+
) = build_model(
|
|
69
|
+
gedcom_path,
|
|
70
|
+
dna_keyword=dna_keyword,
|
|
71
|
+
page_marker=page_marker,
|
|
72
|
+
)
|
|
73
|
+
if model_error:
|
|
74
|
+
self.individuals = {}
|
|
75
|
+
self.families = {}
|
|
76
|
+
self.tag_records = {}
|
|
77
|
+
self.married_name_index = {}
|
|
78
|
+
return False, warning, model_error
|
|
79
|
+
self.married_name_index = self._build_married_name_index()
|
|
80
|
+
self._save_to_cache(gedcom_path, cache_dir)
|
|
81
|
+
return False, warning, None
|
|
82
|
+
|
|
83
|
+
def reflag(self, dna_keyword, page_marker):
|
|
84
|
+
"""Re-apply DNA flags in-place without re-parsing or touching the cache."""
|
|
85
|
+
apply_dna_flags(self.individuals, self.tag_records, dna_keyword, page_marker)
|
|
86
|
+
|
|
87
|
+
def find_dna_matches(self, start_id, top_n, max_depth, cancel_event=None):
|
|
88
|
+
"""Find the nearest DNA-flagged people to a given individual."""
|
|
89
|
+
top_n = _positive_int(top_n, "top_n")
|
|
90
|
+
max_depth = _positive_int(max_depth, "max_depth")
|
|
91
|
+
return bfs_find_dna_matches(
|
|
92
|
+
start_id, self.individuals, self.families,
|
|
93
|
+
top_n=top_n, max_depth=max_depth, cancel_event=cancel_event,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def find_all_paths(self, start_id, end_id, top_n, max_depth, cancel_event=None):
|
|
97
|
+
"""Find up to top_n relationship paths between two individuals."""
|
|
98
|
+
top_n = _positive_int(top_n, "top_n")
|
|
99
|
+
max_depth = _positive_int(max_depth, "max_depth")
|
|
100
|
+
return bfs_find_all_paths(
|
|
101
|
+
start_id, end_id, self.individuals, self.families,
|
|
102
|
+
top_n=top_n, max_depth=max_depth, cancel_event=cancel_event,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def find_common_ancestors(self, start_id, end_id):
|
|
106
|
+
"""Find nearest common biological ancestors between two individuals."""
|
|
107
|
+
return find_common_ancestors(
|
|
108
|
+
start_id, end_id, self.individuals, self.families)
|
|
109
|
+
|
|
110
|
+
def clear_cache(self, cache_dir):
|
|
111
|
+
"""Delete all .json cache files under cache_dir. Returns count deleted."""
|
|
112
|
+
deleted = 0
|
|
113
|
+
try:
|
|
114
|
+
for f in Path(cache_dir).glob('*.json'):
|
|
115
|
+
try:
|
|
116
|
+
f.unlink()
|
|
117
|
+
deleted += 1
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
121
|
+
log_exception(f"clearing cache directory {cache_dir!r}")
|
|
122
|
+
pass
|
|
123
|
+
return deleted
|
|
124
|
+
|
|
125
|
+
def _build_married_name_index(self):
|
|
126
|
+
"""Return derived married-name aliases keyed by wife individual ID."""
|
|
127
|
+
index = {}
|
|
128
|
+
for fam in self.families.values():
|
|
129
|
+
wife_id = fam.get('wife')
|
|
130
|
+
husb_id = fam.get('husb')
|
|
131
|
+
if not wife_id or not husb_id:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
wife = self.individuals.get(wife_id)
|
|
135
|
+
husband = self.individuals.get(husb_id)
|
|
136
|
+
if not wife or not husband:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
given_name = wife.get('given_name', '').strip()
|
|
140
|
+
husband_surname = husband.get('surname', '').strip()
|
|
141
|
+
if not given_name or not husband_surname:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
married_name = f"{given_name} {husband_surname}".strip()
|
|
145
|
+
if not married_name:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
existing_names = set(wife.get('alt_names') or [wife.get('name', '')])
|
|
149
|
+
existing_names.update(index.get(wife_id, ()))
|
|
150
|
+
if married_name not in existing_names:
|
|
151
|
+
index.setdefault(wife_id, []).append(married_name)
|
|
152
|
+
|
|
153
|
+
return index
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Cache internals
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _cache_path(gedcom_path, cache_dir):
|
|
161
|
+
key = os.path.normcase(os.path.abspath(gedcom_path)).encode()
|
|
162
|
+
return Path(cache_dir) / (hashlib.md5(key).hexdigest() + '.json')
|
|
163
|
+
|
|
164
|
+
def _load_from_cache(self, gedcom_path, cache_dir):
|
|
165
|
+
try:
|
|
166
|
+
cache_file = self._cache_path(gedcom_path, cache_dir)
|
|
167
|
+
if not cache_file.exists():
|
|
168
|
+
return None
|
|
169
|
+
file_mtime = os.path.getmtime(gedcom_path)
|
|
170
|
+
with cache_file.open('r', encoding='utf-8') as f:
|
|
171
|
+
data = json.load(f)
|
|
172
|
+
if (data.get('cache_version') != self._CACHE_VERSION
|
|
173
|
+
or data.get('mtime') != file_mtime):
|
|
174
|
+
return None
|
|
175
|
+
if not data.get('individuals'):
|
|
176
|
+
return None
|
|
177
|
+
return data['individuals'], data['families'], data['tag_records']
|
|
178
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
179
|
+
log_exception(f"loading parse cache for {gedcom_path!r}")
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def _save_to_cache(self, gedcom_path, cache_dir):
|
|
183
|
+
try:
|
|
184
|
+
cache_dir_path = Path(cache_dir)
|
|
185
|
+
cache_dir_path.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
cache_file = self._cache_path(gedcom_path, cache_dir_path)
|
|
187
|
+
payload = {
|
|
188
|
+
'cache_version': self._CACHE_VERSION,
|
|
189
|
+
'mtime': os.path.getmtime(gedcom_path),
|
|
190
|
+
'individuals': self.individuals,
|
|
191
|
+
'families': self.families,
|
|
192
|
+
'tag_records': self.tag_records,
|
|
193
|
+
}
|
|
194
|
+
tmp = cache_file.with_suffix('.tmp')
|
|
195
|
+
with tmp.open('w', encoding='utf-8') as f:
|
|
196
|
+
json.dump(payload, f)
|
|
197
|
+
tmp.replace(cache_file)
|
|
198
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
199
|
+
log_exception(f"saving parse cache for {gedcom_path!r}")
|
|
200
|
+
pass
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gedcom_debug.py
|
|
4
|
+
|
|
5
|
+
Debug-only exception logging and process-wide exception hooks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from logging.handlers import RotatingFileHandler
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_DEBUG_ENV = 'GEDCOM_NAVIGATOR_DEBUG'
|
|
18
|
+
_LOG_PATH_ENV = 'GEDCOM_NAVIGATOR_DEBUG_LOG'
|
|
19
|
+
_LOGGER_NAME = 'gedcom_navigator'
|
|
20
|
+
_MAX_LOG_BYTES = 1_000_000
|
|
21
|
+
_BACKUP_COUNT = 3
|
|
22
|
+
|
|
23
|
+
_configured_path = None
|
|
24
|
+
_previous_sys_excepthook = None
|
|
25
|
+
_previous_threading_excepthook = None
|
|
26
|
+
_previous_tk_report_callback_exception = None
|
|
27
|
+
_logged_exception_keys = set()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _getenv(name, default=None):
|
|
31
|
+
"""Return an environment value using case-insensitive lookup on all platforms."""
|
|
32
|
+
value = os.environ.get(name)
|
|
33
|
+
if value is not None:
|
|
34
|
+
return value
|
|
35
|
+
name_upper = name.upper()
|
|
36
|
+
for key, candidate in os.environ.items():
|
|
37
|
+
if key.upper() == name_upper:
|
|
38
|
+
return candidate
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def debug_enabled():
|
|
43
|
+
"""Return whether debug diagnostics are enabled for this process."""
|
|
44
|
+
return _getenv(_DEBUG_ENV, '').strip().lower() in {
|
|
45
|
+
'1', 'true', 'yes', 'on'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_debug_enabled(enabled=True):
|
|
50
|
+
"""Set or clear the environment flag used by all debug-only helpers."""
|
|
51
|
+
if enabled:
|
|
52
|
+
os.environ[_DEBUG_ENV] = '1'
|
|
53
|
+
else:
|
|
54
|
+
os.environ.pop(_DEBUG_ENV, None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _default_debug_log_path():
|
|
58
|
+
if sys.platform == 'win32':
|
|
59
|
+
base = Path(os.environ.get('APPDATA', Path.home()))
|
|
60
|
+
elif sys.platform == 'darwin':
|
|
61
|
+
base = Path.home() / 'Library' / 'Application Support'
|
|
62
|
+
else:
|
|
63
|
+
base = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config'))
|
|
64
|
+
return base / 'gedcom-navigator' / 'debug.log'
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def debug_log_path():
|
|
68
|
+
"""Return the configured debug log path."""
|
|
69
|
+
override = _getenv(_LOG_PATH_ENV)
|
|
70
|
+
if override:
|
|
71
|
+
return Path(override).expanduser()
|
|
72
|
+
return _default_debug_log_path()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_debug_logger():
|
|
76
|
+
"""Return the application debug logger."""
|
|
77
|
+
return logging.getLogger(_LOGGER_NAME)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def configure_debug_logging(*, enabled=None):
|
|
81
|
+
"""Configure rotating file logging when debug mode is enabled."""
|
|
82
|
+
global _configured_path
|
|
83
|
+
|
|
84
|
+
if enabled is not None:
|
|
85
|
+
set_debug_enabled(enabled)
|
|
86
|
+
if not debug_enabled():
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
logger = get_debug_logger()
|
|
90
|
+
logger.setLevel(logging.DEBUG)
|
|
91
|
+
logger.propagate = False
|
|
92
|
+
|
|
93
|
+
if _configured_path is not None:
|
|
94
|
+
return _configured_path
|
|
95
|
+
|
|
96
|
+
path = debug_log_path()
|
|
97
|
+
try:
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
handler = RotatingFileHandler(
|
|
100
|
+
path,
|
|
101
|
+
maxBytes=_MAX_LOG_BYTES,
|
|
102
|
+
backupCount=_BACKUP_COUNT,
|
|
103
|
+
encoding='utf-8',
|
|
104
|
+
)
|
|
105
|
+
except OSError:
|
|
106
|
+
fallback_dir = Path(tempfile.gettempdir())
|
|
107
|
+
path = fallback_dir / 'gedcom-navigator-debug.log'
|
|
108
|
+
fallback_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
handler = RotatingFileHandler(
|
|
110
|
+
path,
|
|
111
|
+
maxBytes=_MAX_LOG_BYTES,
|
|
112
|
+
backupCount=_BACKUP_COUNT,
|
|
113
|
+
encoding='utf-8',
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
handler.setLevel(logging.DEBUG)
|
|
117
|
+
handler.setFormatter(logging.Formatter(
|
|
118
|
+
'%(asctime)s %(levelname)s [%(threadName)s] %(name)s: %(message)s'
|
|
119
|
+
))
|
|
120
|
+
logger.addHandler(handler)
|
|
121
|
+
_configured_path = path
|
|
122
|
+
logger.debug(
|
|
123
|
+
'Debug logging enabled: executable=%r argv=%r log_path=%s',
|
|
124
|
+
sys.executable,
|
|
125
|
+
sys.argv,
|
|
126
|
+
path,
|
|
127
|
+
)
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def log_debug(message, *args, **kwargs):
|
|
132
|
+
"""Write a debug message if diagnostics are enabled."""
|
|
133
|
+
if not debug_enabled():
|
|
134
|
+
return
|
|
135
|
+
configure_debug_logging()
|
|
136
|
+
get_debug_logger().debug(message, *args, **kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def log_exception(context):
|
|
140
|
+
"""Log the active exception if diagnostics are enabled."""
|
|
141
|
+
if not debug_enabled():
|
|
142
|
+
return
|
|
143
|
+
configure_debug_logging()
|
|
144
|
+
get_debug_logger().debug('Recovered exception: %s', context, exc_info=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def log_exception_once(key, context):
|
|
148
|
+
"""Log the active exception once per key if diagnostics are enabled."""
|
|
149
|
+
if key in _logged_exception_keys:
|
|
150
|
+
return
|
|
151
|
+
_logged_exception_keys.add(key)
|
|
152
|
+
log_exception(context)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _log_unhandled_exception(context, exc_info):
|
|
156
|
+
configure_debug_logging()
|
|
157
|
+
get_debug_logger().critical(context, exc_info=exc_info)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def install_exception_hooks(root=None):
|
|
161
|
+
"""Install process-wide debug hooks for uncaught exceptions."""
|
|
162
|
+
global _previous_sys_excepthook
|
|
163
|
+
global _previous_threading_excepthook
|
|
164
|
+
global _previous_tk_report_callback_exception
|
|
165
|
+
|
|
166
|
+
if not debug_enabled():
|
|
167
|
+
return
|
|
168
|
+
configure_debug_logging()
|
|
169
|
+
|
|
170
|
+
if _previous_sys_excepthook is None:
|
|
171
|
+
_previous_sys_excepthook = sys.excepthook
|
|
172
|
+
|
|
173
|
+
def _sys_excepthook(exc_type, exc_value, traceback):
|
|
174
|
+
_log_unhandled_exception(
|
|
175
|
+
'Unhandled main-thread exception',
|
|
176
|
+
(exc_type, exc_value, traceback),
|
|
177
|
+
)
|
|
178
|
+
_previous_sys_excepthook(exc_type, exc_value, traceback)
|
|
179
|
+
|
|
180
|
+
sys.excepthook = _sys_excepthook
|
|
181
|
+
|
|
182
|
+
if (_previous_threading_excepthook is None
|
|
183
|
+
and hasattr(threading, 'excepthook')):
|
|
184
|
+
_previous_threading_excepthook = threading.excepthook
|
|
185
|
+
|
|
186
|
+
def _threading_excepthook(args):
|
|
187
|
+
_log_unhandled_exception(
|
|
188
|
+
f'Unhandled thread exception in {args.thread!r}',
|
|
189
|
+
(args.exc_type, args.exc_value, args.exc_traceback),
|
|
190
|
+
)
|
|
191
|
+
_previous_threading_excepthook(args)
|
|
192
|
+
|
|
193
|
+
threading.excepthook = _threading_excepthook
|
|
194
|
+
|
|
195
|
+
if root is not None and _previous_tk_report_callback_exception is None:
|
|
196
|
+
_previous_tk_report_callback_exception = root.report_callback_exception
|
|
197
|
+
|
|
198
|
+
def _report_callback_exception(exc_type, exc_value, traceback):
|
|
199
|
+
_log_unhandled_exception(
|
|
200
|
+
'Unhandled Tk callback exception',
|
|
201
|
+
(exc_type, exc_value, traceback),
|
|
202
|
+
)
|
|
203
|
+
_previous_tk_report_callback_exception(
|
|
204
|
+
exc_type, exc_value, traceback)
|
|
205
|
+
|
|
206
|
+
root.report_callback_exception = _report_callback_exception
|