Skip to main content

common/parser_tools/
comment_options.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Comment payload shared by every writer that can anchor a margin note into its output —
5//! today DOCX ([`super::docx_options::DocxExportOptions::comments`]), and ODT once its own
6//! writer reaches this crate. One definition here, reused by both, is the whole point: a
7//! DOCX-only `struct Comment` in `document_io` and a parallel ODT-only one elsewhere would let
8//! the two drift the moment a field's meaning changed in one but not the other.
9//!
10//! # The character range this crate deals in
11//!
12//! [`DocumentComment::start`]/[`DocumentComment::end`] are `[start, end)` in the document's
13//! own **addressable character space** — the same space `TextDocument::to_addressable_text()`,
14//! `find_all` match positions, and a block's `document_position` all share (see
15//! [`crate::format_runs::AddressableInlinePiece`]'s doc comment for the full contract). A
16//! writer splitting a run at a comment boundary must resolve that boundary against
17//! [`crate::format_runs_query::addressable_inline_pieces_for_block`]'s own `start`/`end`
18//! fields — never against `FormatRun`'s block-local UTF-8 *byte* offsets, which agree with
19//! this crate's char offsets only by coincidence on the first line of the first block.
20
21use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24
25/// One reply in a comment's thread.
26///
27/// A reply carries no range of its own: in every writer this crate feeds, a reply anchors to
28/// the exact same span as the comment it answers — precisely how Word and LibreOffice both
29/// render a reply thread (one highlighted range in the body, several bubbles stacked in the
30/// margin), and precisely what lets a writer treat "open this thread's range" and "open each
31/// reply's own range" as the same operation repeated once per reply.
32#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
33pub struct CommentReply {
34    /// Durable identifier, stable across a save/reload round trip the way `BinderItem::uid`
35    /// is in the app this crate was built for — never a store id or a document position,
36    /// both of which are free to be re-minted the moment the host reloads.
37    pub uid: String,
38    pub author: String,
39    #[serde(default)]
40    pub author_initials: String,
41    /// ISO-8601 (e.g. `"2026-08-09T12:00:00Z"`).
42    pub date: String,
43    /// Djot source. A writer that cannot embed rich text (a plain-text export, say) is free
44    /// to reduce it to its plain reading; every rich writer this crate ships today renders at
45    /// least bold, italic and paragraph/line breaks — see each writer's own body renderer.
46    pub body: String,
47}
48
49/// One comment thread: an opening note anchored to a character range, plus its flat list of
50/// replies.
51///
52/// Flat, not a tree: neither DOCX nor ODT's comment model nests a reply under another reply
53/// (Word's own UI does not offer it either), so a second level of nesting would have nowhere
54/// to go in the output format — the model does not pretend to support what no consumer of it
55/// can render.
56#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
57pub struct DocumentComment {
58    /// `[start, end)` in the document's addressable character space. `start == end` anchors
59    /// the comment to a single insertion point rather than a highlighted run — both DOCX and
60    /// ODT accept an empty range there.
61    pub start: u32,
62    pub end: u32,
63    /// Durable identifier — see [`CommentReply::uid`].
64    pub uid: String,
65    pub author: String,
66    #[serde(default)]
67    pub author_initials: String,
68    /// ISO-8601 (e.g. `"2026-08-09T12:00:00Z"`).
69    pub date: String,
70    /// Whether the thread is marked resolved (DOCX `w15:done`; ODT's own resolved marker on
71    /// the annotation). Resolving a thread does not delete it: the opening note and its full
72    /// reply history are still written out either way, just flagged.
73    #[serde(default)]
74    pub resolved: bool,
75    /// Djot source — see [`CommentReply::body`].
76    pub body: String,
77    #[serde(default)]
78    pub replies: Vec<CommentReply>,
79}
80
81/// Every comment thread supplied to one export, keyed by [`DocumentComment::uid`].
82///
83/// A `BTreeMap`, not a `HashMap`, for the same reason [`super::image_options::ExportImages`]
84/// is one: two exports of the same document must be byte-comparable, and a randomised
85/// iteration order would quietly break that. Keying by `uid` rather than storing a bare `Vec`
86/// also makes "does this document already carry a thread with this id" an O(log n) lookup
87/// instead of a linear scan — relevant because a caller re-exporting after an edit typically
88/// hands over its whole current comment set again, not just what changed since last time.
89///
90/// Iteration order is uid order, which is *not* the order a writer needs to open and close
91/// ranges in as it walks the document front to back — use
92/// [`in_document_order`](Self::in_document_order) for that.
93#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
94pub struct DocumentComments(BTreeMap<String, DocumentComment>);
95
96impl DocumentComments {
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Register a comment, keyed by its own `uid`. A second insert under the same uid
102    /// replaces the first — the caller is expected to hand over its current state on every
103    /// export, not maintain an append-only log through this type.
104    pub fn insert(&mut self, comment: DocumentComment) -> &mut Self {
105        self.0.insert(comment.uid.clone(), comment);
106        self
107    }
108
109    pub fn get(&self, uid: &str) -> Option<&DocumentComment> {
110        self.0.get(uid)
111    }
112
113    pub fn iter(&self) -> impl Iterator<Item = &DocumentComment> {
114        self.0.values()
115    }
116
117    pub fn is_empty(&self) -> bool {
118        self.0.is_empty()
119    }
120
121    pub fn len(&self) -> usize {
122        self.0.len()
123    }
124
125    /// Every comment, sorted by `(start, end, uid)` — the order a writer walking the document
126    /// text front-to-back needs to open and close ranges in. `uid` breaks an exact
127    /// `(start, end)` tie deterministically (two threads anchored to the identical span)
128    /// rather than leaving it to whatever order the `BTreeMap`'s own key (`uid` again, but
129    /// unsorted by position) happened to produce.
130    pub fn in_document_order(&self) -> Vec<&DocumentComment> {
131        let mut out: Vec<&DocumentComment> = self.0.values().collect();
132        out.sort_by(|a, b| {
133            a.start
134                .cmp(&b.start)
135                .then(a.end.cmp(&b.end))
136                .then(a.uid.cmp(&b.uid))
137        });
138        out
139    }
140}
141
142impl FromIterator<DocumentComment> for DocumentComments {
143    fn from_iter<I: IntoIterator<Item = DocumentComment>>(iter: I) -> Self {
144        let mut out = Self::default();
145        for c in iter {
146            out.insert(c);
147        }
148        out
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn comment(uid: &str, start: u32, end: u32) -> DocumentComment {
157        DocumentComment {
158            start,
159            end,
160            uid: uid.to_string(),
161            author: "Author".to_string(),
162            author_initials: "AU".to_string(),
163            date: "2026-01-01T00:00:00Z".to_string(),
164            resolved: false,
165            body: "Body".to_string(),
166            replies: vec![],
167        }
168    }
169
170    #[test]
171    fn insert_keys_by_uid_and_replaces_on_reinsert() {
172        let mut comments = DocumentComments::new();
173        comments.insert(comment("a", 0, 5));
174        comments.insert(comment("a", 10, 20));
175        assert_eq!(comments.len(), 1);
176        assert_eq!(comments.get("a").unwrap().start, 10);
177    }
178
179    #[test]
180    fn document_order_sorts_by_start_then_end_then_uid() {
181        let comments: DocumentComments =
182            [comment("z", 5, 10), comment("a", 5, 10), comment("m", 0, 3)]
183                .into_iter()
184                .collect();
185        let ordered: Vec<&str> = comments
186            .in_document_order()
187            .into_iter()
188            .map(|c| c.uid.as_str())
189            .collect();
190        assert_eq!(ordered, vec!["m", "a", "z"]);
191    }
192
193    #[test]
194    fn iteration_order_is_stable_across_builds() {
195        let build = || -> DocumentComments {
196            ["z", "a", "m"]
197                .into_iter()
198                .map(|uid| comment(uid, 0, 1))
199                .collect()
200        };
201        let first: Vec<String> = build().iter().map(|c| c.uid.clone()).collect();
202        let second: Vec<String> = build().iter().map(|c| c.uid.clone()).collect();
203        assert_eq!(first, second);
204        assert_eq!(first, vec!["a", "m", "z"]);
205    }
206
207    #[test]
208    fn empty_range_and_replies_round_trip_through_json() {
209        let mut c = comment("root", 4, 4);
210        c.replies.push(CommentReply {
211            uid: "reply-1".to_string(),
212            author: "Editor".to_string(),
213            author_initials: "ED".to_string(),
214            date: "2026-02-02T00:00:00Z".to_string(),
215            body: "*Fixed.*".to_string(),
216        });
217        let json = serde_json::to_string(&c).expect("serialize");
218        let back: DocumentComment = serde_json::from_str(&json).expect("deserialize");
219        assert_eq!(back, c);
220        assert_eq!(back.start, back.end);
221        assert_eq!(back.replies.len(), 1);
222    }
223}