epstein-files 1.1.5__py3-none-any.whl → 1.2.0__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.
@@ -0,0 +1,350 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime, date
3
+
4
+ from rich.console import Group, RenderableType
5
+ from rich.padding import Padding
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from epstein_files.documents.document import Document
11
+ from epstein_files.documents.email import MAILING_LISTS, JUNK_EMAILERS, Email
12
+ from epstein_files.documents.messenger_log import MessengerLog
13
+ from epstein_files.documents.other_file import OtherFile
14
+ from epstein_files.util.constant.strings import *
15
+ from epstein_files.util.constant.urls import *
16
+ from epstein_files.util.constants import *
17
+ from epstein_files.util.data import days_between, flatten, without_falsey
18
+ from epstein_files.util.env import args
19
+ from epstein_files.util.highlighted_group import (QUESTION_MARKS_TXT, HighlightedNames,
20
+ get_highlight_group_for_name, get_style_for_name, styled_category, styled_name)
21
+ from epstein_files.util.rich import GREY_NUMBERS, LAST_TIMESTAMP_STYLE, TABLE_TITLE_STYLE, build_table, console, join_texts, print_centered
22
+
23
+ ALT_INFO_STYLE = 'medium_purple4'
24
+ CC = 'cc:'
25
+ MIN_AUTHOR_PANEL_WIDTH = 80
26
+ EMAILER_INFO_TITLE = 'Email Conversations Will Appear'
27
+ UNINTERESTING_CC_INFO = "CC: or BCC: recipient only"
28
+
29
+ INVALID_FOR_EPSTEIN_WEB = JUNK_EMAILERS + MAILING_LISTS + [
30
+ 'ACT for America',
31
+ 'BS Stern',
32
+ UNKNOWN,
33
+ ]
34
+
35
+
36
+ @dataclass(kw_only=True)
37
+ class Person:
38
+ """Collection of data about someone texting or emailing Epstein."""
39
+ name: Name
40
+ emails: list[Email] = field(default_factory=list)
41
+ imessage_logs: list[MessengerLog] = field(default_factory=list)
42
+ other_files: list[OtherFile] = field(default_factory=list)
43
+ is_uninteresting_cc: bool = False
44
+
45
+ def __post_init__(self):
46
+ self.emails = Document.sort_by_timestamp(self.emails)
47
+ self.imessage_logs = Document.sort_by_timestamp(self.imessage_logs)
48
+
49
+ def category(self) -> str | None:
50
+ highlight_group = self.highlight_group()
51
+
52
+ if highlight_group and isinstance(highlight_group, HighlightedNames):
53
+ category = highlight_group.category or highlight_group.label
54
+
55
+ if category != self.name and category != 'paula': # TODO: this sucks
56
+ return category
57
+
58
+ def category_txt(self) -> Text | None:
59
+ if self.name is None:
60
+ return None
61
+ elif self.category():
62
+ return styled_category(self.category())
63
+ elif self.is_a_mystery() or self.is_uninteresting_cc:
64
+ return QUESTION_MARKS_TXT
65
+
66
+ def email_conversation_length_in_days(self) -> int:
67
+ return days_between(self.emails[0].timestamp, self.emails[-1].timestamp)
68
+
69
+ def earliest_email_at(self) -> datetime:
70
+ return self.emails[0].timestamp
71
+
72
+ def earliest_email_date(self) -> date:
73
+ return self.earliest_email_at().date()
74
+
75
+ def last_email_at(self) -> datetime:
76
+ return self.emails[-1].timestamp
77
+
78
+ def last_email_date(self) -> date:
79
+ return self.last_email_at().date()
80
+
81
+ def emails_by(self) -> list[Email]:
82
+ return [e for e in self.emails if self.name == e.author]
83
+
84
+ def emails_to(self) -> list[Email]:
85
+ return [
86
+ e for e in self.emails
87
+ if self.name in e.recipients or (self.name is None and len(e.recipients) == 0)
88
+ ]
89
+
90
+ def external_link(self, site: ExternalSite = EPSTEINIFY) -> str:
91
+ return PERSON_LINK_BUILDERS[site](self.name_str())
92
+
93
+ def external_link_txt(self, site: ExternalSite = EPSTEINIFY, link_str: str | None = None) -> Text:
94
+ if self.name is None:
95
+ return Text('')
96
+
97
+ return link_text_obj(self.external_link(site), link_str or site, style=self.style())
98
+
99
+ def external_links_line(self) -> Text:
100
+ links = [self.external_link_txt(site) for site in PERSON_LINK_BUILDERS]
101
+ return Text('', justify='center', style='dim').append(join_texts(links, join=' / ')) #, encloser='()'))#, encloser='‹›'))
102
+
103
+ def highlight_group(self) -> HighlightedNames | None:
104
+ return get_highlight_group_for_name(self.name)
105
+
106
+ def info_panel(self) -> Padding:
107
+ """Print a panel with the name of an emailer and a few tidbits of information about them."""
108
+ style = 'white' if (not self.style() or self.style() == DEFAULT) else self.style()
109
+ panel_style = f"black on {style} bold"
110
+
111
+ if self.name == JEFFREY_EPSTEIN:
112
+ email_count = len(self._printable_emails())
113
+ title_suffix = f"sent by {JEFFREY_EPSTEIN} to himself"
114
+ else:
115
+ email_count = len(self.unique_emails())
116
+ num_days = self.email_conversation_length_in_days()
117
+ title_suffix = f"to/from {self.name_str()} starting {self.earliest_email_date()} covering {num_days:,} days"
118
+
119
+ title = f"Found {email_count} emails {title_suffix}"
120
+ width = max(MIN_AUTHOR_PANEL_WIDTH, len(title) + 4, len(self.info_with_category()) + 8)
121
+ panel = Panel(Text(title, justify='center'), width=width, style=panel_style)
122
+ elements: list[RenderableType] = [panel]
123
+
124
+ if self.info_with_category():
125
+ elements.append(Text(f"({self.info_with_category()})", justify='center', style=f"{style} italic"))
126
+
127
+ return Padding(Group(*elements), (2, 0, 1, 0))
128
+
129
+ def info_str(self) -> str | None:
130
+ highlight_group = self.highlight_group()
131
+
132
+ if highlight_group and isinstance(highlight_group, HighlightedNames) and self.name:
133
+ return highlight_group.info_for(self.name)
134
+ elif self.is_uninteresting_cc:
135
+ return UNINTERESTING_CC_INFO
136
+
137
+ def info_with_category(self) -> str:
138
+ return ', '.join(without_falsey([self.category(), self.info_str()]))
139
+
140
+ def info_txt(self) -> Text | None:
141
+ if self.name == JEFFREY_EPSTEIN:
142
+ return Text('(emails sent by Epstein to himself are here)', style=ALT_INFO_STYLE)
143
+ elif self.name is None:
144
+ return Text('(emails whose author or recipient could not be determined)', style=ALT_INFO_STYLE)
145
+ elif self.category() == JUNK:
146
+ return Text(f"({JUNK} mail)", style='tan dim')
147
+ elif self.is_a_mystery():
148
+ return Text(QUESTION_MARKS, style='magenta dim')
149
+ elif self.is_uninteresting_cc and self.info_str() == UNINTERESTING_CC_INFO:
150
+ return Text(f"({self.info_str()})", style='wheat4 dim')
151
+ elif self.info_str() is None:
152
+ if self.name in MAILING_LISTS:
153
+ return Text('(mailing list)', style=f"{self.style()} dim")
154
+ else:
155
+ return None
156
+ else:
157
+ return Text(self.info_str())
158
+
159
+ def is_a_mystery(self) -> bool:
160
+ """Return True if this is someone we theroetically could know more about."""
161
+ return self.is_unstyled() and not (self.is_email_address() or self.info_str() or self.is_uninteresting_cc)
162
+
163
+ def is_email_address(self) -> bool:
164
+ return '@' in (self.name or '')
165
+
166
+ def is_linkable(self) -> bool:
167
+ """Return True if it's likely that EpsteinWeb has a page for this name."""
168
+ if self.name is None or ' ' not in self.name:
169
+ return False
170
+ elif self.is_email_address() or '/' in self.name or QUESTION_MARKS in self.name:
171
+ return False
172
+ elif self.name in INVALID_FOR_EPSTEIN_WEB:
173
+ return False
174
+
175
+ return True
176
+
177
+ def is_unstyled(self) -> bool:
178
+ """True if there's no highlight group for this name."""
179
+ return self.style() == DEFAULT_NAME_STYLE
180
+
181
+ def name_str(self) -> str:
182
+ return self.name or UNKNOWN
183
+
184
+ def name_link(self) -> Text:
185
+ """Will only link if it's worth linking, otherwise just a Text object."""
186
+ if not self.is_linkable():
187
+ return self.name_txt()
188
+ else:
189
+ return Text.from_markup(link_markup(self.external_link(), self.name_str(), self.style()))
190
+
191
+ def name_txt(self) -> Text:
192
+ return styled_name(self.name)
193
+
194
+ def print_emails(self) -> list[Email]:
195
+ """Print complete emails to or from a particular 'author'. Returns the Emails that were printed."""
196
+ print_centered(self.info_panel())
197
+ self.print_emails_table()
198
+ last_printed_email_was_duplicate = False
199
+
200
+ if self.category() == JUNK:
201
+ logger.warning(f"Not printing junk emailer '{self.name}'")
202
+ else:
203
+ for email in self._printable_emails():
204
+ if email.is_duplicate():
205
+ console.print(Padding(email.duplicate_file_txt().append('...'), (0, 0, 0, 4)))
206
+ last_printed_email_was_duplicate = True
207
+ else:
208
+ if last_printed_email_was_duplicate:
209
+ console.line()
210
+
211
+ console.print(email)
212
+ last_printed_email_was_duplicate = False
213
+
214
+ return self._printable_emails()
215
+
216
+ def print_emails_table(self) -> None:
217
+ emails = [email for email in self._printable_emails() if not email.is_duplicate()] # Remove dupes
218
+ print_centered(Padding(Email.build_emails_table(emails, self.name), (0, 5, 0, 5)))
219
+
220
+ if self.is_linkable():
221
+ print_centered(self.external_links_line())
222
+
223
+ console.line()
224
+
225
+ def sort_key(self) -> list[int | str]:
226
+ counts = [len(self.unique_emails())]
227
+ counts = [-1 * count for count in counts]
228
+
229
+ if args.sort_alphabetical:
230
+ return [self.name_str()] + counts
231
+ else:
232
+ return counts + [self.name_str()]
233
+
234
+ def style(self) -> str:
235
+ return get_style_for_name(self.name)
236
+
237
+ def unique_emails(self) -> list[Email]:
238
+ return [email for email in self.emails if not email.is_duplicate()]
239
+
240
+ def unique_emails_by(self) -> list[Email]:
241
+ return [email for email in self.emails_by() if not email.is_duplicate()]
242
+
243
+ def unique_emails_to(self) -> list[Email]:
244
+ return [email for email in self.emails_to() if not email.is_duplicate()]
245
+
246
+ def _printable_emails(self):
247
+ """For Epstein we only want to print emails he sent to himself."""
248
+ if self.name == JEFFREY_EPSTEIN:
249
+ return [e for e in self.emails if e.is_note_to_self()]
250
+ else:
251
+ return self.emails
252
+
253
+ def __str__(self):
254
+ return f"{self.name_str()}"
255
+
256
+ @staticmethod
257
+ def emailer_info_table(people: list['Person'], highlighted: list['Person'] | None = None) -> Table:
258
+ """Table of info about emailers."""
259
+ highlighted = highlighted or people
260
+ highlighted_names = [p.name for p in highlighted]
261
+ is_selection = len(people) != len(highlighted) or args.emailers_info_png
262
+
263
+ if is_selection:
264
+ title = Text(f"{EMAILER_INFO_TITLE} in This Order for the Highlighted Names (see ", style=TABLE_TITLE_STYLE)
265
+ title.append(THE_OTHER_PAGE_TXT).append(" for the rest)")
266
+ else:
267
+ title = f"{EMAILER_INFO_TITLE} in Chronological Order Based on Timestamp of First Email"
268
+
269
+ table = build_table(title)
270
+ table.add_column('Start')
271
+ table.add_column('Name', max_width=24, no_wrap=True)
272
+ table.add_column('Category', justify='left', style='dim italic')
273
+ table.add_column('Num', justify='right', style='white')
274
+ table.add_column('Sent', justify='right', style='wheat4')
275
+ table.add_column('Recv', justify='right', style='wheat4')
276
+ table.add_column('Days', justify='right', style=TIMESTAMP_DIM)
277
+ table.add_column('Info', style='white italic')
278
+ current_year = 1990
279
+ current_year_month = current_year * 12
280
+ grey_idx = 0
281
+
282
+ for person in people:
283
+ earliest_email_date = person.earliest_email_date()
284
+ year_months = (earliest_email_date.year * 12) + earliest_email_date.month
285
+
286
+ # Color year rollovers more brightly
287
+ if current_year != earliest_email_date.year:
288
+ grey_idx = 0
289
+ elif current_year_month != year_months:
290
+ grey_idx = ((current_year_month - 1) % 12) + 1
291
+
292
+ current_year_month = year_months
293
+ current_year = earliest_email_date.year
294
+
295
+ table.add_row(
296
+ Text(str(earliest_email_date), style=f"grey{GREY_NUMBERS[0 if is_selection else grey_idx]}"),
297
+ person.name_txt(), # TODO: make link?
298
+ person.category_txt(),
299
+ f"{len(person._printable_emails())}",
300
+ f"{len(person.unique_emails_by())}",
301
+ f"{len(person.unique_emails_to())}",
302
+ f"{person.email_conversation_length_in_days()}",
303
+ person.info_txt() or '',
304
+ style='' if person.name in highlighted_names else 'dim',
305
+ )
306
+
307
+ return table
308
+
309
+ @staticmethod
310
+ def emailer_stats_table(people: list['Person']) -> Table:
311
+ email_authors = [p for p in people if p.emails_by() and p.name]
312
+ all_emails = Document.uniquify(flatten([p.unique_emails() for p in people]))
313
+ attributed_emails = [email for email in all_emails if email.author]
314
+ footer = f"(identified {len(email_authors)} authors of {len(attributed_emails):,}"
315
+ footer = f"{footer} out of {len(attributed_emails):,} emails)"
316
+
317
+ counts_table = build_table(
318
+ f"All {len(email_authors)} People Who Sent or Received an Email in the Files",
319
+ caption=footer,
320
+ cols=[
321
+ 'Name',
322
+ {'name': 'Count', 'justify': 'right', 'style': 'bold bright_white'},
323
+ {'name': 'Sent', 'justify': 'right', 'style': 'gray74'},
324
+ {'name': 'Recv', 'justify': 'right', 'style': 'gray74'},
325
+ {'name': 'First', 'style': TIMESTAMP_STYLE},
326
+ {'name': 'Last', 'style': LAST_TIMESTAMP_STYLE},
327
+ {'name': 'Days', 'justify': 'right', 'style': 'dim'},
328
+ JMAIL,
329
+ EPSTEIN_MEDIA,
330
+ EPSTEIN_WEB,
331
+ 'Twitter',
332
+ ]
333
+ )
334
+
335
+ for person in sorted(people, key=lambda person: person.sort_key()):
336
+ counts_table.add_row(
337
+ person.name_link(),
338
+ f"{len(person.unique_emails()):,}",
339
+ f"{len(person.unique_emails_by()):,}",
340
+ f"{len(person.unique_emails_to()):,}",
341
+ str(person.earliest_email_date()),
342
+ str(person.last_email_date()),
343
+ f"{person.email_conversation_length_in_days()}",
344
+ person.external_link_txt(JMAIL),
345
+ person.external_link_txt(EPSTEIN_MEDIA) if person.is_linkable() else '',
346
+ person.external_link_txt(EPSTEIN_WEB) if person.is_linkable() else '',
347
+ person.external_link_txt(TWITTER),
348
+ )
349
+
350
+ return counts_table
@@ -1,6 +1,6 @@
1
1
  from epstein_files.util.constant.strings import QUESTION_MARKS, remove_question_marks
