epstein-files 1.1.3__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'
@@ -178,6 +183,7 @@ HENRY_HOLT = 'Henry Holt' # Actually a company?
178
183
  IVANKA = 'Ivanka'
179
184
  JAMES_PATTERSON = 'James Patterson'
180
185
  JARED_KUSHNER = 'Jared Kushner'
186
+ JEFFREY_WERNICK = 'Jeffrey Wernick'
181
187
  JULIE_K_BROWN = 'Julie K. Brown'
182
188
  KARIM_SADJADPOUR = 'KARIM SADJADPOUR'.title()
183
189
  MICHAEL_J_BOCCIO = 'Michael J. Boccio'
@@ -201,68 +207,59 @@ INSIGHTS_POD = f"InsightsPod" # Zubair bots
201
207
  MIT_MEDIA_LAB = 'MIT Media Lab'
202
208
  NEXT_MANAGEMENT = 'Next Management LLC'
203
209
  JP_MORGAN = 'JP Morgan'
204
- OSBORNE_LLP = f"{IAN_OSBORNE} & Partners LLP" # Ian Osborne's PR firm
205
- 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
206
212
  TRUMP_ORG = 'Trump Organization'
207
213
  UBS = 'UBS'
208
214
 
209
- # Invalid for links to EpsteinWeb
210
- JUNK_EMAILERS = [
211
- 'asmallworld@travel.asmallworld.net',
212
- "digest-noreply@quora.com",
213
- 'editorialstaff@flipboard.com',
214
- 'How To Academy',
215
- 'Jokeland',
216
- ]
217
-
218
215
  # First and last names that should be made part of a highlighting regex for emailers
219
- NAMES_TO_NOT_HIGHLIGHT: list[str] = [name.lower() for name in [
220
- 'Al', 'Alan', 'Alfredo', 'Allen', 'Alex', 'Alexander', 'Amanda', 'Andres', 'Andrew',
221
- 'Bard', 'Barry', 'Bill', 'Black', 'Boris', 'Brad', 'Bruce',
222
- 'Carolyn', 'Chris', 'Christina',
223
- 'Dan', 'Daniel', 'Danny', 'Darren', 'Dave', 'David',
224
- 'Ed', 'Edward', 'Edwards', 'Epstein', 'Eric', 'Erika', 'Etienne',
225
- 'Faith', 'Fred', 'Friendly', 'Frost', 'Fuller',
226
- 'George',
227
- 'Heather', 'Henry', 'Hill', 'Hoffman',
228
- 'Ian',
229
- 'Jack', 'James', 'Jay', 'Jean', 'Jeff', 'Jeffrey', 'Jennifer', 'Jeremy', 'jessica', 'Joel', 'John', 'Jon', 'Jonathan', 'Joseph', 'Jr',
230
- 'Kahn', 'Katherine', 'Kelly', 'Ken', 'Kevin',
231
- 'Larry', 'Leon', 'Lesley', 'Linda', 'Link', 'Lisa',
232
- 'Mann', 'Marc', 'Marie', 'Mark', 'Martin', 'Melanie', 'Michael', 'Mike', 'Miller', 'Mitchell', 'Miles', 'Morris', 'Moskowitz',
233
- 'Nancy', 'Neal', 'New',
234
- 'Paul', 'Paula', 'Pen', 'Peter', 'Philip', 'Prince',
235
- 'Randall', 'Reid', 'Richard', 'Robert', 'Rodriguez', 'Roger', 'Rosenberg', 'Ross', 'Roth', 'Rubin',
236
- 'Scott', 'Sean', 'Stanley', 'Stern', 'Stephen', 'Steve', 'Steven', 'Stone', 'Susan',
237
- 'The', 'Thomas', 'Tim', 'Tom', 'Tyler',
238
- 'Victor',
239
- 'Wade',
240
- "Y",
241
- ]]
216
+ NAMES_TO_NOT_HIGHLIGHT = """
217
+ al alain alan alfredo allen alex alexander amanda andres andrew
218
+ bard barrett barry bill black bob boris brad bruce
219
+ carolyn chris christina
220
+ dan daniel danny darren dave david donald
221
+ ed edward edwards enterprise enterprises entourage epstein eric erika etienne
222
+ faith forget fred friendly frost fuller
223
+ gerald george gold gordon
224
+ haddad harry hay heather henry hill hoffman
225
+ ian ivan
226
+ jack james jay jean jeff jeffrey jennifer jeremy jessica joel john jon jonathan joseph jr
227
+ kahn karl kate katherine kelly ken kevin krassner
228
+ larry laurie lawrence leon lesley linda link lisa
229
+ mann marc marie mark martin melanie michael mike miller mitchell miles morris moskowitz
230
+ nancy neal new nicole
231
+ owen
232
+ paul paula pen peter philip prince
233
+ randall rangel reid richard robert rodriguez roger rosenberg ross roth roy rubin
234
+ scott sean skip stanley stern stephen steve steven stone susan
235
+ the thomas tim tom tony tyler
236
+ victor
237
+ wade waters
238
+ y
239
+ """.strip().split()
242
240
 
