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.
Files changed (75) hide show
  1. gedcom_navigator/__init__.py +3 -0
  2. gedcom_navigator/_scripts/gedcom_config.py +227 -0
  3. gedcom_navigator/_scripts/gedcom_core.py +42 -0
  4. gedcom_navigator/_scripts/gedcom_data_model.py +200 -0
  5. gedcom_navigator/_scripts/gedcom_debug.py +206 -0
  6. gedcom_navigator/_scripts/gedcom_display.py +26 -0
  7. gedcom_navigator/_scripts/gedcom_family_tree.py +871 -0
  8. gedcom_navigator/_scripts/gedcom_graph_export.py +510 -0
  9. gedcom_navigator/_scripts/gedcom_gui_appearance.py +821 -0
  10. gedcom_navigator/_scripts/gedcom_gui_background.py +218 -0
  11. gedcom_navigator/_scripts/gedcom_gui_dialogs.py +999 -0
  12. gedcom_navigator/_scripts/gedcom_gui_family_tree_render.py +490 -0
  13. gedcom_navigator/_scripts/gedcom_gui_graph_common.py +876 -0
  14. gedcom_navigator/_scripts/gedcom_gui_graph_layout.py +1708 -0
  15. gedcom_navigator/_scripts/gedcom_gui_graph_render.py +14 -0
  16. gedcom_navigator/_scripts/gedcom_gui_help_dialogs.py +344 -0
  17. gedcom_navigator/_scripts/gedcom_gui_path_graph.py +874 -0
  18. gedcom_navigator/_scripts/gedcom_gui_person_dialog.py +965 -0
  19. gedcom_navigator/_scripts/gedcom_gui_results.py +689 -0
  20. gedcom_navigator/_scripts/gedcom_gui_search.py +694 -0
  21. gedcom_navigator/_scripts/gedcom_i18n.py +96 -0
  22. gedcom_navigator/_scripts/gedcom_markdown.py +289 -0
  23. gedcom_navigator/_scripts/gedcom_name_search.py +149 -0
  24. gedcom_navigator/_scripts/gedcom_navigator_cli.py +290 -0
  25. gedcom_navigator/_scripts/gedcom_navigator_gui.py +857 -0
  26. gedcom_navigator/_scripts/gedcom_parser.py +406 -0
  27. gedcom_navigator/_scripts/gedcom_platform.py +24 -0
  28. gedcom_navigator/_scripts/gedcom_relationship.py +868 -0
  29. gedcom_navigator/_scripts/gedcom_search.py +985 -0
  30. gedcom_navigator/_scripts/gedcom_shortcuts.py +135 -0
  31. gedcom_navigator/_scripts/gedcom_strings.py +542 -0
  32. gedcom_navigator/_scripts/gedcom_theme.py +75 -0
  33. gedcom_navigator/_scripts/gedcom_tooltip.py +362 -0
  34. gedcom_navigator/_scripts/gedcom_transliteration.py +232 -0
  35. gedcom_navigator/_scripts/gedcom_update.py +108 -0
  36. gedcom_navigator/_scripts/gedcom_zoom.py +87 -0
  37. gedcom_navigator/cli.py +40 -0
  38. gedcom_navigator/docs/CHANGELOG.md +453 -0
  39. gedcom_navigator/docs/DEVELOPMENT.md +138 -0
  40. gedcom_navigator/docs/HELP.md +52 -0
  41. gedcom_navigator/docs/KEYBOARD_SHORTCUTS.md +36 -0
  42. gedcom_navigator/docs/LICENSE.md +13 -0
  43. gedcom_navigator/docs/LINUX_ISSUES.md +52 -0
  44. gedcom_navigator/docs/MAC_SECURITY.md +6 -0
  45. gedcom_navigator/docs/OLD-MAC-SECURITY.md +13 -0
  46. gedcom_navigator/docs/PRIVACY_POLICY.md +63 -0
  47. gedcom_navigator/docs/README-translate-po-deepl.md +45 -0
  48. gedcom_navigator/docs/README.md +68 -0
  49. gedcom_navigator/docs/TECHNICAL.md +229 -0
  50. gedcom_navigator/docs/TODO.md +3 -0
  51. gedcom_navigator/docs/WINDOWS_SECURITY.md +6 -0
  52. gedcom_navigator/docs/screenshots/App Store Screen Recording.mp4 +0 -0
  53. gedcom_navigator/docs/screenshots/App Store Screenshot.png +0 -0
  54. gedcom_navigator/docs/screenshots/GEDCOM Navigator Windows Screenshot.png +0 -0
  55. gedcom_navigator/docs/screenshots/downloaded-from-internet.png +0 -0
  56. gedcom_navigator/docs/screenshots/gedcom-navigator-screenshot-for-app-store.png +0 -0
  57. gedcom_navigator/docs/screenshots/main-window.png +0 -0
  58. gedcom_navigator/docs/screenshots/old_screen_recording.gif +0 -0
  59. gedcom_navigator/docs/screenshots/open_anyway.png +0 -0
  60. gedcom_navigator/docs/screenshots/relationship-path.png +0 -0
  61. gedcom_navigator/docs/screenshots/results-pane.png +0 -0
  62. gedcom_navigator/docs/screenshots/screen_recording.gif +0 -0
  63. gedcom_navigator/docs/screenshots/screen_recording.mp4 +0 -0
  64. gedcom_navigator/docs/screenshots/windows-security.png +0 -0
  65. gedcom_navigator/docs/transliterated-name-search-plan.md +61 -0
  66. gedcom_navigator/gui.py +43 -0
  67. gedcom_navigator/icons/family_tree-old.png +0 -0
  68. gedcom_navigator/icons/family_tree.ico +0 -0
  69. gedcom_navigator/icons/family_tree.png +0 -0
  70. gedcom_navigator/icons/family_tree_appstore.pdn +2553 -14
  71. gedcom_navigator/icons/family_tree_appstore.png +0 -0
  72. gedcom_navigator-1.9.4.dist-info/METADATA +102 -0
  73. gedcom_navigator-1.9.4.dist-info/RECORD +75 -0
  74. gedcom_navigator-1.9.4.dist-info/WHEEL +4 -0
  75. gedcom_navigator-1.9.4.dist-info/entry_points.txt +5 -0
@@ -0,0 +1,3 @@
1
+ """GEDCOM Navigator — powerful tool to explore large GEDCOM family tree files."""
2
+ __version__ = "1.9.4"
3
+ __release_date__ = "2026-05-25"
@@ -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