From bc50c6f7f933e7a56305af94e34852407aaa2dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:53:03 +0000 Subject: [PATCH] Gap 19: ODT export keeps replies to replies Export wrote only the direct replies of a comment, so a reply to a reply was lost on save. Every reply in a comment's thread is written now, each answering the comment that starts the thread, as LibreOffice's threads are flat. A reply chain that loops (hostile) is written once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- crates/formats/src/model/from_doc.rs | 15 ++++++++++++--- crates/formats/src/odt/tests_more/comments.rs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index ef90a5bd4e..d5da5a3801 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -139,12 +139,21 @@ fn rich_inlines(doc: &Document, o: &InlineObject, f: &Fmt, opts: FlowOpts) -> Op let endnote = *kind == wordcraft_doc::para::NoteKind::Endnote; vec![Inline::Note { endnote, custom: custom.clone(), blocks }] } - // The comment, then its replies, which have no place of their own. + // The comment, then the replies in its thread (replies to replies too, each answering the + // comment that starts the thread), which have no place of their own. InlineObject::CommentStart { id } if opts.comments => { let Some(c) = doc.comments.get(id) else { return Some(Vec::new()) }; let mut out = vec![Inline::Comment { id: *id, comment: Box::new(flow_comment(doc, c, None, opts)) }]; - for (rid, r) in doc.comments.iter().filter(|(_, r)| r.parent == Some(*id)) { - out.push(Inline::Comment { id: *rid, comment: Box::new(flow_comment(doc, r, Some(*id), opts)) }); + let mut thread = std::collections::BTreeSet::from([*id]); + let mut k = 0; + while let Some(Inline::Comment { id: at, .. }) = out.get(k) { + let at = *at; + k += 1; + for (rid, r) in doc.comments.iter().filter(|(_, r)| r.parent == Some(at)) { + if thread.insert(*rid) { + out.push(Inline::Comment { id: *rid, comment: Box::new(flow_comment(doc, r, Some(*id), opts)) }); + } + } } out } diff --git a/crates/formats/src/odt/tests_more/comments.rs b/crates/formats/src/odt/tests_more/comments.rs index 0890f99d23..0e3a43a7d8 100644 --- a/crates/formats/src/odt/tests_more/comments.rs +++ b/crates/formats/src/odt/tests_more/comments.rs @@ -168,3 +168,20 @@ fn unmatched_and_hostile_comments_do_not_panic() { } } } + +#[test] +fn a_reply_to_a_reply_is_kept() { + let xml = concat!( + r#"ARootx"#, + r#"BReply"#, + r#"CReply to reply"#, + ); + let d = import(&body(xml)).unwrap(); + assert_eq!(d.comments.len(), 3); + // LibreOffice's threads are flat: every reply answers the comment that starts them. + let back = import(&export(&d).unwrap()).unwrap(); + let got = comments(&back); + assert_eq!(got.len(), 3, "{got:?}"); + assert_eq!(got[2].4.as_deref(), Some("Root")); + assert_eq!(got[2].5, "Reply to reply"); +}