2
2
 
3
- UNKNOWN = '(unknown)'
3
+ Name = str | None
4
4
 
5
5
  # Texting Names
6
6
  ANDRZEJ_DUDA = 'Andrzej Duda or entourage'
@@ -34,6 +34,7 @@ BARBRO_C_EHNBOM = 'Barbro C. Ehnbom'
34
34
  BARRY_J_COHEN = 'Barry J. Cohen'
35
35
  BENNET_MOSKOWITZ = 'Bennet Moskowitz'
36
36
  BILL_SIEGEL = 'Bill Siegel'
37
+ BOB_CROWE = 'Bob Crowe'
37
38
  BRAD_EDWARDS = 'Brad Edwards'
38
39
  BRAD_KARP = 'Brad Karp'
39
40
  BRAD_WECHSLER = 'Brad Wechsler'
@@ -78,6 +79,7 @@ JACK_GOLDBERGER = 'Jack Goldberger'
78
79
  JACK_SCAROLA = 'Jack Scarola'
79
80
  JACKIE_PERCZEK = 'Jackie Perczek'
80
81
  JAMES_HILL = 'James Hill'
82
+ JANUSZ_BANASIAK = 'Janusz Banasiak'
81
83
  JAY_LEFKOWITZ = 'Jay Lefkowitz'
