heathrow 0.7.6 → 0.7.8

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edb287ccce2f2ceb0b7582ce124efe53e9e7842662e241e12951d22c9bc202a6
4
- data.tar.gz: 9ede8d4eab17aa9d0fff183b183eec501ebbdd011c8515497772e7d26cf51841
3
+ metadata.gz: 2c08562dbe55671975842e77588946f4e89bf5b94810c7c6396ace1becdd8994
4
+ data.tar.gz: 0e77bfadabbfbd37b56c93cba7cc42fb7ede639133e58c64a64c4c78564b1df7
5
5
  SHA512:
6
- metadata.gz: 3566cc3e67b80037396991d097b7eedac09f22a0ab6c85e9f01d098c51dfa5be7dfd1cc87d3be476adc866eb8ac05c75be48ca77b170aa2149b5f2fa3a676164
7
- data.tar.gz: 245b04002f624724fc3e1b475656425efac505c10a28763308dcee1b2403c796c005284eb4a7f6be0c7bc272de79af1a7da2687e6a952b2b65914faf3599f8ad
6
+ metadata.gz: 4abff9476dbeb379c387ea9105d541f1a2f0ca599a763f118c04defa8fd76047161a077b9ab990fb5428722a58f1cbcdfbb15864ef3340ab8f825d8da044e291
7
+ data.tar.gz: 7f2ca4bdb2685d8496eb716124bc32422b4910ea113fc4e0de61b72aa6f789a337d43c61a5213658249c8639f50d62f5721e64be4f1f17fa190864fda58f4a9f
data/README.md CHANGED
@@ -121,8 +121,9 @@ Press `?` for full help. Here are the essentials:
121
121
  | `<` | Purge deleted |
122
122
  | `x` | Open in browser |
123
123
  | `v` | View attachments |
124
- | `Y` | Copy right pane to clipboard |
124
+ | `Y`/`Ctrl-y` | Copy right pane to clipboard |
125
125
  | `y` | Copy message ID to clipboard |
126
+ | `@` | Address book (a=add sender, e=edit) |
126
127
 
127
128
  ### Compose Prompt
128
129
  | Key | Action |
@@ -139,11 +140,24 @@ Press `?` for full help. Here are the essentials:
139
140
  | `w` | Cycle pane width |
140
141
  | `Ctrl-b` | Cycle border style |
141
142
  | `D` | Cycle date format |
