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}