82
84
  JEAN_HUGUEN = 'Jean Huguen'
83
85
  JEAN_LUC_BRUNEL = 'Jean Luc Brunel'
@@ -114,6 +116,7 @@ MARK_TRAMO = 'Mark Tramo'
114
116
  MARTIN_NOWAK = 'Martin Nowak'
115
117
  MARTIN_WEINBERG = "Martin Weinberg"
116
118
  MASHA_DROKOVA = 'Masha Drokova'
119
+ MATTHEW_HILTZIK = 'Matthew Hiltzik'
117
120
  MELANIE_SPINELLA = 'Melanie Spinella'
118
121
  MERWIN_DELA_CRUZ = 'Merwin Dela Cruz'
119
122
  MICHAEL_BUCHHOLTZ = 'Michael Buchholtz'
@@ -170,6 +173,8 @@ VINCENZO_IOZZO = 'Vincenzo Iozzo'
170
173
  VINIT_SAHNI = 'Vinit Sahni'
171
174
  ZUBAIR_KHAN = 'Zubair Khan'
172
175
 
176
+ UNKNOWN = '(unknown)'
177
+
173
178
  # No communications but name is in the files
174
179
  BILL_GATES = 'Bill Gates'
175
180
  DONALD_TRUMP = 'Donald Trump'
