+416 -37 +/-10 browse
1 | diff --git a/web/build.rs b/web/build.rs |
2 | index 0e5f64d..bd29123 100644 |
3 | --- a/web/build.rs |
4 | +++ b/web/build.rs |
5 | @@ -79,6 +79,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
6 | } |
7 | } |
8 | |
9 | + compressed.write_all(b"&[")?; |
10 | for (name, template_path) in templates { |
11 | let mut templ = OpenOptions::new() |
12 | .write(false) |
13 | @@ -98,6 +99,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
14 | compressed.write_all(format!("{:?}", compressed_bytes).as_bytes())?; |
15 | compressed.write_all(b"),")?; |
16 | } |
17 | + compressed.write_all(b"]")?; |
18 | |
19 | commit_sha(); |
20 | Ok(()) |
21 | diff --git a/web/src/lists.rs b/web/src/lists.rs |
22 | index 3c4c9ea..3fe4b6a 100644 |
23 | --- a/web/src/lists.rs |
24 | +++ b/web/src/lists.rs |
25 | @@ -47,13 +47,31 @@ pub async fn list( |
26 | .map(|user| db.list_subscription_by_address(list.pk, &user.address).ok()); |
27 | |
28 | let posts = db.list_posts(list.pk, None)?; |
29 | + let post_map = posts |
30 | + .iter() |
31 | + .map(|p| (p.message_id.as_str(), p)) |
32 | + .collect::<IndexMap<&str, &mailpot::models::DbVal<mailpot::models::Post>>>(); |
33 | let mut hist = months |
34 | .iter() |
35 | .map(|m| (m.to_string(), [0usize; 31])) |
36 | .collect::<HashMap<String, [usize; 31]>>(); |
37 | - let posts_ctx = posts |
38 | - .iter() |
39 | - .map(|post| { |
40 | + let envelopes: Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>> = |
41 | + Default::default(); |
42 | + let mut env_lock = envelopes.write().unwrap(); |
43 | + |
44 | + for post in &posts { |
45 | + let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None) |
46 | + .expect("Could not parse mail"); |
47 | + env_lock.insert(envelope.hash(), envelope); |
48 | + } |
49 | + let mut threads: melib::Threads = melib::Threads::new(posts.len()); |
50 | + drop(env_lock); |
51 | + threads.amend(&envelopes); |
52 | + let roots = thread_roots(&envelopes, &mut threads); |
53 | + let posts_ctx = roots |
54 | + .into_iter() |
55 | + .map(|(thread, length, _timestamp)| { |
56 | + let post = &post_map[&thread.message_id.as_str()]; |
57 | //2019-07-14T14:21:02 |
58 | if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) { |
59 | hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1; |
60 | @@ -70,7 +88,7 @@ pub async fn list( |
61 | { |
62 | subject_ref = subject_ref[2 + list.id.len()..].trim(); |
63 | } |
64 | - minijinja::context! { |
65 | + let ret = minijinja::context! { |
66 | pk => post.pk, |
67 | list => post.list, |
68 | subject => subject_ref, |
69 | @@ -79,8 +97,10 @@ pub async fn list( |
70 | message => post.message, |
71 | timestamp => post.timestamp, |
72 | datetime => post.datetime, |
73 | - root_url_prefix => &state.root_url_prefix, |
74 | - } |
75 | + replies => length.saturating_sub(1), |
76 | + last_active => thread.datetime, |
77 | + }; |
78 | + ret |
79 | }) |
80 | .collect::<Vec<_>>(); |
81 | let crumbs = vec![ |
82 | @@ -123,7 +143,7 @@ pub async fn list_post( |
83 | auth: AuthContext, |
84 | State(state): State<Arc<AppState>>, |
85 | ) -> Result<Html<String>, ResponseError> { |
86 | - let db = Connection::open_db(state.conf.clone())?; |
87 | + let db = Connection::open_db(state.conf.clone())?.trusted(); |
88 | let Some(list) = (match id { |
89 | ListPathIdentifier::Pk(id) => db.list(id)?, |
90 | ListPathIdentifier::Id(id) => db.list_by_id(id)?, |
91 | @@ -146,6 +166,7 @@ pub async fn list_post( |
92 | StatusCode::NOT_FOUND, |
93 | )); |
94 | }; |
95 | + let thread = super::utils::thread_db(&db, list.pk, &post.message_id); |
96 | let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None) |
97 | .with_status(StatusCode::BAD_REQUEST)?; |
98 | let body = envelope.body_bytes(post.message.as_slice()); |
99 | @@ -190,6 +211,7 @@ pub async fn list_post( |
100 | message => post.message, |
101 | timestamp => post.timestamp, |
102 | datetime => post.datetime, |
103 | + thread => thread, |
104 | root_url_prefix => &state.root_url_prefix, |
105 | current_user => auth.current_user, |
106 | user_context => user_context, |
107 | @@ -485,3 +507,67 @@ pub enum SubscriptionPolicySettings { |
108 | Request, |
109 | Custom, |
110 | } |
111 | + |
112 | + /// Raw post page. |
113 | + pub async fn post_raw( |
114 | + ListPostRawPath(id, msg_id): ListPostRawPath, |
115 | + State(state): State<Arc<AppState>>, |
116 | + ) -> Result<String, ResponseError> { |
117 | + let db = Connection::open_db(state.conf.clone())?.trusted(); |
118 | + let Some(list) = (match id { |
119 | + ListPathIdentifier::Pk(id) => db.list(id)?, |
120 | + ListPathIdentifier::Id(id) => db.list_by_id(id)?, |
121 | + }) else { |
122 | + return Err(ResponseError::new( |
123 | + "List not found".to_string(), |
124 | + StatusCode::NOT_FOUND, |
125 | + )); |
126 | + }; |
127 | + |
128 | + let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? { |
129 | + post |
130 | + } else { |
131 | + return Err(ResponseError::new( |
132 | + format!("Post with Message-ID {} not found", msg_id), |
133 | + StatusCode::NOT_FOUND, |
134 | + )); |
135 | + }; |
136 | + Ok(String::from_utf8_lossy(&post.message).to_string()) |
137 | + } |
138 | + |
139 | + /// .eml post page. |
140 | + pub async fn post_eml( |
141 | + ListPostEmlPath(id, msg_id): ListPostEmlPath, |
142 | + State(state): State<Arc<AppState>>, |
143 | + ) -> Result<impl IntoResponse, ResponseError> { |
144 | + let db = Connection::open_db(state.conf.clone())?.trusted(); |
145 | + let Some(list) = (match id { |
146 | + ListPathIdentifier::Pk(id) => db.list(id)?, |
147 | + ListPathIdentifier::Id(id) => db.list_by_id(id)?, |
148 | + }) else { |
149 | + return Err(ResponseError::new( |
150 | + "List not found".to_string(), |
151 | + StatusCode::NOT_FOUND, |
152 | + )); |
153 | + }; |
154 | + |
155 | + let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? { |
156 | + post |
157 | + } else { |
158 | + return Err(ResponseError::new( |
159 | + format!("Post with Message-ID {} not found", msg_id), |
160 | + StatusCode::NOT_FOUND, |
161 | + )); |
162 | + }; |
163 | + let mut response = post.into_inner().message.into_response(); |
164 | + response.headers_mut().insert( |
165 | + http::header::CONTENT_TYPE, |
166 | + http::HeaderValue::from_static("application/octet-stream"), |
167 | + ); |
168 | + response.headers_mut().insert( |
169 | + http::header::CONTENT_DISPOSITION, |
170 | + http::HeaderValue::try_from(format!("attachment; filename=\"{}.eml\"", msg_id)).unwrap(), |
171 | + ); |
172 | + |
173 | + Ok(response) |
174 | + } |
175 | diff --git a/web/src/main.rs b/web/src/main.rs |
176 | index c2bc13f..1fabdcc 100644 |
177 | --- a/web/src/main.rs |
178 | +++ b/web/src/main.rs |
179 | @@ -62,6 +62,8 @@ async fn main() { |
180 | .route("/", get(root)) |
181 | .typed_get(list) |
182 | .typed_get(list_post) |
183 | + .typed_get(post_raw) |
184 | + .typed_get(post_eml) |
185 | .typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect( |
186 | Role::User.., |
187 | Arc::clone(&login_url), |
188 | diff --git a/web/src/minijinja_utils.rs b/web/src/minijinja_utils.rs |
189 | index 220880d..7a6d36a 100644 |
190 | --- a/web/src/minijinja_utils.rs |
191 | +++ b/web/src/minijinja_utils.rs |
192 | @@ -37,6 +37,7 @@ lazy_static::lazy_static! { |
193 | } |
194 | } |
195 | add!(function calendarize, |
196 | + strip_carets, |
197 | login_path, |
198 | logout_path, |
199 | settings_path, |
200 | @@ -44,7 +45,9 @@ lazy_static::lazy_static! { |
201 | list_path, |
202 | list_settings_path, |
203 | list_edit_path, |
204 | - list_post_path |
205 | + list_post_path, |
206 | + post_raw_path, |
207 | + post_eml_path |
208 | ); |
209 | add!(filter pluralize); |
210 | #[cfg(not(feature = "zstd"))] |
211 | @@ -381,3 +384,16 @@ pub fn pluralize( |
212 | (true, _, Some(suffix)) => suffix.into(), |
213 | }) |
214 | } |
215 | + |
216 | + pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> { |
217 | + Ok(Value::from( |
218 | + arg.as_str() |
219 | + .ok_or_else(|| { |
220 | + minijinja::Error::new( |
221 | + minijinja::ErrorKind::InvalidOperation, |
222 | + format!("argument to strip_carets() is of type {}", arg.kind()), |
223 | + ) |
224 | + })? |
225 | + .strip_carets(), |
226 | + )) |
227 | + } |
228 | diff --git a/web/src/minijinja_utils/compressed.rs b/web/src/minijinja_utils/compressed.rs |
229 | index afd8501..8965d02 100644 |
230 | --- a/web/src/minijinja_utils/compressed.rs |
231 | +++ b/web/src/minijinja_utils/compressed.rs |
232 | @@ -17,4 +17,4 @@ |
233 | * along with this program. If not, see <https://www.gnu.org/licenses/>. |
234 | */ |
235 | |
236 | - pub const COMPRESSED: &[(&str, &[u8])] = &[include!("compressed.data")]; |
237 | + pub const COMPRESSED: &[(&str, &[u8])] = include!("compressed.data"); |
238 | diff --git a/web/src/templates/css.html b/web/src/templates/css.html |
239 | index 534d1a1..06560d8 100644 |
240 | --- a/web/src/templates/css.html |
241 | +++ b/web/src/templates/css.html |
242 | @@ -49,7 +49,7 @@ |
243 | text-rendering:optimizeLegibility; |
244 | -webkit-font-smoothing:antialiased; |
245 | -moz-osx-font-smoothing:grayscale; |
246 | - font-family:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif; |
247 | + font-family:var(--sans-serif-system-stack); |
248 | font-size:100%; |
249 | } |
250 | |
251 | @@ -149,9 +149,12 @@ |
252 | } |
253 | |
254 | :root { |
255 | + --emoji-system-stack: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; |
256 | --monospace-system-stack: /* apple */ ui-monospace, SFMono-Regular, Menlo, Monaco, |
257 | /* windows */ "Cascadia Mono", "Segoe UI Mono", Consolas, |
258 | - /* free unixes */ "Liberation Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace; |
259 | + /* free unixes */ "DejaVu Sans Mono", "Liberation Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace, var(--emoji-system-stack); |
260 | + --sans-serif-system-stack:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif, var(--emoji-system-stack); |
261 | + --grotesque-system-stack: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif, var(--emoji-system-stack); |
262 | --text-primary: CanvasText; |
263 | --text-faded: GrayText; |
264 | --horizontal-rule: #88929d; |
265 | @@ -167,7 +170,7 @@ |
266 | --text-link: #0069c2; |
267 | --text-invert: #fff; |
268 | --background-primary: #fff; |
269 | - --background-secondary: #f9f9fb; |
270 | + --background-secondary: #ebebeb; |
271 | --background-tertiary: #fff; |
272 | --background-toc-active: #ebeaea; |
273 | --background-mark-yellow: #c7b70066; |
274 | @@ -344,6 +347,7 @@ |
275 | } |
276 | main.layout>.rightside { grid-area: rightside; } |
277 | main.layout>footer { |
278 | + font-family: var(--grotesque-system-stack); |
279 | grid-area: footer; |
280 | border-top: 2px inset; |
281 | margin-block-start: 1rem; |
282 | @@ -370,6 +374,8 @@ |
283 | |
284 | main.layout>div.header>h1 { |
285 | margin: 1rem; |
286 | + font-family: var(--grotesque-system-stack); |
287 | + font-size: xx-large; |
288 | } |
289 | |
290 | main.layout>div.header>div.page-header { |
291 | @@ -416,6 +422,7 @@ |
292 | |
293 | main.layout>div.header h2.page-title { |
294 | margin: 1rem 0px; |
295 | + font-family: var(--grotesque-system-stack); |
296 | } |
297 | |
298 | nav.breadcrumbs { |
299 | @@ -649,6 +656,10 @@ |
300 | margin-inline-end: 1rem; |
301 | } |
302 | |
303 | + table.headers { |
304 | + margin-left: -3vw; |
305 | + } |
306 | + |
307 | table.headers tr>th { |
308 | text-align: right; |
309 | color: var(--text-faded); |
310 | @@ -657,29 +668,44 @@ |
311 | table.headers th[scope="row"] { |
312 | padding-right: .5rem; |
313 | vertical-align: top; |
314 | + font-family: var(--grotesque-system-stack); |
315 | } |
316 | |
317 | table.headers tr>td { |
318 | overflow-wrap: break-word; |
319 | hyphens: auto; |
320 | + word-wrap: anywhere; |
321 | + word-break: break-all; |
322 | + width: 50ch; |
323 | } |
324 | |
325 | div.post-body { |
326 | - margin: 1rem; |
327 | + margin: 1rem 0px; |
328 | } |
329 | |
330 | div.post-body>pre { |
331 | - max-width: 69vw; |
332 | overflow-wrap: break-word; |
333 | white-space: pre-line; |
334 | - margin-left: min(5rem, 14vw); |
335 | hyphens: auto; |
336 | + background-color: var(--background-secondary); |
337 | + outline: 1rem solid var(--background-secondary); |
338 | + margin: 2rem 0; |
339 | + line-height: 1.333; |
340 | + } |
341 | + |
342 | + div.post-body:not(:last-child) { |
343 | + padding-bottom: .5rem; |
344 | + border-bottom: 1px solid var(--horizontal-rule); |
345 | } |
346 | |
347 | td.message-id, |
348 | span.message-id{ |
349 | color: var(--text-faded); |
350 | } |
351 | + .message-id>a { |
352 | + overflow-wrap: break-word; |
353 | + hyphens: auto; |
354 | + } |
355 | td.message-id:before, |
356 | span.message-id:before{ |
357 | content: '<'; |
358 | @@ -705,6 +731,13 @@ |
359 | span.faded:is(:focus, :hover, :focus-visible, :focus-within) { |
360 | color: revert; |
361 | } |
362 | + details.reply-details button { |
363 | + padding: 0.3rem; |
364 | + font-size: medium; |
365 | + min-width: 0; |
366 | + margin-block-start: 0.2rem; |
367 | + display: inline-block; |
368 | + } |
369 | |
370 | ul.lists { |
371 | padding: 1rem 2rem; |
372 | diff --git a/web/src/templates/lists/list.html b/web/src/templates/lists/list.html |
373 | index 113b326..4767525 100644 |
374 | --- a/web/src/templates/lists/list.html |
375 | +++ b/web/src/templates/lists/list.html |
376 | @@ -94,6 +94,7 @@ |
377 | <div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}"> |
378 | <span class="subject"><a id="post_link_{{ loop.index }}" href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a> <span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span> |
379 | <span class="metadata"><span aria-hidden="true">👤 </span><span class="from" title="post author">{{ post.address }}</span><span aria-hidden="true"> 📆 </span><span class="date" title="post date">{{ post.datetime }}</span></span> |
380 | + {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">💓 </span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %} |
381 | <span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span> |
382 | </div> |
383 | {% endfor %} |
384 | diff --git a/web/src/templates/lists/post.html b/web/src/templates/lists/post.html |
385 | index 27d0c68..3b9324f 100644 |
386 | --- a/web/src/templates/lists/post.html |
387 | +++ b/web/src/templates/lists/post.html |
388 | @@ -38,5 +38,29 @@ |
389 | <div class="post-body"> |
390 | <pre title="E-mail text content">{{ body }}</pre> |
391 | </div> |
392 | + {% for (depth, post, body, date) in thread %} |
393 | + <table class="headers" title="E-mail headers"> |
394 | + <caption class="screen-reader-only">E-mail headers</caption> |
395 | + <tr> |
396 | + <th scope="row">From:</th> |
397 | + <td>{{ post.address }}</td> |
398 | + </tr> |
399 | + <tr> |
400 | + <th scope="row">Date:</th> |
401 | + <td class="faded">{{ date }}</td> |
402 | + </tr> |
403 | + <tr> |
404 | + <th scope="row">Message-ID:</th> |
405 | + <td class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td> |
406 | + </tr> |
407 | + <tr> |
408 | + <td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ root_url_prefix }}{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ root_url_prefix }}{{ post_eml_path(list.id, post.message_id) }}">Download as <code>eml</code> (RFC 5322 format)</a></details></td> |
409 | + </tr> |
410 | + </table> |
411 | + <div class="post-body"> |
412 | + <pre title="E-mail text content">{{ body }}</pre> |
413 | + </div> |
414 | + |
415 | + {% endfor %} |
416 | </div> |
417 | {% include "footer.html" %} |
418 | diff --git a/web/src/typed_paths.rs b/web/src/typed_paths.rs |
419 | index 5ebd35c..4db1660 100644 |
420 | --- a/web/src/typed_paths.rs |
421 | +++ b/web/src/typed_paths.rs |
422 | @@ -91,6 +91,14 @@ impl From<&DbVal<mailpot::models::MailingList>> for ListPath { |
423 | pub struct ListPostPath(pub ListPathIdentifier, pub String); |
424 | |
425 | #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
426 | + #[typed_path("/list/:id/posts/:msgid/raw/")] |
427 | + pub struct ListPostRawPath(pub ListPathIdentifier, pub String); |
428 | + |
429 | + #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
430 | + #[typed_path("/list/:id/posts/:msgid/eml/")] |
431 | + pub struct ListPostEmlPath(pub ListPathIdentifier, pub String); |
432 | + |
433 | + #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
434 | #[typed_path("/list/:id/edit/")] |
435 | pub struct ListEditPath(pub ListPathIdentifier); |
436 | |
437 | @@ -159,31 +167,39 @@ list_id_impl!(list_edit_path, ListEditPath); |
438 | list_id_impl!(list_subscribers_path, ListEditSubscribersPath); |
439 | list_id_impl!(list_candidates_path, ListEditCandidatesPath); |
440 | |
441 | - pub fn list_post_path(id: Value, msg_id: Value) -> std::result::Result<Value, Error> { |
442 | - let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else { |
443 | - format!("<{s}>") |
444 | - }) else { |
445 | - return Err(Error::new( |
446 | - minijinja::ErrorKind::UnknownMethod, |
447 | - "Second argument of list_post_path must be a string." |
448 | - )); |
449 | - }; |
450 | + macro_rules! list_post_impl { |
451 | + ($ident:ident, $ty:tt) => { |
452 | + pub fn $ident(id: Value, msg_id: Value) -> std::result::Result<Value, Error> { |
453 | + let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else { |
454 | + format!("<{s}>") |
455 | + }) else { |
456 | + return Err(Error::new( |
457 | + minijinja::ErrorKind::UnknownMethod, |
458 | + "Second argument of list_post_path must be a string." |
459 | + )); |
460 | + }; |
461 | |
462 | - if let Some(id) = id.as_str() { |
463 | - return Ok(Value::from_safe_string( |
464 | - ListPostPath(ListPathIdentifier::Id(id.to_string()), msg_id) |
465 | - .to_crumb() |
466 | - .to_string(), |
467 | - )); |
468 | - } |
469 | - let pk = id.try_into()?; |
470 | - Ok(Value::from_safe_string( |
471 | - ListPostPath(ListPathIdentifier::Pk(pk), msg_id) |
472 | - .to_crumb() |
473 | - .to_string(), |
474 | - )) |
475 | + if let Some(id) = id.as_str() { |
476 | + return Ok(Value::from_safe_string( |
477 | + $ty(ListPathIdentifier::Id(id.to_string()), msg_id) |
478 | + .to_crumb() |
479 | + .to_string(), |
480 | + )); |
481 | + } |
482 | + let pk = id.try_into()?; |
483 | + Ok(Value::from_safe_string( |
484 | + $ty(ListPathIdentifier::Pk(pk), msg_id) |
485 | + .to_crumb() |
486 | + .to_string(), |
487 | + )) |
488 | + } |
489 | + }; |
490 | } |
491 | |
492 | + list_post_impl!(list_post_path, ListPostPath); |
493 | + list_post_impl!(post_raw_path, ListPostRawPath); |
494 | + list_post_impl!(post_eml_path, ListPostEmlPath); |
495 | + |
496 | pub mod tsr { |
497 | use std::{borrow::Cow, convert::Infallible}; |
498 | |
499 | diff --git a/web/src/utils.rs b/web/src/utils.rs |
500 | index 5a2b724..35daa40 100644 |
501 | --- a/web/src/utils.rs |
502 | +++ b/web/src/utils.rs |
503 | @@ -212,3 +212,202 @@ where |
504 | .map(|s| Value::from_safe_string(s.to_string())) |
505 | .serialize(ser) |
506 | } |
507 | + |
508 | + pub struct ThreadEntry { |
509 | + pub hash: melib::EnvelopeHash, |
510 | + pub depth: usize, |
511 | + pub thread_node: melib::ThreadNodeHash, |
512 | + pub thread: melib::ThreadHash, |
513 | + pub from: String, |
514 | + pub message_id: String, |
515 | + pub timestamp: u64, |
516 | + pub datetime: String, |
517 | + } |
518 | + |
519 | + pub fn thread_db( |
520 | + db: &mailpot::Connection, |
521 | + list: i64, |
522 | + root: &str, |
523 | + ) -> Vec<(i64, DbVal<mailpot::models::Post>, String, String)> { |
524 | + let mut stmt = db |
525 | + .connection |
526 | + .prepare( |
527 | + "WITH RECURSIVE cte_replies AS MATERIALIZED |
528 | + ( |
529 | + SELECT |
530 | + pk, |
531 | + message_id, |
532 | + REPLACE( |
533 | + TRIM( |
534 | + SUBSTR( |
535 | + CAST(message AS TEXT), |
536 | + INSTR( |
537 | + CAST(message AS TEXT), |
538 | + 'In-Reply-To: ' |
539 | + ) |
540 | + + |
541 | + LENGTH('in-reply-to: '), |
542 | + INSTR( |
543 | + SUBSTR( |
544 | + CAST(message AS TEXT), |
545 | + INSTR( |
546 | + CAST(message AS TEXT), |
547 | + 'In-Reply-To: ') |
548 | + + |
549 | + LENGTH('in-reply-to: ') |
550 | + ), |
551 | + '>' |
552 | + ) |
553 | + ) |
554 | + ), |
555 | + ' ', |
556 | + '' |
557 | + ) AS in_reply_to, |
558 | + INSTR( |
559 | + CAST(message AS TEXT), |
560 | + 'In-Reply-To: ' |
561 | + ) AS offset |
562 | + FROM post |
563 | + WHERE |
564 | + offset > 0 |
565 | + UNION |
566 | + SELECT |
567 | + pk, |
568 | + message_id, |
569 | + NULL AS in_reply_to, |
570 | + INSTR( |
571 | + CAST(message AS TEXT), |
572 | + 'In-Reply-To: ' |
573 | + ) AS offset |
574 | + FROM post |
575 | + WHERE |
576 | + offset = 0 |
577 | + ), |
578 | + cte_thread(parent, root, depth) AS ( |
579 | + SELECT DISTINCT |
580 | + message_id AS parent, |
581 | + message_id AS root, |
582 | + 0 AS depth |
583 | + FROM cte_replies |
584 | + WHERE |
585 | + in_reply_to IS NULL |
586 | + UNION ALL |
587 | + SELECT |
588 | + t.message_id AS parent, |
589 | + cte_thread.root AS root, |
590 | + (cte_thread.depth + 1) AS depth |
591 | + FROM cte_replies |
592 | + AS t |
593 | + JOIN |
594 | + cte_thread |
595 | + ON cte_thread.parent = t.in_reply_to |
596 | + WHERE t.in_reply_to IS NOT NULL |
597 | + ) |
598 | + SELECT * FROM cte_thread WHERE root = ? ORDER BY root, depth;", |
599 | + ) |
600 | + .unwrap(); |
601 | + let iter = stmt |
602 | + .query_map(rusqlite::params![root], |row| { |
603 | + let parent: String = row.get("parent")?; |
604 | + let root: String = row.get("root")?; |
605 | + let depth: i64 = row.get("depth")?; |
606 | + Ok((parent, root, depth)) |
607 | + }) |
608 | + .unwrap(); |
609 | + let mut ret = vec![]; |
610 | + for post in iter { |
611 | + let post = post.unwrap(); |
612 | + ret.push(post); |
613 | + } |
614 | + let posts = db.list_posts(list, None).unwrap(); |
615 | + ret.into_iter() |
616 | + .filter_map(|(m, _, depth)| { |
617 | + posts |
618 | + .iter() |
619 | + .find(|p| m.as_str().strip_carets() == p.message_id.as_str().strip_carets()) |
620 | + .map(|p| { |
621 | + let envelope = melib::Envelope::from_bytes(p.message.as_slice(), None).unwrap(); |
622 | + let body = envelope.body_bytes(p.message.as_slice()); |
623 | + let body_text = body.text(); |
624 | + let date = envelope.date_as_str().to_string(); |
625 | + (depth, p.clone(), body_text, date) |
626 | + }) |
627 | + }) |
628 | + .skip(1) |
629 | + .collect() |
630 | + } |
631 | + |
632 | + pub fn thread( |
633 | + envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>, |
634 | + threads: &melib::Threads, |
635 | + root_env_hash: melib::EnvelopeHash, |
636 | + ) -> Vec<ThreadEntry> { |
637 | + let env_lock = envelopes.read().unwrap(); |
638 | + let thread = threads.envelope_to_thread[&root_env_hash]; |
639 | + let mut ret = vec![]; |
640 | + for (depth, t) in threads.thread_group_iter(thread) { |
641 | + let hash = threads.thread_nodes[&t].message.unwrap(); |
642 | + ret.push(ThreadEntry { |
643 | + hash, |
644 | + depth, |
645 | + thread_node: t, |
646 | + thread, |
647 | + message_id: env_lock[&hash].message_id().to_string(), |
648 | + from: env_lock[&hash].field_from_to_string(), |
649 | + datetime: env_lock[&hash].date_as_str().to_string(), |
650 | + timestamp: env_lock[&hash].timestamp, |
651 | + }); |
652 | + } |
653 | + ret |
654 | + } |
655 | + |
656 | + pub fn thread_roots( |
657 | + envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>, |
658 | + threads: &mut melib::Threads, |
659 | + ) -> Vec<(ThreadEntry, usize, u64)> { |
660 | + let items = threads.roots(); |
661 | + let env_lock = envelopes.read().unwrap(); |
662 | + let mut ret = vec![]; |
663 | + 'items_for_loop: for thread in items { |
664 | + let mut iter_ptr = threads.thread_ref(thread).root(); |
665 | + let thread_node = &threads.thread_nodes()[&iter_ptr]; |
666 | + let root_env_hash = if let Some(h) = thread_node.message().or_else(|| { |
667 | + if thread_node.children().is_empty() { |
668 | + return None; |
669 | + } |
670 | + iter_ptr = thread_node.children()[0]; |
671 | + while threads.thread_nodes()[&iter_ptr].message().is_none() { |
672 | + if threads.thread_nodes()[&iter_ptr].children().is_empty() { |
673 | + return None; |
674 | + } |
675 | + iter_ptr = threads.thread_nodes()[&iter_ptr].children()[0]; |
676 | + } |
677 | + threads.thread_nodes()[&iter_ptr].message() |
678 | + }) { |
679 | + h |
680 | + } else { |
681 | + continue 'items_for_loop; |
682 | + }; |
683 | + if !env_lock.contains_key(&root_env_hash) { |
684 | + panic!("key = {}", root_env_hash); |
685 | + } |
686 | + let envelope: &melib::Envelope = &env_lock[&root_env_hash]; |
687 | + let tref = threads.thread_ref(thread); |
688 | + ret.push(( |
689 | + ThreadEntry { |
690 | + hash: root_env_hash, |
691 | + depth: 0, |
692 | + thread_node: iter_ptr, |
693 | + thread, |
694 | + message_id: envelope.message_id().to_string(), |
695 | + from: envelope.field_from_to_string(), |
696 | + datetime: envelope.date_as_str().to_string(), |
697 | + timestamp: envelope.timestamp, |
698 | + }, |
699 | + tref.len, |
700 | + tref.date, |
701 | + )); |
702 | + } |
703 | + ret.sort_by_key(|(_, _, key)| std::cmp::Reverse(*key)); |
704 | + ret |
705 | + } |