142
- | `o` | Cycle sort order |
143
+ | `o` | Cycle sort order (Latest, A-Z, Sender, From, Conversation, Unread, Source) |
144
+ | `Z` | Open calendar invite in [Timely](https://github.com/isene/timely) |
143
145
  | `P` | Preferences popup |
144
146
  | `?` | Help (press again for extended) |
145
147
  | `q` | Quit |
146
148
 
149
+ ### Sort Orders
150
+
151
+ | Order | Description |
152
+ |-------|-------------|
153
+ | Latest | Newest messages first (default) |
154
+ | A-Z | Alphabetical by subject |
155
+ | Sender | Alphabetical by sender |
156
+ | From | Group by sender, most recently active sender first |
157
+ | Conversation | Group by thread/conversation |
158
+ | Unread | Unread messages first |
159
+ | Source | Group by source type |
160
+
147
161
  ## Configuration
148
162
 
149
163
  All settings live in `~/.heathrow/heathrowrc` (Ruby syntax):
@@ -200,6 +214,14 @@ Each source is a self-contained plugin. Sources crash independently without affe
200
214
  - Built on [rcurses](https://github.com/isene/rcurses) TUI library
201
215
  - Inspired by [RTFM](https://github.com/isene/RTFM) file manager
202
216
 
217
+ ## Timely Integration
218
+
219
+ Heathrow works with [Timely](https://github.com/isene/timely), a terminal calendar app:
220
+
221
+ - **Z key**: On a calendar invite email, press `Z` to open it in Timely (extracts the event date from ICS data and copies the ICS for import)
222
+ - **Wezterm mailto**: Click any email address in wezterm to compose in Heathrow
223
+ - **From sort**: The "From" sort order groups messages by sender with the most recently active person at the top, perfect for family or team views
224
+
203
225
  ## License
204
226
 
205
227
  [Unlicense](https://unlicense.org/) - released into the public domain.
@@ -0,0 +1,321 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 560" width="1500" height="560" font-family="monospace, Courier New, Courier">
2
+ <defs>
3
+ <style>
4
+ .key { fill: #333; stroke: #555; stroke-width: 1; rx: 5; ry: 5; }
5
+ .kw { fill: #2a2a2a; stroke: #555; stroke-width: 1; rx: 5; ry: 5; }
6
+ .kl { font-size: 12px; fill: #bbb; font-weight: bold; text-anchor: middle; }
7
+ .lo { font-size: 8.5px; fill: #FF8888; text-anchor: middle; }
8
+ .up { font-size: 8.5px; fill: #88BBFF; text-anchor: middle; }
9
+ .ct { font-size: 10px; fill: #88FF88; }
10
+ .sp { font-size: 8.5px; fill: #DDAAFF; text-anchor: middle; }
11
+ .nv { font-size: 8.5px; fill: #bbb; text-anchor: middle; }
12
+ </style>
13
+ </defs>
14
+ <rect width="1500" height="560" fill="#1a1a1a" rx="8"/>
15
+ <text x="470" y="22" text-anchor="middle" font-size="15" font-weight="bold" fill="#fff">Heathrow Keyboard Reference</text>
16
+
17
+ <!-- Row 0: Esc -->
18
+ <rect x="10" y="34" width="58" height="55" class="key"/>
19
+ <text x="39" y="54" class="kl">Esc</text>
20
+ <text x="39" y="80" class="lo">quit</text>
21
+
22
+ <!-- Row 1: Number row kw=62 gap=4 -->
23
+ <rect x="10" y="97" width="58" height="55" class="key"/>
24
+ <text x="39" y="127" class="kl">` ~</text>
25
+
26
+ <rect x="72" y="97" width="62" height="55" class="key"/>
27
+ <text x="103" y="117" class="kl">1</text>
28
+ <text x="103" y="143" class="lo">view 1</text>
29
+
30
+ <rect x="138" y="97" width="62" height="55" class="key"/>
31
+ <text x="169" y="117" class="kl">2</text>
32
+ <text x="169" y="143" class="lo">view 2</text>
33
+
34
+ <rect x="204" y="97" width="62" height="55" class="key"/>
35
+ <text x="235" y="117" class="kl">3</text>
36
+ <text x="235" y="143" class="lo">view 3</text>
37
+
38
+ <rect x="270" y="97" width="62" height="55" class="key"/>
39
+ <text x="301" y="117" class="kl">4</text>
40
+ <text x="301" y="143" class="lo">view 4</text>
41
+
42
+ <rect x="336" y="97" width="62" height="55" class="key"/>
43
+ <text x="367" y="117" class="kl">5</text>
44
+ <text x="367" y="143" class="lo">view 5</text>
45
+
46
+ <rect x="402" y="97" width="62" height="55" class="key"/>
47
+ <text x="433" y="117" class="kl">6</text>
48
+ <text x="433" y="143" class="lo">view 6</text>
49
+
50
+ <rect x="468" y="97" width="62" height="55" class="key"/>
51
+ <text x="499" y="117" class="kl">7</text>
52
+ <text x="499" y="143" class="lo">view 7</text>
53
+
54
+ <rect x="534" y="97" width="62" height="55" class="key"/>
55
+ <text x="565" y="117" class="kl">8</text>
56
+ <text x="565" y="143" class="lo">view 8</text>
57
+
58
+ <rect x="600" y="97" width="62" height="55" class="key"/>
59
+ <text x="631" y="117" class="kl">9</text>
60
+ <text x="631" y="143" class="lo">view 9</text>
61
+
62
+ <rect x="666" y="97" width="62" height="55" class="key"/>
63
+ <text x="697" y="117" class="kl">0</text>
64
+ <text x="697" y="143" class="lo">view 0</text>
65
+
66
+ <rect x="732" y="97" width="62" height="55" class="key"/>
67
+ <text x="763" y="115" class="kl">- _</text>
68
+
69
+ <rect x="798" y="97" width="62" height="55" class="key"/>
70
+ <text x="829" y="115" class="kl">= +</text>
71
+ <text x="829" y="140" class="sp">+ fav folder</text>
72
+
73
+ <rect x="864" y="97" width="76" height="55" class="kw"/>
74
+ <text x="902" y="130" class="nv">Bksp</text>
75
+
76
+ <!-- Row 2: QWERTY start x=10+80=90 offset, kw=62 -->
77
+ <rect x="10" y="160" width="76" height="55" class="kw"/>
78
+ <text x="48" y="183" class="nv">Tab</text>
79
+ <text x="48" y="206" class="nv">scroll R</text>
80
+
81
+ <rect x="90" y="160" width="62" height="55" class="key"/>
82
+ <text x="121" y="176" class="kl">Q</text>
83
+ <text x="121" y="194" class="up">Force quit</text>
84
+ <text x="121" y="207" class="lo">quit</text>
85
+
86
+ <rect x="156" y="160" width="62" height="55" class="key"/>
87
+ <text x="187" y="176" class="kl">W</text>
88
+ <text x="187" y="207" class="lo">width</text>
89
+
90
+ <rect x="222" y="160" width="62" height="55" class="key"/>
91
+ <text x="253" y="176" class="kl">E</text>
92
+ <text x="253" y="194" class="up">Edit as new</text>
93
+ <text x="253" y="207" class="lo">reply+edit</text>
94
+
95
+ <rect x="288" y="160" width="62" height="55" class="key"/>
96
+ <text x="319" y="176" class="kl">R</text>
97
+ <text x="319" y="194" class="up">Read/Unrd</text>
98
+ <text x="319" y="207" class="lo">reply</text>
99
+
100
+ <rect x="354" y="160" width="62" height="55" class="key"/>
101
+ <text x="385" y="176" class="kl">T</text>
102
+ <text x="385" y="194" class="up">Tag all</text>
103
+ <text x="385" y="207" class="lo">tag</text>
104
+
105
+ <rect x="420" y="160" width="62" height="55" class="key"/>
106
+ <text x="451" y="176" class="kl">Y</text>
107
+ <text x="451" y="194" class="up">Copy pane</text>
108
+ <text x="451" y="207" class="lo">copy ID</text>
109
+
110
+ <rect x="486" y="160" width="62" height="55" class="key"/>
111
+ <text x="517" y="176" class="kl">U</text>
112
+
113
+ <rect x="552" y="160" width="62" height="55" class="key"/>
114
+ <text x="583" y="176" class="kl">I</text>
115
+ <text x="583" y="194" class="up">AI assist</text>
116
+ <text x="583" y="207" class="lo">invert</text>
117
+
118
+ <rect x="618" y="160" width="62" height="55" class="key"/>
119
+ <text x="649" y="176" class="kl">O</text>
120
+ <text x="649" y="207" class="lo">sort</text>
121
+
122
+ <rect x="684" y="160" width="62" height="55" class="key"/>
123
+ <text x="715" y="176" class="kl">P</text>
124
+ <text x="715" y="194" class="up">Prefs</text>
125
+ <text x="715" y="207" class="lo">prev unrd</text>
126
+
127
+ <rect x="750" y="160" width="62" height="55" class="key"/>
128
+ <text x="781" y="176" class="kl">[ {</text>
129
+
130
+ <rect x="816" y="160" width="62" height="55" class="key"/>
131
+ <text x="847" y="176" class="kl">] }</text>
132
+
133
+ <rect x="882" y="160" width="58" height="55" class="key"/>
134
+ <text x="911" y="190" class="kl">\</text>
135
+
136
+ <!-- Row 3: ASDF start x=10+90=100 offset -->
137
+ <rect x="10" y="223" width="86" height="55" class="kw"/>
138
+ <text x="53" y="255" class="nv">Ctrl</text>
139
+
140
+ <rect x="100" y="223" width="62" height="55" class="key"/>
141
+ <text x="131" y="239" class="kl">A</text>
142
+ <text x="131" y="262" class="up">All msgs</text>
143
+
144
+ <rect x="166" y="223" width="62" height="55" class="key"/>
145
+ <text x="197" y="239" class="kl">S</text>
146
+ <text x="197" y="257" class="up">Sources</text>
147
+ <text x="197" y="270" class="lo">save</text>
148
+
149
+ <rect x="232" y="223" width="62" height="55" class="key"/>
150
+ <text x="263" y="239" class="kl">D</text>
151
+ <text x="263" y="257" class="up">Date fmt</text>
152
+ <text x="263" y="270" class="lo">del mark</text>
153
+
154
+ <rect x="298" y="223" width="62" height="55" class="key"/>
155
+ <text x="329" y="239" class="kl">F</text>
156
+ <text x="329" y="257" class="up">Favorites</text>
157
+ <text x="329" y="270" class="lo">forward</text>
158
+
159
+ <rect x="364" y="223" width="62" height="55" class="key"/>
160
+ <text x="395" y="239" class="kl">G</text>
161
+ <text x="395" y="257" class="up">View mode</text>
162
+ <text x="395" y="270" class="lo">reply all</text>
163
+
164
+ <rect x="430" y="223" width="62" height="55" class="key"/>
165
+ <text x="461" y="239" class="kl">H</text>
166
+ <text x="461" y="270" class="nv">h = back</text>
167
+
168
+ <rect x="496" y="223" width="62" height="55" class="key"/>
169
+ <text x="527" y="239" class="kl">J</text>
170
+ <text x="527" y="257" class="up">Jump date</text>
171
+ <text x="527" y="270" class="nv">j = down</text>
172
+
173
+ <rect x="562" y="223" width="62" height="55" class="key"/>
174
+ <text x="593" y="239" class="kl">K</text>
175
+ <text x="593" y="257" class="up">Kill view</text>
176
+ <text x="593" y="270" class="nv">k = up</text>
177
+
178
+ <rect x="628" y="223" width="62" height="55" class="key"/>
179
+ <text x="659" y="239" class="kl">L</text>
180
+ <text x="659" y="257" class="up">Load more</text>
181
+ <text x="659" y="270" class="nv">l = open</text>
182
+
183
+ <rect x="694" y="223" width="62" height="55" class="key"/>
184
+ <text x="725" y="239" class="kl">; :</text>
185
+
186
+ <rect x="760" y="223" width="62" height="55" class="key"/>
187
+ <text x="791" y="255" class="kl">'</text>
188
+
189
+ <rect x="826" y="223" width="114" height="55" class="kw"/>
190
+ <text x="883" y="248" class="nv">Enter</text>
191
+ <text x="883" y="268" class="nv">open msg</text>
192
+
193
+ <!-- Row 4: ZXCV start x=10+100=110 offset -->
194
+ <rect x="10" y="286" width="96" height="55" class="kw"/>
195
+ <text x="58" y="318" class="nv">Shift</text>
196
+
197
+ <rect x="110" y="286" width="62" height="55" class="key"/>
198
+ <text x="141" y="302" class="kl">Z</text>
199
+ <text x="141" y="325" class="up">Timely</text>
200
+
201
+ <rect x="176" y="286" width="62" height="55" class="key"/>
202
+ <text x="207" y="302" class="kl">X</text>
203
+ <text x="207" y="325" class="lo">browser</text>
204
+
205
+ <rect x="242" y="286" width="62" height="55" class="key"/>
206
+ <text x="273" y="302" class="kl">C</text>
207
+
208
+ <rect x="308" y="286" width="62" height="55" class="key"/>
209
+ <text x="339" y="302" class="kl">V</text>
210
+ <text x="339" y="320" class="up">Inline img</text>
211
+ <text x="339" y="333" class="lo">attach</text>
212
+
213
+ <rect x="374" y="286" width="62" height="55" class="key"/>
214
+ <text x="405" y="302" class="kl">B</text>
215
+ <text x="405" y="325" class="up">Browse</text>
216
+
217
+ <rect x="440" y="286" width="62" height="55" class="key"/>
218
+ <text x="471" y="302" class="kl">N</text>
219
+ <text x="471" y="320" class="up">New msgs</text>
220
+ <text x="471" y="333" class="lo">next unrd</text>
221
+
222
+ <rect x="506" y="286" width="62" height="55" class="key"/>
223
+ <text x="537" y="302" class="kl">M</text>
224
+ <text x="537" y="320" class="up">Mark read</text>
225
+ <text x="537" y="333" class="lo">compose</text>
226
+
227
+ <rect x="572" y="286" width="62" height="55" class="key"/>
228
+ <text x="603" y="302" class="kl">, &lt;</text>
229
+ <text x="603" y="325" class="sp">&lt; purge</text>
230
+
231
+ <rect x="638" y="286" width="62" height="55" class="key"/>
232
+ <text x="669" y="315" class="kl">. &gt;</text>
233
+
234
+ <rect x="704" y="286" width="62" height="55" class="key"/>
235
+ <text x="735" y="302" class="kl">/ ?</text>
236
+ <text x="735" y="320" class="sp">? help</text>
237
+ <text x="735" y="333" class="sp">/ search</text>
238
+
239
+ <rect x="770" y="286" width="170" height="55" class="kw"/>
240
+ <text x="855" y="318" class="nv">Shift</text>
241
+
242
+ <!-- Row 5: Bottom -->
243
+ <rect x="10" y="349" width="70" height="50" class="kw"/>
244
+ <text x="45" y="378" class="nv">Ctrl</text>
245
+
246
+ <rect x="84" y="349" width="58" height="50" class="kw"/>
247
+ <text x="113" y="378" class="nv">Alt</text>
248
+
249
+ <rect x="146" y="349" width="330" height="50" class="key"/>
250
+ <text x="311" y="372" class="nv">Space</text>
251
+ <text x="311" y="390" class="sp">collapse</text>
252
+
253
+ <rect x="480" y="349" width="62" height="50" class="key"/>
254
+ <text x="511" y="366" class="kl"># @</text>
255
+ <text x="511" y="382" class="sp">* star</text>
256
+ <text x="511" y="394" class="sp">@ addr book</text>
257
+
258
+ <!-- Nav cluster -->
259
+ <rect x="990" y="97" width="64" height="55" class="key"/>
260
+ <text x="1022" y="120" class="nv">Home</text>
261
+ <text x="1022" y="143" class="nv">first</text>
262
+
263
+ <rect x="1058" y="97" width="64" height="55" class="key"/>
264
+ <text x="1090" y="120" class="nv">End</text>
265
+ <text x="1090" y="143" class="nv">last</text>
266
+
267
+ <rect x="990" y="160" width="64" height="55" class="key"/>
268
+ <text x="1022" y="183" class="nv">PgUp</text>
269
+ <text x="1022" y="206" class="nv">page up</text>
270
+
271
+ <rect x="1058" y="160" width="64" height="55" class="key"/>
272
+ <text x="1090" y="183" class="nv">PgDn</text>
273
+ <text x="1090" y="206" class="nv">page dn</text>
274
+
275
+ <rect x="1022" y="286" width="60" height="55" class="key"/>
276
+ <text x="1052" y="310" class="nv">UP</text>
277
+ <text x="1052" y="333" class="nv">k</text>
278
+
279
+ <rect x="958" y="349" width="60" height="50" class="key"/>
280
+ <text x="988" y="370" class="nv">LEFT</text>
281
+ <text x="988" y="390" class="nv">h/back</text>
282
+
283
+ <rect x="1022" y="349" width="60" height="50" class="key"/>
284
+ <text x="1052" y="370" class="nv">DOWN</text>
285
+ <text x="1052" y="390" class="nv">j</text>
286
+
287
+ <rect x="1086" y="349" width="60" height="50" class="key"/>
288
+ <text x="1116" y="370" class="nv">RIGHT</text>
289
+ <text x="1116" y="390" class="nv">l/open</text>
290
+
291
+ <!-- Right panel: Ctrl combos -->
292
+ <rect x="1180" y="34" width="300" height="370" rx="6" fill="#1a2a1a" stroke="#4a6a4a" stroke-width="1"/>
293
+ <text x="1330" y="56" text-anchor="middle" font-size="13" font-weight="bold" fill="#66FF66">Ctrl Combos</text>
294
+ <text x="1194" y="80" class="ct">C-B Toggle border</text>
295
+ <text x="1194" y="100" class="ct">C-F Edit filter</text>
296
+ <text x="1194" y="120" class="ct">C-L Redraw screen</text>
297
+ <text x="1194" y="140" class="ct">C-R Refresh sources</text>
298
+ <text x="1194" y="160" class="ct">C-T Tag by regex</text>
299
+ <text x="1194" y="180" class="ct">C-Y Copy pane</text>
300
+
301
+ <text x="1330" y="220" text-anchor="middle" font-size="13" font-weight="bold" fill="#888">Navigation</text>
302
+ <text x="1194" y="244" class="ct" style="fill:#888">Enter Open message</text>
303
+ <text x="1194" y="264" class="ct" style="fill:#888">Arrows Navigate</text>
304
+ <text x="1194" y="284" class="ct" style="fill:#888">PgUp/Dn Page scroll</text>
305
+ <text x="1194" y="304" class="ct" style="fill:#888">Home/End First/Last</text>
306
+ <text x="1194" y="324" class="ct" style="fill:#888">Tab Scroll right</text>
307
+ <text x="1194" y="344" class="ct" style="fill:#888">S-Arrows Scroll pane</text>
308
+ <text x="1194" y="364" class="ct" style="fill:#888">Space Collapse thread</text>
309
+ <text x="1194" y="384" class="ct" style="fill:#888">0-9 Switch views</text>
310
+
311
+ <!-- Legend -->
312
+ <rect x="20" y="470" width="14" height="14" fill="#3a2020" rx="2" stroke="#553333" stroke-width="0.5"/>
313
+ <text x="40" y="482" font-size="10" fill="#FF6666">lowercase</text>
314
+ <rect x="120" y="470" width="14" height="14" fill="#20203a" rx="2" stroke="#333355" stroke-width="0.5"/>
315
+ <text x="140" y="482" font-size="10" fill="#6699FF">UPPERCASE</text>
316
+ <rect x="240" y="470" width="14" height="14" fill="#203a20" rx="2" stroke="#335533" stroke-width="0.5"/>
317
+ <text x="260" y="482" font-size="10" fill="#66FF66">Ctrl+key</text>
318
+ <rect x="350" y="470" width="14" height="14" fill="#2a2030" rx="2" stroke="#443355" stroke-width="0.5"/>
319
+ <text x="370" y="482" font-size="10" fill="#CC99FF">special</text>
320
+ <text x="460" y="482" font-size="10" fill="#888">gray = navigation</text>
321
+ </svg>
@@ -375,9 +375,9 @@ module Heathrow
375
375
  # Handle sender pattern (supports regex via pipe separation)
376
376
  if filters[:sender_pattern]
377
377
  patterns = filters[:sender_pattern].split('|')
378
- conditions = patterns.map { "sender LIKE ?" }.join(' OR ')
378
+ conditions = patterns.map { "(sender LIKE ? OR sender_name LIKE ?)" }.join(' OR ')
379
379
  query += " AND (#{conditions})"
380
- params += patterns.map { |p| "%#{p}%" }
380
+ patterns.each { |p| params += ["%#{p}%", "%#{p}%"] }
381
381
  end
382
382
 
383
383
  # Handle subject pattern
@@ -748,7 +748,7 @@ module Heathrow
748
748
  load_more_messages
749
749
  when 'w'
750
750
  change_width
751
- when 'Y'
751
+ when 'Y', 'C-Y'
752
752
  copy_right_pane_to_clipboard
753
753
  when 'c'
754
754
  set_view_top_bg
@@ -835,10 +835,14 @@ module Heathrow
835
835
  label_message
836
836
  when 'v'
837
837
  view_attachments
838
+ when 'Z'
839
+ open_in_timely
838
840
  when 'I'
839
841
  ai_assistant
840
842
  when 'V'
841
843
  toggle_inline_image
844
+ when '@'
845
+ address_book_menu
842
846
  when 'ESC', "\e"
843
847
  if @showing_image
844
848
  clear_inline_image
@@ -1395,6 +1399,7 @@ module Heathrow
1395
1399
  when 'latest' then 'Latest'
1396
1400
  when 'alphabetical' then 'A-Z'
1397
1401
  when 'sender' then 'Sender'
1402
+ when 'from' then 'From'
1398
1403
  when 'unread' then 'Unread'
1399
1404
  when 'source' then 'Source'
1400
1405
  else @sort_order.capitalize
@@ -2040,10 +2045,10 @@ module Heathrow
2040
2045
  end
2041
2046
  end
2042
2047
 
2043
- header << ("─" * 60).fg(238)
2044
-
2045
- # Format message body — prefer HTML rendered via w3m
2046
2048
  pane_w = @panes[:right].w rescue 80
2049
+ header << ("─" * (pane_w - 2)).fg(238)
2050
+
2051
+ # Format message body — prefer HTML rendered via w3m
2047
2052
  render_width = [pane_w - 2, 40].max
2048
2053
  content = nil
2049
2054
  if msg['html_content'] && !msg['html_content'].to_s.strip.empty?
@@ -2997,6 +3002,83 @@ module Heathrow
2997
3002
  (msg['content'] && msg['content'] =~ /\A\s*<(!DOCTYPE|html|head|body)\b/i)
2998
3003
  end
2999
3004
 
3005
+ def open_in_timely
3006
+ msg = current_message
3007
+ return unless msg
3008
+ msg = ensure_full_message(msg)
3009
+
3010
+ # Try to find a date from calendar data or message timestamp
3011
+ timely_home = File.expand_path('~/.timely')
3012
+ return set_feedback("Timely not configured (~/.timely missing)", 196, 3) unless File.directory?(timely_home)
3013
+
3014
+ # Check for ICS attachment or inline calendar data
3015
+ date_str = nil
3016
+ meta = msg['metadata']
3017
+ meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
3018
+ file = meta['maildir_file'] if meta.is_a?(Hash)
3019
+
3020
+ if file && File.exist?(file)
3021
+ begin
3022
+ require 'mail'
3023
+ mail = Mail.read(file)
3024
+ if mail.multipart?
3025
+ mail.parts.each do |part|
3026
+ ct = (part.content_type || '').downcase
3027
+ if ct.include?('calendar') || ct.include?('ics')
3028
+ ics = part.decoded
3029
+ vevent = ics[/BEGIN:VEVENT(.*?)END:VEVENT/m, 1]
3030
+ if vevent
3031
+ vevent = vevent.gsub(/\r?\n[ \t]/, '')
3032
+ if vevent =~ /^DTSTART;TZID=[^:]*:(\d{8})/i ||
3033
+ vevent =~ /^DTSTART:(\d{8})/i ||
3034
+ vevent =~ /^DTSTART;VALUE=DATE:(\d{8})/i
3035
+ d = $1
3036
+ date_str = "#{d[0,4]}-#{d[4,2]}-#{d[6,2]}"
3037
+ end
3038
+ end
3039
+ break
3040
+ end
3041
+ end
3042
+ end
3043
+ rescue => e
3044
+ # Fall through to timestamp
3045
+ end
3046
+ end
3047
+
3048
+ # Fallback: use message timestamp
3049
+ unless date_str
3050
+ ts = msg['timestamp'].to_i
3051
+ date_str = Time.at(ts).strftime('%Y-%m-%d') if ts > 0
3052
+ end
3053
+
3054
+ return set_feedback("Could not determine date for Timely", 245, 2) unless date_str
3055
+
3056
+ # Write goto file for Timely
3057
+ File.write(File.join(timely_home, 'goto'), date_str)
3058
+
3059
+ # Also copy ICS to incoming if it has calendar data
3060
+ if file && File.exist?(file)
3061
+ begin
3062
+ incoming = File.join(timely_home, 'incoming')
3063
+ FileUtils.mkdir_p(incoming)
3064
+ require 'mail'
3065
+ mail = Mail.read(file)
3066
+ mail.parts.each do |part|
3067
+ ct = (part.content_type || '').downcase
3068
+ if ct.include?('calendar') || ct.include?('ics')
3069
+ ics_file = File.join(incoming, "heathrow_#{msg['id']}.ics")
3070
+ File.write(ics_file, part.decoded) unless File.exist?(ics_file)
3071
+ break
3072
+ end
3073
+ end
3074
+ rescue => e
3075
+ # Non-fatal
3076
+ end
3077
+ end
3078
+
3079
+ set_feedback("Sent to Timely: #{date_str}", 156, 0)
3080
+ end
3081
+
3000
3082
  def open_message_external
3001
3083
  msg = current_message
3002
3084
  return unless msg
@@ -4677,6 +4759,7 @@ module Heathrow
4677
4759
  ai_reply_with_draft(msg, response.strip)
4678
4760
  when 'y'
4679
4761
  IO.popen('xclip -selection clipboard', 'w') { |io| io.write(response.strip) } rescue nil
4762
+ IO.popen('xclip -selection primary', 'w') { |io| io.write(response.strip) } rescue nil
4680
4763
  set_feedback("Draft copied to clipboard", 156, 2)
4681
4764
  end
4682
4765
  @panes[:right].content_update = true
@@ -4772,6 +4855,7 @@ module Heathrow
4772
4855
  chr = getchr
4773
4856
  if chr == 'y'
4774
4857
  IO.popen('xclip -selection clipboard', 'w') { |io| io.write(response.strip) } rescue nil
4858
+ IO.popen('xclip -selection primary', 'w') { |io| io.write(response.strip) } rescue nil
4775
4859
  set_feedback("Corrected text copied to clipboard", 156, 2)
4776
4860
  end
4777
4861
  @panes[:right].content_update = true
@@ -5819,7 +5903,7 @@ module Heathrow
5819
5903
  ['%d %b %H:%M', 'DD Mon HH:MM'],
5820
5904
  ['%b %d %H:%M', 'Mon DD HH:MM']
5821
5905
  ]
5822
- sort_orders = ['latest', 'alphabetical', 'sender', 'conversation', 'unread', 'source']
5906
+ sort_orders = ['latest', 'alphabetical', 'sender', 'from', 'conversation', 'unread', 'source']
5823
5907
  border_labels = ['none', 'right', 'both', 'left']
5824
5908
  # Build view choices: A, N, plus any user-defined views
5825
5909
  view_choices = [['A', 'All'], ['N', 'New/Unread']]
@@ -6249,11 +6333,12 @@ module Heathrow
6249
6333
  end
6250
6334
 
6251
6335
  def cycle_sort_order
6252
- # Cycle through: latest -> alphabetical -> sender -> conversation -> unread -> source -> latest
6336
+ # Cycle through: latest -> alphabetical -> sender -> from -> conversation -> unread -> source -> latest
6253
6337
  @sort_order = case @sort_order
6254
6338
  when 'latest' then 'alphabetical'
6255
6339
  when 'alphabetical' then 'sender'
6256
- when 'sender' then 'conversation'
6340
+ when 'sender' then 'from'
6341
+ when 'from' then 'conversation'
6257
6342
  when 'conversation' then 'unread'
6258
6343
  when 'unread' then 'source'
6259
6344
  else 'latest' # This handles 'source' and any other value
@@ -6391,6 +6476,24 @@ module Heathrow
6391
6476
  end
6392
6477
  end
6393
6478
  end
6479
+ when 'from'
6480
+ # Group by sender, most recently active sender first
6481
+ # Within each sender group, newest message first
6482
+ latest_per_sender = {}
6483
+ @filtered_messages.each do |m|
6484
+ s = display_sender(m).downcase
6485
+ t = ts_cache[m.object_id] || Time.at(0)
6486
+ latest_per_sender[s] = t if !latest_per_sender[s] || t > latest_per_sender[s]
6487
+ end
6488
+ @filtered_messages.sort! do |a, b|
6489
+ sa = display_sender(a).downcase
6490
+ sb = display_sender(b).downcase
6491
+ if sa == sb
6492
+ (ts_cache[b.object_id] || Time.at(0)) <=> (ts_cache[a.object_id] || Time.at(0))
6493
+ else
6494
+ (latest_per_sender[sb] || Time.at(0)) <=> (latest_per_sender[sa] || Time.at(0))
6495
+ end
6496
+ end
6394
6497
  when 'unread'
6395
6498
  # Sort by unread first, then by timestamp
6396
6499
  @filtered_messages.sort! do |a, b|
@@ -6799,8 +6902,9 @@ Required: URL, optional CSS selector
6799
6902
  #{"D".fg(10)} = Cycle date/time format
6800
6903
  #{"o".fg(10)} = Cycle sort order (newest/oldest/unread first)
6801
6904
  #{"i".fg(10)} = Invert sort order (toggle reverse)
6802
- #{"Y".fg(10)} = Copy right pane content to clipboard
6905
+ #{"Y/C-y".fg(10)} = Copy right pane content to clipboard
6803
6906
  #{"P".fg(10)} = Settings popup (theme, format, etc.)
6907
+ #{"@".fg(10)} = Address book (a=add sender, e=edit file)
6804
6908
  #{custom_bindings_help}
6805
6909
  Press #{"?".fg(10)} again for extended help • Any other key to continue
6806
6910
  HELP
@@ -7022,7 +7126,8 @@ Required: URL, optional CSS selector
7022
7126
  msg = current_message
7023
7127
  return unless msg && msg['id']
7024
7128
  id_str = "heathrow:#{msg['id']}"
7025
- IO.popen('xclip -selection clipboard', 'w') { |io| io.write(id_str) }
7129
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(id_str) } rescue nil
7130
+ IO.popen('xclip -selection primary', 'w') { |io| io.write(id_str) } rescue nil
7026
7131
  set_feedback("Message ID #{msg['id']} copied", 156, 2)
7027
7132
  rescue => e
7028
7133
  set_feedback("Copy failed: #{e.message}", 196, 2)
@@ -7033,7 +7138,8 @@ Required: URL, optional CSS selector
7033
7138
  if text && !text.strip.empty?
7034
7139
  # Strip ANSI codes for clean clipboard content
7035
7140
  clean = text.gsub(/\e\[[0-9;]*m/, '')
7036
- IO.popen('xclip -selection clipboard', 'w') { |io| io.write(clean) }
7141
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(clean) } rescue nil
7142
+ IO.popen('xclip -selection primary', 'w') { |io| io.write(clean) } rescue nil
7037
7143
  set_feedback("Copied to clipboard", 156, 2)
7038
7144
  else
7039
7145
  set_feedback("Nothing to copy", 196, 2)
@@ -7042,6 +7148,69 @@ Required: URL, optional CSS selector
7042
7148
  set_feedback("Copy failed: #{e.message}", 196, 2)
7043
7149
  end
7044
7150
 
7151
+ # Address book: add sender or edit file
7152
+ def address_book_menu
7153
+ @panes[:bottom].text = " @: a=Add sender to address book, e=Edit address book".fg(245)
7154
+ @panes[:bottom].refresh
7155
+ chr = getchr
7156
+ case chr
7157
+ when 'a'
7158
+ add_sender_to_address_book
7159
+ when 'e'
7160
+ edit_address_book
7161
+ else
7162
+ set_feedback("Cancelled", 245, 1)
7163
+ end
7164
+ end
7165
+
7166
+ def add_sender_to_address_book
7167
+ msg = current_message
7168
+ return set_feedback("No message selected", 196, 2) unless msg
7169
+
7170
+ sender = msg['sender'].to_s.strip
7171
+ sender_name = msg['sender_name'].to_s.strip
7172
+ return set_feedback("No sender", 196, 2) if sender.empty?
7173
+
7174
+ # Build full address: "Name <email>" or just "email"
7175
+ full = if sender_name.empty? || sender_name == sender
7176
+ sender
7177
+ else
7178
+ "#{sender_name} <#{sender}>"
7179
+ end
7180
+
7181
+ # Suggest alias from name or email local part
7182
+ suggested = if !sender_name.empty? && sender_name != sender
7183
+ sender_name.split.first.downcase
7184
+ else
7185
+ sender.split('@').first.downcase
7186
+ end
7187
+
7188
+ @panes[:bottom].text = " Add: #{full}".fg(156)
7189
+ @panes[:bottom].refresh
7190
+ alias_name = @panes[:bottom].ask(" Alias: ", suggested)
7191
+ return set_feedback("Cancelled", 245, 1) if alias_name.nil? || alias_name.strip.empty?
7192
+ alias_name = alias_name.strip.gsub(/\s+/, '.')
7193
+
7194
+ ab_path = File.expand_path('~/setup/addressbook')
7195
+ File.open(ab_path, 'a') { |f| f.puts "alias #{alias_name} #{full}" }
7196
+ @address_book = AddressBook.new(ab_path)
7197
+ set_feedback("Added: #{alias_name} → #{full}", 156, 3)
7198
+ rescue => e
7199
+ set_feedback("Failed: #{e.message}", 196, 2)
7200
+ end
7201
+
7202
+ def edit_address_book
7203
+ ab_path = File.expand_path('~/setup/addressbook')
7204
+ editor = ENV['EDITOR'] || 'vim'
7205
+ Rcurses.clear_screen
7206
+ system("#{editor} #{ab_path}")
7207
+ @address_book = AddressBook.new(ab_path)
7208
+ setup_display
7209
+ create_panes
7210
+ render_all
7211
+ set_feedback("Address book reloaded", 156, 2)
7212
+ end
7213
+
7045
7214
  def set_view_top_bg
7046
7215
  view = @views[@current_view]
7047
7216
  unless view
@@ -8072,7 +8241,6 @@ Required: URL, optional CSS selector
8072
8241
 
8073
8242
  # Format the event for display
8074
8243
  lines = []
8075
- lines << ("─" * 50).fg(238)
8076
8244
  lines << "Calendar Event".b.fg(226)
8077
8245
  lines << ""
8078
8246
  lines << "WHAT: #{event[:summary]}".fg(156) if event[:summary]
@@ -8093,7 +8261,6 @@ Required: URL, optional CSS selector
8093
8261
  end
8094
8262
  # Skip description (email body already shows it, and ICS descriptions
8095
8263
  # often contain raw URLs that can overflow the pane)
8096
- lines << ("─" * 50).fg(238)
8097
8264
  lines.join("\n")
8098
8265
  rescue => e
8099
8266
  nil # Don't crash on calendar parse errors
@@ -1,3 +1,3 @@
1
1
  module Heathrow
2
- VERSION = '0.7.6'
2
+ VERSION = '0.7.8'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: heathrow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.6
4
+ version: 0.7.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-03-21 00:00:00.000000000 Z
12
+ date: 2026-03-25 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rcurses
@@ -66,6 +66,7 @@ files:
66
66
  - docs/WHATSAPP_TELEGRAM_SETUP.md
67
67
  - heathrow.gemspec
68
68
  - heathrowd.service
69
+ - img/heathrow-kb.svg
69
70
  - img/heathrow.svg
70
71
  - img/rss_threaded.png
71
72
  - img/sources.png