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