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 if let Some(line) = lines.get(l_last - 1) {
204 if is_empty_line(line) {
205 l_last -= 1
206 }
207 }
208 }
209 if l_last > 1 {
210 if let Some(line) = lines.get(l_last - 1) {
211 if is_quoted_headline(line) {
212 l_last -= 1
213 }
214 }
215 }
216 (lines.get(..l_last).unwrap_or(lines), Some(quoted_text))
217 } else {
218 (lines, None)
219 }
220}
221
222fn remove_top_quote<'a>(
223 lines: &'a [&str],
224 is_chat_message: bool,
225) -> (&'a [&'a str], Option<String>) {
226 let mut first_quoted_line = 0;
227 let mut last_quoted_line = None;
228 let mut has_quoted_headline = false;
229 for (l, line) in lines.iter().enumerate() {
230 if is_plain_quote(line) {
231 if last_quoted_line.is_none() {
232 first_quoted_line = l;
233 }
234 last_quoted_line = Some(l)
235 } else if !is_chat_message
236 && is_quoted_headline(line)
237 && !has_quoted_headline
238 && last_quoted_line.is_none()
239 {
240 has_quoted_headline = true
241 } else {
242 break;
244 }
245 }
246 if let Some(last_quoted_line) = last_quoted_line {
247 (
248 lines.get(last_quoted_line + 1..).unwrap_or(lines),
249 Some(
250 lines
251 .iter()
252 .take(last_quoted_line + 1)
253 .skip(first_quoted_line)
254 .map(|s| {
255 s.strip_prefix('>')
256 .map_or(*s, |u| u.strip_prefix(' ').unwrap_or(u))
257 })
258 .collect::<Vec<&str>>()
259 .join("\n"),
260 ),
261 )
262 } else {
263 (lines, None)
264 }
265}
266
267fn render_message(lines: &[&str], is_cut_at_end: bool) -> String {
268 let mut ret = String::new();
269 let mut pending_linebreaks = 0;
271 for line in lines {
272 if is_empty_line(line) {
273 pending_linebreaks += 1
274 } else {
275 if !ret.is_empty() {
276 if pending_linebreaks > 2 {
277 pending_linebreaks = 2
278 }
279 while 0 != pending_linebreaks {
280 ret += "\n";
281 pending_linebreaks -= 1
282 }
283 }
284 ret += line;
286 pending_linebreaks = 1
287 }
288 }
289 if is_cut_at_end && !ret.is_empty() {
290 ret += " [...]";
291 }
292 ret.replace('\u{200B}', "")
294}
295
296fn is_empty_line(buf: &str) -> bool {
298 buf.chars().all(char::is_whitespace)
299 }
303
304fn is_quoted_headline(buf: &str) -> bool {
305 buf.len() <= 120 && buf.ends_with(':')
311}
312
313fn is_plain_quote(buf: &str) -> bool {
314 buf.starts_with('>')
315}
316
317#[cfg(test)]
318mod tests {
319 use proptest::prelude::*;
320
321 use super::*;
322
323 proptest! {
324 #[test]
325 fn test_simplify_plain_text_fuzzy(input in "[!-~\t \n]+") {
327 let SimplifiedText {
328 text,
329 ..
330 } = simplify(input, true);
331 assert!(text.split('\n').all(|s| s != "-- "));
332 }
333 }
334
335 #[test]
336 fn test_dont_remove_whole_message() {
337 let input = "\n------\nFailed\n------\n\nUh-oh, this workflow did not succeed!\n\nlots of other text".to_string();
338 let SimplifiedText {
339 text,
340 is_forwarded,
341 is_cut,
342 ..
343 } = simplify(input, false);
344 assert_eq!(
345 text,
346 "------\nFailed\n------\n\nUh-oh, this workflow did not succeed!\n\nlots of other text"
347 );
348 assert!(!is_forwarded);
349 assert!(!is_cut);
350 }
351
352 #[test]
353 fn test_chat_message() {
354 let input = "Hi! How are you?\n\n---\n\nI am good.\n-- \nSent with my Delta Chat Messenger: https://delta.chat".to_string();
355 let SimplifiedText {
356 text,
357 is_forwarded,
358 is_cut,
359 footer,
360 ..
361 } = simplify(input, true);
362 assert_eq!(text, "Hi! How are you?\n\n---\n\nI am good.");
363 assert!(!is_forwarded);
364 assert!(!is_cut);
365 assert_eq!(
366 footer.unwrap(),
367 "Sent with my Delta Chat Messenger: https://delta.chat"
368 );
369 }
370
371 #[test]
372 fn test_simplify_trim() {
373 let input = "line1\n\r\r\rline2".to_string();
374 let SimplifiedText {
375 text,
376 is_forwarded,
377 is_cut,
378 ..
379 } = simplify(input, false);
380
381 assert_eq!(text, "line1\nline2");
382 assert!(!is_forwarded);
383 assert!(!is_cut);
384 }
385
386 #[test]
387 fn test_simplify_forwarded_message() {
388 let input = "---------- Forwarded message ----------\r\nFrom: test@example.com\r\n\r\nForwarded message\r\n-- \r\nSignature goes here".to_string();
389 let SimplifiedText {
390 text,
391 is_forwarded,
392 is_cut,
393 footer,
394 ..
395 } = simplify(input, false);
396
397 assert_eq!(text, "Forwarded message");
398 assert!(is_forwarded);
399 assert!(!is_cut);
400 assert_eq!(footer.unwrap(), "Signature goes here");
401 }
402
403 #[test]
404 fn test_simplify_utilities() {
405 assert!(is_empty_line(" \t"));
406 assert!(is_empty_line(""));
407 assert!(is_empty_line(" \r"));
408 assert!(!is_empty_line(" x"));
409 assert!(is_plain_quote("> hello world"));
410 assert!(is_plain_quote(">>"));
411 assert!(!is_plain_quote("Life is pain"));
412 assert!(!is_plain_quote(""));
413 }
414
415 #[test]
416 fn test_is_quoted_headline() {
417 assert!(is_quoted_headline("On 2024-08-28, Bob wrote:"));
418 assert!(is_quoted_headline("Am 11. November 2024 schrieb Alice:"));
419 assert!(is_quoted_headline("Anonymous Longer Name a écrit:"));
420 assert!(is_quoted_headline("There is not really a pattern wrote:"));
421 assert!(is_quoted_headline(
422 "On Mon, 3 Jan, 2022 at 8:34 PM \"Anonymous Longer Name\" <anonymous-longer-name@example.com> wrote:"
423 ));
424 assert!(!is_quoted_headline(
425 "How are you? I just want to say that this line does not belong to the quote!"
426 ));
427 assert!(!is_quoted_headline(
428 "No quote headline as not ending with a colon"
429 ));
430 assert!(!is_quoted_headline(
431 "Even though this ends with a colon, \
432 this is no quote-headline as just too long for most cases of date+name+address. \
433 it's all heuristics only, it is expected to go wrong sometimes. there is always the 'Show full message' button:"
434 ));
435 }
436
437 #[test]
438 fn test_remove_top_quote() {
439 let (lines, top_quote) = remove_top_quote(&["> first", "> second"], true);
440 assert!(lines.is_empty());
441 assert_eq!(top_quote.unwrap(), "first\nsecond");
442
443 let (lines, top_quote) = remove_top_quote(&["> first", "> second", "not a quote"], true);
444 assert_eq!(lines, &["not a quote"]);
445 assert_eq!(top_quote.unwrap(), "first\nsecond");
446
447 let (lines, top_quote) = remove_top_quote(&["not a quote", "> first", "> second"], true);
448 assert_eq!(lines, &["not a quote", "> first", "> second"]);
449 assert!(top_quote.is_none());
450
451 let (lines, top_quote) = remove_top_quote(
452 &["On 2024-08-28, Bob wrote:", "> quote", "not a quote"],
453 false,
454 );
455 assert_eq!(lines, &["not a quote"]);
456 assert_eq!(top_quote.unwrap(), "quote");
457
458 let (lines, top_quote) = remove_top_quote(
459 &["On 2024-08-28, Bob wrote:", "> quote", "not a quote"],
460 true,
461 );
462 assert_eq!(
463 lines,
464 &["On 2024-08-28, Bob wrote:", "> quote", "not a quote"]
465 );
466 assert!(top_quote.is_none());
467 }
468
469 #[test]
470 fn test_escape_message_footer_marks() {
471 let esc = escape_message_footer_marks("--\n--text --in line");
472 assert_eq!(esc, "-\u{200B}-\n-\u{200B}-text --in line");
473
474 let esc = escape_message_footer_marks("--\r\n--text");
475 assert_eq!(esc, "-\u{200B}-\r\n-\u{200B}-text");
476 }
477
478 #[test]
479 fn test_remove_message_footer() {
480 let input = "text\n--\nno footer".to_string();
481 let SimplifiedText {
482 text,
483 is_cut,
484 footer,
485 ..
486 } = simplify(input, true);
487 assert_eq!(text, "text\n--\nno footer");
488 assert_eq!(footer, None);
489 assert!(!is_cut);
490
491 let input = "text\n\n--\n\nno footer".to_string();
492 let SimplifiedText {
493 text,
494 is_cut,
495 footer,
496 ..
497 } = simplify(input, true);
498 assert_eq!(text, "text\n\n--\n\nno footer");
499 assert_eq!(footer, None);
500 assert!(!is_cut);
501
502 let input = "text\n\n-- no footer\n\n".to_string();
503 let SimplifiedText { text, footer, .. } = simplify(input, true);
504 assert_eq!(text, "text\n\n-- no footer");
505 assert_eq!(footer, None);
506
507 let input = "text\n\n--\nno footer\n-- \nfooter".to_string();
508 let SimplifiedText {
509 text,
510 is_cut,
511 footer,
512 ..
513 } = simplify(input, true);
514 assert_eq!(text, "text\n\n--\nno footer");
515 assert!(!is_cut);
516 assert_eq!(footer.unwrap(), "footer");
517
518 let input = "text\n\n--\ntreated as footer when unescaped".to_string();
519 let SimplifiedText {
520 text,
521 is_cut,
522 footer,
523 ..
524 } = simplify(input.clone(), true);
525 assert_eq!(text, "text"); assert!(!is_cut);
527 assert_eq!(footer.unwrap(), "treated as footer when unescaped");
528 let escaped = escape_message_footer_marks(&input);
529 let SimplifiedText {
530 text,
531 is_cut,
532 footer,
533 ..
534 } = simplify(escaped, true);
535 assert_eq!(text, "text\n\n--\ntreated as footer when unescaped");
536 assert!(!is_cut);
537 assert_eq!(footer, None);
538
539 let input = "Message text here\n---Desde mi teléfono con SIJÚ\n\nQuote here".to_string();
541 let SimplifiedText {
542 text,
543 is_cut,
544 footer,
545 ..
546 } = simplify(input.clone(), false);
547 assert_eq!(text, "Message text here [...]");
548 assert!(is_cut);
549 assert_eq!(footer, None);
550 let SimplifiedText {
551 text,
552 is_cut,
553 footer,
554 ..
555 } = simplify(input.clone(), true);
556 assert_eq!(text, input);
557 assert!(!is_cut);
558 assert_eq!(footer, None);
559
560 let input = "--\ntreated as footer when unescaped".to_string();
561 let SimplifiedText {
562 text,
563 is_cut,
564 footer,
565 ..
566 } = simplify(input.clone(), true);
567 assert_eq!(text, ""); assert!(!is_cut);
569 assert_eq!(footer.unwrap(), "treated as footer when unescaped");
570
571 let escaped = escape_message_footer_marks(&input);
572 let SimplifiedText {
573 text,
574 is_cut,
575 footer,
576 ..
577 } = simplify(escaped, true);
578 assert_eq!(text, "--\ntreated as footer when unescaped");
579 assert!(!is_cut);
580 assert_eq!(footer, None);
581 }
582}