@@ -202,30 +207,30 @@ INSIGHTS_POD = f"InsightsPod" # Zubair bots
202
207
  MIT_MEDIA_LAB = 'MIT Media Lab'
203
208
  NEXT_MANAGEMENT = 'Next Management LLC'
204
209
  JP_MORGAN = 'JP Morgan'
205
- OSBORNE_LLP = f"{IAN_OSBORNE} & Partners LLP" # Ian Osborne's PR firm
206
- ROTHSTEIN_ROSENFELDT_ADLER = 'Rothstein Rosenfeldt Adler (Rothstein was a crook & partner of Roger Stone)'
210
+ OSBORNE_LLP = f"{IAN_OSBORNE} & Partners" # Ian Osborne's PR firm
211
+ ROTHSTEIN_ROSENFELDT_ADLER = "Rothstein Rosenfeldt Adler (Rothstein was Roger Stone's partner)" # and a crook
207
212
  TRUMP_ORG = 'Trump Organization'
208
213
  UBS = 'UBS'
209
214
 
210
215
  # First and last names that should be made part of a highlighting regex for emailers
211
216
  NAMES_TO_NOT_HIGHLIGHT = """
212
- al alan alfredo allen alex alexander amanda andres andrew
213
- bard barrett barry bill black boris brad bruce
217
+ al alain alan alfredo allen alex alexander amanda andres andrew
218
+ bard barrett barry bill black bob boris brad bruce
214
219
  carolyn chris christina
215
220
  dan daniel danny darren dave david donald
216
221
  ed edward edwards enterprise enterprises entourage epstein eric erika etienne
217
- faith fred friendly frost fuller
218
- gerald george gold
219
- harry hay heather henry hill hoffman
220
- ian
222
+ faith forget fred friendly frost fuller
223
+ gerald george gold gordon
224
+ haddad harry hay heather henry hill hoffman
225
+ ian ivan
221
226
  jack james jay jean jeff jeffrey jennifer jeremy jessica joel john jon jonathan joseph jr
222
- kahn karl kate katherine kelly ken kevin
227
+ kahn karl kate katherine kelly ken kevin krassner
223
228
  larry laurie lawrence leon lesley linda link lisa
224
229
  mann marc marie mark martin melanie michael mike miller mitchell miles morris moskowitz
225
230
  nancy neal new nicole
226
231
  owen
227
232
  paul paula pen peter philip prince
228
- randall reid richard robert rodriguez roger rosenberg ross roth roy rubin
233
+ randall rangel reid richard robert rodriguez roger rosenberg ross roth roy rubin
229
234
  scott sean skip stanley stern stephen steve steven stone susan
230
235
  the thomas tim tom tony tyler
231
236
  victor
@@ -272,3 +277,22 @@ def constantize_name(name: str) -> str:
272
277
  return f"'{name}'"
273
278
  else:
274
279
  return variable_name
280
+
281
+
282
+ def extract_first_name(name: str) -> str:
283
+ if ' ' not in name:
284
+ return name
285
+
286
+ return name.removesuffix(f" {extract_last_name(name)}")
287
+
288
+
289
+ def extract_last_name(name: str) -> str:
290
+ if ' ' not in name:
291
+ return name
292
+
293
+ first_last_names = remove_question_marks(name).strip().split()
294
+
295
+ if first_last_names[-1].startswith('Jr') and len(first_last_names[-1]) <= 3:
296
+ return ' '.join(first_last_names[-2:])
297
+ else:
298
+ return first_last_names[-1]
@@ -13,6 +13,7 @@ TEXT_MSGS_HTML_PATH = HTML_DIR.joinpath('index.html')
13
13
  WORD_COUNT_HTML_PATH = HTML_DIR.joinpath(f'communication_word_count_{EPSTEIN_FILES_NOV_2025}.html')
14
14
  # EPSTEIN_WORD_COUNT_HTML_PATH = HTML_DIR.joinpath('epstein_texts_and_emails_word_count.html')
15
15
  URLS_ENV = '.urls.env'
16
+ EMAILERS_TABLE_PNG_PATH = HTML_DIR.joinpath('emailers_info_table.png')
16
17
 
17
18
  # Deployment URLS
18
19
  # NOTE: don't rename these variables without changing deploy.sh!
@@ -11,6 +11,7 @@ BUSINESS = 'business'
11
11
  CONFERENCE = 'conference'
12
12
  ENTERTAINER = 'entertainer'
13
13
  FINANCE = 'finance'
14
+ FRIEND = 'friend'
14
15
  FLIGHT_LOG = 'flight log'
15
16
  JOURNALIST = 'journalist'
16
17
  JUNK = 'junk'
@@ -50,7 +51,7 @@ TEXT_MESSAGE = 'text message'
50
51
  SiteType = Literal['email', 'text message']
51
52
 
52
53
  # Styles
53
- DEFAULT_NAME_STYLE = 'gray46'
54
+ DEFAULT_NAME_STYLE = 'grey23'
54
55
  TIMESTAMP_STYLE = 'turquoise4'
55
56
  TIMESTAMP_DIM = f"turquoise4 dim"
56
57
 
@@ -76,7 +77,7 @@ MESSENGER_LOG_CLASS = 'MessengerLog'
76
77
  OTHER_FILE_CLASS = 'OtherFile'
77
78
 
78
79
 
79
- remove_question_marks = lambda name: QUESTION_MARKS_REGEX.sub('', name)
80
+ remove_question_marks = lambda name: QUESTION_MARKS_REGEX.sub('', name).strip()
80
81
 
81
82
 
82
83
  def indented(s: str, spaces: int = 4) -> str:
@@ -1,6 +1,6 @@
1
1
  import re
2
2
  import urllib.parse
3
- from typing import Literal
3
+ from typing import Callable, Literal
4
4
 
5
5
  from inflection import parameterize
6
6
  from rich.text import Text
@@ -14,12 +14,13 @@ ARCHIVE_LINK_COLOR = 'slate_blue3'
14
14
  TEXT_LINK = 'text_link'
15
15
 
16
16
  # External site names
17
- ExternalSite = Literal['epstein.media', 'epsteinify', 'EpsteinWeb', 'RollCall']
17
+ ExternalSite = Literal['epstein.media', 'epsteinify', 'EpsteinWeb', 'Jmail', 'RollCall', 'search X']
18
18
  EPSTEIN_MEDIA = 'epstein.media'
19
19
  EPSTEIN_WEB = 'EpsteinWeb'
20
20
  EPSTEINIFY = 'epsteinify'
21
21
  JMAIL = 'Jmail'
22
22
  ROLLCALL = 'RollCall'
23
+ TWITTER = 'search X'
23
24
 
24
25
  GH_PROJECT_URL = 'https://github.com/michelcrypt4d4mus/epstein_text_messages'
25
26
  GH_MASTER_URL = f"{GH_PROJECT_URL}/blob/master"
@@ -71,6 +72,15 @@ search_jmail_url = lambda txt: f"{JMAIL_URL}/search?q={urllib.parse.quote(txt)}"
71
72
  search_twitter_url = lambda txt: f"https://x.com/search?q={urllib.parse.quote(txt)}&src=typed_query&f=live"
72
73
 
73
74
 
75
+ PERSON_LINK_BUILDERS: dict[ExternalSite, Callable[[str], str]] = {
76
+ EPSTEIN_MEDIA: epstein_media_person_url,
77
+ EPSTEIN_WEB: epstein_web_person_url,
78
+ EPSTEINIFY: epsteinify_name_url,
79
+ JMAIL: search_jmail_url,
80
+ TWITTER: search_twitter_url,
81
+ }
82
+
83
+
74
84
  def build_doc_url(base_url: str, filename_or_id: int | str, case: Literal['lower', 'title'] | None = None) -> str:
75
85
  file_stem = coerce_file_stem(filename_or_id)
76
86
  file_stem = file_stem.lower() if case == 'lower' or EPSTEIN_MEDIA in base_url else file_stem
@@ -111,3 +121,5 @@ def other_site_url() -> str:
111
121
 
112
122
 
113
123
  CRYPTADAMUS_TWITTER = link_markup('https://x.com/cryptadamist', '@cryptadamist')
124
+ THE_OTHER_PAGE_MARKUP = link_markup(other_site_url(), 'the other page', style='light_slate_grey bold')
125
+ THE_OTHER_PAGE_TXT = Text.from_markup(THE_OTHER_PAGE_MARKUP)