243
241
  # Names to color white in the word counts
244
242
  OTHER_NAMES = NAMES_TO_NOT_HIGHLIGHT + """
245
243
  aaron albert alberto alec alexandra alice anderson andre ann anna anne ariana arthur
246
244
  baldwin barack barrett ben benjamin berger bert binant bob bonner boyden bradley brady branson bright bruno bryant burton
247
245
  chapman charles charlie christopher clint cohen colin collins conway
248
- danny davis dean debra deborah dennis diana diane diaz dickinson dixon dominique don dylan
249
- edmond elizabeth emily enterprises entwistle erik evelyn
250
- ferguson flachsbart francis franco frank frost
251
- gardner gary geoff geoffrey gerald gilbert gloria gold goldberg gonzalez gould graham greene guarino gwyneth
252
- hancock harold harrison harry hay helen hill hirsch hofstadter horowitz hussein
253
- ian isaac isaacson
254
- james jamie jane janet jason jeffrey jen jim joe johnson jones josh julie justin
255
- karl kate kathy kelly kim kruger kyle
256
- laurie lawrence leo leonard lenny leslie lieberman louis lynch lynn
246
+ davis dean debra deborah dennis diana diane diaz dickinson dixon dominique don dylan
247
+ edmond elizabeth emily entwistle erik evelyn
248
+ ferguson flachsbart francis franco frank
249
+ gardner gary geoff geoffrey gilbert gloria goldberg gonzalez gould graham greene guarino gwyneth
250
+ hancock harold harrison helen hirsch hofstadter horowitz hussein
251
+ isaac isaacson
252
+ jamie jane janet jason jeffrey jen jim joe johnson jones josh julie justin
253
+ kathy kim kruger kyle
254
+ lawrence leo leonard lenny leslie lieberman louis lynch lynn
257
255
  marcus marianne matt matthew melissa michele michelle moore moscowitz
258
- nancy nicole nussbaum
259
- owen
260
- paulson peter philippe
256
+ nancy nussbaum
257
+ paulson philippe
261
258
  rafael ray richard richardson rob robert robin ron rubin rudolph ryan
262
- sara sarah sean seligman serge sergey silverman sloman smith snowden sorkin steele stevie stewart
263
- ted theresa thompson tiffany timothy tony
259
+ sara sarah seligman serge sergey silverman sloman smith snowden sorkin steele stevie stewart
260
+ ted theresa thompson tiffany timothy
264
261
  valeria
265
- walter warren waters weinstein weiss william
262
+ walter warren weinstein weiss william
266
263
  zach zack
267
264
  """.strip().split()
268
265
 
@@ -280,3 +277,22 @@ def constantize_name(name: str) -> str:
280
277
  return f"'{name}'"
281
278
  else:
282
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,6 +51,7 @@ TEXT_MESSAGE = 'text message'
50
51
  SiteType = Literal['email', 'text message']
51
52
 
52
53
  # Styles
54
+ DEFAULT_NAME_STYLE = 'grey23'
53
55
  TIMESTAMP_STYLE = 'turquoise4'
54
56
  TIMESTAMP_DIM = f"turquoise4 dim"
55
57
 
@@ -75,7 +77,7 @@ MESSENGER_LOG_CLASS = 'MessengerLog'
75
77
  OTHER_FILE_CLASS = 'OtherFile'
76
78
 
77
79
 
78
- remove_question_marks = lambda name: QUESTION_MARKS_REGEX.sub('', name)
80
+ remove_question_marks = lambda name: QUESTION_MARKS_REGEX.sub('', name).strip()
79
81
 
80
82
 
81
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)