1use crate::tools::IsNoneOrEmpty;
3
4pub fn escape_message_footer_marks(text: &str) -> String {
13 if let Some(text) = text.strip_prefix("--") {
14 "-\u{200B}-".to_string() + &text.replace("\n--", "\n-\u{200B}-")
15 } else {
16 text.replace("\n--", "\n-\u{200B}-")
17 }
18}
19
20pub(crate) fn remove_message_footer<'a>(
25 lines: &'a [&str],
26) -> (&'a [&'a str], Option<&'a [&'a str]>) {
27 let mut nearly_standard_footer = None;
28 for (ix, &line) in lines.iter().enumerate() {
29 match line {
30 "-- " | "-- " => return (lines.get(..ix).unwrap_or(lines), lines.get(ix + 1..)),
32 "--" => {
36 if (ix == 0 || lines.get(ix.saturating_sub(1)).is_none_or_empty())
37 && !lines.get(ix + 1).is_none_or_empty()
38 {
39 nearly_standard_footer = Some(ix);
40 }
41 }
42 _ => (),
43 }
44 }
45 if let Some(ix) = nearly_standard_footer {
46 return (lines.get(..ix).unwrap_or(lines), lines.get(ix + 1..));
47 }
48 (lines, None)
49}
50
51fn remove_nonstandard_footer<'a>(lines: &'a [&str]) -> (&'a [&'a str], bool) {
56 for (ix, &line) in lines.iter().enumerate() {
57 if line == "--"
58 || line.starts_with("---")
59 || line.starts_with("_____")
60 || line.starts_with("=====")
61 || line.starts_with("*****")
62 || line.starts_with("~~~~~")
63 {
64 if let Some(lines) = lines.get(..ix) {
66 return (lines, true);
67 }
68 }
69 }
70 (lines, false)
71}
72
73pub(crate) fn remove_footers(msg: &str) -> String {
76 let lines = split_lines(msg);
77 let lines = remove_message_footer(&lines).0;
78 let lines = remove_nonstandard_footer(lines).0;
79 lines.join("\n")
80}
81
82pub(crate) fn split_lines(buf: &str) -> Vec<&str> {
83 buf.split('\n').collect()
84}
85
86#[derive(Debug, Default, PartialEq, Eq)]
88pub(crate) struct SimplifiedText {
89 pub text: String,
91
92 pub is_forwarded: bool,
94
95 pub is_cut: bool,
98
99 pub top_quote: Option<String>,
101
102 pub footer: Option<String>,
104}
105
106pub(crate) fn simplify_quote(quote: &str) -> (String, bool) {
107 let quote_lines = split_lines(quote);
108 let (quote_lines, quote_footer_lines) = remove_message_footer("e_lines);
109 let is_cut = quote_footer_lines.is_some();
110
111 (render_message(quote_lines, false), is_cut)
112}
113
114pub(crate) fn simplify(mut input: String, is_chat_message: bool) -> SimplifiedText {
117 let mut is_cut = false;
118
119 input.retain(|c| c != '\r');
120 let lines = split_lines(&input);
121 let (lines, is_forwarded) = skip_forward_header(&lines);
122
123 let (lines, mut top_quote) = remove_top_quote(lines, is_chat_message);
124 let original_lines = &lines;
125 let (lines, footer_lines) = remove_message_footer(lines);
126 let footer = footer_lines.map(|footer_lines| render_message(footer_lines, false));
127
128 let text = if is_chat_message {
129 render_message(lines, false)
130 } else {
131 let (lines, has_nonstandard_footer) = remove_nonstandard_footer(lines);
132 let (lines, mut bottom_quote) = remove_bottom_quote(lines);
133
134 if top_quote.is_none() && bottom_quote.is_some() {
135 std::mem::swap(&mut top_quote, &mut bottom_quote);
136 }
137
138 if lines.iter().all(|it| it.trim().is_empty()) {
139 render_message(original_lines, false)
140 } else {
141 is_cut = is_cut || has_nonstandard_footer || bottom_quote.is_some();
142 render_message(lines, has_nonstandard_footer || bottom_quote.is_some())
143 }
144 };
145
146 if !is_chat_message {
147 top_quote = top_quote.map(|quote| {
148 let (quote, quote_cut) = simplify_quote("e);
149 is_cut |= quote_cut;
150 quote
151 });
152 }
153
154 SimplifiedText {
155 text,
156 is_forwarded,
157 is_cut,
158 top_quote,
159 footer,
160 }
161}
162
163fn skip_forward_header<'a>(lines: &'a [&str]) -> (&'a [&'a str], bool) {
167 match lines {
168 [
169 "---------- Forwarded message ----------",
170 first_line,
171 "",
172 rest @ ..,
173 ] if first_line.starts_with("From: ") => (rest, true),
174 _ => (lines, false),
175 }
176}
177
178fn remove_bottom_quote<'a>(lines: &'a [&str]) -> (&'a [&'a str], Option<String>) {
179 let mut first_quoted_line = lines.len();
180 let mut last_quoted_line = None;
181 for (l, line) in lines.iter().enumerate().rev() {
182 if is_plain_quote(line) {
183 if last_quoted_line.is_none() {
184 first_quoted_line = l + 1;
185 }
186 last_quoted_line = Some(l)
187 } else if !is_empty_line(line) {
188 break;
189 }
190 }
191 if let Some(mut l_last) = last_quoted_line {
192 let quoted_text = lines
193 .iter()
194 .take(first_quoted_line)
195 .skip(l_last)
196 .map(|s| {
197 s.strip_prefix('>')
198 .map_or(*s, |u| u.strip_prefix(' ').unwrap_or(u))
199 })
200 .collect::<Vec<&str>>()
201 .join("\n");
202 if l_last > 1
203 && let Some(line) = lines.get(l_last - 1)
204 && is_empty_line(line)
205 {
206 l_last -= 1
207 }
208 if l_last > 1
209 && let Some(line) = lines.get(l_last - 1)
210 && is_quoted_headline(line)
211 {
212 l_last -= 1
213 }
214 (lines.get(..l_last).unwrap_or(lines), Some(quoted_text))
215 } else {
216 (lines, None)
217 }
218}
219
220fn remove_top_quote<'a>(
221 lines: &'a [&str],
222 is_chat_message: bool,
223) -> (&'a [&'a str], Option<String>) {
224 let mut first_quoted_line = 0;
225 let mut last_quoted_line = None;
226 let mut has_quoted_headline = false;
227 for (l, line) in lines.iter().enumerate() {
228 if is_plain_quote(line) {
229 if last_quoted_line.is_none() {
230 first_quoted_line = l;
231 }
232 last_quoted_line = Some(l)
233 } else if !is_chat_message
234 && is_quoted_headline(line)
235 && !has_quoted_headline
236 && last_quoted_line.is_none()
237 {
238 has_quoted_headline = true
239 } else {
240 break;
242 }
243 }
244 if let Some(last_quoted_line) = last_quoted_line {
245 (
246 lines.get(last_quoted_line + 1..).unwrap_or(lines),
247 Some(
248 lines
249 .iter()
250 .take(last_quoted_line + 1)
251 .skip(first_quoted_line)
252 .map(|s| {
253 s.strip_prefix('>')
254 .map_or(*s, |u| u.strip_prefix(' ').unwrap_or(u))
255 })
256 .collect::<Vec<&str>>()
257 .join("\n"),
258 ),
259 )
260 } else {
261 (lines, None)
262 }
263}
264
265fn render_message(lines: &[&str], is_cut_at_end: bool) -> String {
266 let mut ret = String::new();
267 let mut pending_linebreaks = 0;
269 for line in lines {
270 if is_empty_line(line) {
271 pending_linebreaks += 1
272 } else {
273 if !ret.is_empty() {
274 if pending_linebreaks > 2 {
275 pending_linebreaks = 2
276 }
277 while 0 != pending_linebreaks {
278 ret += "\n";
279 pending_linebreaks -= 1
280 }
281 }
282 ret += line;
284 pending_linebreaks = 1
285 }
286 }
287 if is_cut_at_end && !ret.is_empty() {
288 ret += " [...]";
289 }
290 ret.replace('\u{200B}', "")
292}
293
294fn is_empty_line(buf: &str) -> bool {
296 buf.chars().all(char::is_whitespace)
297 }
301
302fn is_quoted_headline(buf: &str) -> bool {
303 buf.len() <= 120 && buf.ends_with(':')
309}
310
311fn is_plain_quote(buf: &str) -> bool {
312 buf.starts_with('>')
313}
314
315#[cfg(test)]
316mod tests {
317 use proptest::prelude::*;
318
319 use super::*;
320
321 proptest! {
322 #[test]
323 fn test_simplify_plain_text_fuzzy(input in "[!-~\t \n]+") {
325 let SimplifiedText {
326 text,
327 ..
328 } = simplify(input, true);
329 assert!(text.split('\n').all(|s| s != "-- "));
330 }
331 }
332
333 #[test]
334 fn test_dont_remove_whole_message() {
335 let input = "\n------\nFailed\n------\n\nUh-oh, this workflow did not succeed!\n\nlots of other text".to_string();
336 let SimplifiedText {
337 text,
338 is_forwarded,
339 is_cut,
340 ..
341 } = simplify(input, false);
342 assert_eq!(
343 text,
344 "------\nFailed\n------\n\nUh-oh, this workflow did not succeed!\n\nlots of other text"
345 );
346 assert!(!is_forwarded);
347 assert!(!is_cut);
348 }
349
350 #[test]
351 fn test_chat_message() {
352 let input = "Hi! How are you?\n\n---\n\nI am good.\n-- \nSent with my Delta Chat Messenger: https://delta.chat".to_string();
353 let SimplifiedText {
354 text,
355 is_forwarded,
356 is_cut,
357 footer,
358 ..
359 } = simplify(input, true);
360 assert_eq!(text, "Hi! How are you?\n\n---\n\nI am good.");
361 assert!(!is_forwarded);
362 assert!(!is_cut);
363 assert_eq!(
364 footer.unwrap(),
365 "Sent with my Delta Chat Messenger: https://delta.chat"
366 );
367 }
368
369 #[test]
370 fn test_simplify_trim() {
371 let input = "line1\n\r\r\rline2".to_string();
372 let SimplifiedText {
373 text,
374 is_forwarded,
375 is_cut,
376 ..
377 } = simplify(input, false);
378
379 assert_eq!(text, "line1\nline2");
380 assert!(!is_forwarded);
381 assert!(!is_cut);
382 }
383
384 #[test]
385 fn test_simplify_forwarded_message() {
386 let input = "---------- Forwarded message ----------\r\nFrom: test@example.com\r\n\r\nForwarded message\r\n-- \r\nSignature goes here".to_string();
387 let SimplifiedText {
388 text,
389 is_forwarded,
390 is_cut,
391 footer,
392 ..
393 } = simplify(input, false);
394
395 assert_eq!(text, "Forwarded message");
396 assert!(is_forwarded);
397 assert!(!is_cut);
398 assert_eq!(footer.unwrap(), "Signature goes here");
399 }
400
401 #[test]
402 fn test_simplify_utilities() {
403 assert!(is_empty_line(" \t"));
404 assert!(is_empty_line(""));
405 assert!(is_empty_line(" \r"));
406 assert!(!is_empty_line(" x"));
407 assert!(is_plain_quote("> hello world"));
408 assert!(is_plain_quote(">>"));
409 assert!(!is_plain_quote("Life is pain"));
410 assert!(!is_plain_quote(""));
411 }
412
413 #[test]
414 fn test_is_quoted_headline() {
415 assert!(is_quoted_headline("On 2024-08-28, Bob wrote:"));
416 assert!(is_quoted_headline("Am 11. November 2024 schrieb Alice:"));
417 assert!(is_quoted_headline("Anonymous Longer Name a écrit:"));
418 assert!(is_quoted_headline("There is not really a pattern wrote:"));
419 assert!(is_quoted_headline(
420 "On Mon, 3 Jan, 2022 at 8:34 PM \"Anonymous Longer Name\" <anonymous-longer-name@example.com> wrote:"
421 ));
422 assert!(!is_quoted_headline(
423 "How are you? I just want to say that this line does not belong to the quote!"
424 ));
425 assert!(!is_quoted_headline(
426 "No quote headline as not ending with a colon"
427 ));
428 assert!(!is_quoted_headline(
429 "Even though this ends with a colon, \
430 this is no quote-headline as just too long for most cases of date+name+address. \
431 it's all heuristics only, it is expected to go wrong sometimes. there is always the 'Show full message' button:"
432 ));
433 }
434
435 #[test]
436 fn test_remove_top_quote() {
437 let (lines, top_quote) = remove_top_quote(&["> first", "> second"], true);
438 assert!(lines.is_empty());
439 assert_eq!(top_quote.unwrap(), "first\nsecond");
440
441 let (lines, top_quote) = remove_top_quote(&["> first", "> second", "not a quote"], true);
442 assert_eq!(lines, &["not a quote"]);
443 assert_eq!(top_quote.unwrap(), "first\nsecond");
444
445 let (lines, top_quote) = remove_top_quote(&["not a quote", "> first", "> second"], true);
446 assert_eq!(lines, &["not a quote", "> first", "> second"]);
447 assert!(top_quote.is_none());
448
449 let (lines, top_quote) = remove_top_quote(
450 &["On 2024-08-28, Bob wrote:", "> quote", "not a quote"],
451 false,
452 );
453 assert_eq!(lines, &["not a quote"]);
454 assert_eq!(top_quote.unwrap(), "quote");
455
456 let (lines, top_quote) = remove_top_quote(
457 &["On 2024-08-28, Bob wrote:", "> quote", "not a quote"],
458 true,
459 );
460 assert_eq!(
461 lines,
462 &["On 2024-08-28, Bob wrote:", "> quote", "not a quote"]
463 );
464 assert!(top_quote.is_none());
465 }
466
467 #[test]
468 fn test_escape_message_footer_marks() {
469 let esc = escape_message_footer_marks("--\n--text --in line");
470 assert_eq!(esc, "-\u{200B}-\n-\u{200B}-text --in line");
471
472 let esc = escape_message_footer_marks("--\r\n--text");
473 assert_eq!(esc, "-\u{200B}-\r\n-\u{200B}-text");
474 }
475
476 #[test]
477 fn test_remove_message_footer() {
478 let input = "text\n--\nno footer".to_string();
479 let SimplifiedText {
480 text,
481 is_cut,
482 footer,
483 ..
484 } = simplify(input, true);
485 assert_eq!(text, "text\n--\nno footer");
486 assert_eq!(footer, None);
487 assert!(!is_cut);
488
489 let input = "text\n\n--\n\nno footer".to_string();
490 let SimplifiedText {
491 text,
492 is_cut,
493 footer,
494 ..
495 } = simplify(input, true);
496 assert_eq!(text, "text\n\n--\n\nno footer");
497 assert_eq!(footer, None);
498 assert!(!is_cut);
499
500 let input = "text\n\n-- no footer\n\n".to_string();
501 let SimplifiedText { text, footer, .. } = simplify(input, true);
502 assert_eq!(text, "text\n\n-- no footer");
503 assert_eq!(footer, None);
504
505 let input = "text\n\n--\nno footer\n-- \nfooter".to_string();
506 let SimplifiedText {
507 text,
508 is_cut,
509 footer,
510 ..
511 } = simplify(input, true);
512 assert_eq!(text, "text\n\n--\nno footer");
513 assert!(!is_cut);
514 assert_eq!(footer.unwrap(), "footer");
515
516 let input = "text\n\n--\ntreated as footer when unescaped".to_string();
517 let SimplifiedText {
518 text,
519 is_cut,
520 footer,
521 ..
522 } = simplify(input.clone(), true);
523 assert_eq!(text, "text"); assert!(!is_cut);
525 assert_eq!(footer.unwrap(), "treated as footer when unescaped");
526 let escaped = escape_message_footer_marks(&input);
527 let SimplifiedText {
528 text,
529 is_cut,
530 footer,
531 ..
532 } = simplify(escaped, true);
533 assert_eq!(text, "text\n\n--\ntreated as footer when unescaped");
534 assert!(!is_cut);
535 assert_eq!(footer, None);
536
537 let input = "Message text here\n---Desde mi teléfono con SIJÚ\n\nQuote here".to_string();
539 let SimplifiedText {
540 text,
541 is_cut,
542 footer,
543 ..
544 } = simplify(input.clone(), false);
545 assert_eq!(text, "Message text here [...]");
546 assert!(is_cut);
547 assert_eq!(footer, None);
548 let SimplifiedText {
549 text,
550 is_cut,
551 footer,
552 ..
553 } = simplify(input.clone(), true);
554 assert_eq!(text, input);
555 assert!(!is_cut);
556 assert_eq!(footer, None);
557
558 let input = "--\ntreated as footer when unescaped".to_string();
559 let SimplifiedText {
560 text,
561 is_cut,
562 footer,
563 ..
564 } = simplify(input.clone(), true);
565 assert_eq!(text, ""); assert!(!is_cut);
567 assert_eq!(footer.unwrap(), "treated as footer when unescaped");
568
569 let escaped = escape_message_footer_marks(&input);
570 let SimplifiedText {
571 text,
572 is_cut,
573 footer,
574 ..
575 } = simplify(escaped, true);
576 assert_eq!(text, "--\ntreated as footer when unescaped");
577 assert!(!is_cut);
578 assert_eq!(footer, None);
579 }
580}