extern crate text_document_io as document_io;
use common::parser_tools::{CommentReply, DocumentComment, DocumentComments, DocxExportOptions};
use document_io::docx_rs::{DocumentChild, Docx, Hyperlink, Paragraph, ParagraphChild, RunChild};
use document_io::{ExportDocxDto, ImportDjotDto, document_io_controller};
use test_harness::{DbContext, EventHub, setup};
use std::sync::Arc;
fn import_djot(db: &DbContext, ev: &Arc<EventHub>, djot: &str) {
let mut mgr = common::long_operation::LongOperationManager::new();
let op = document_io_controller::import_djot(
db,
ev,
&mut mgr,
&ImportDjotDto {
djot_text: djot.to_string(),
options: Default::default(),
},
)
.expect("import_djot");
while let Some(common::long_operation::OperationStatus::Running) = mgr.get_operation_status(&op)
{
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert_eq!(
mgr.get_operation_status(&op),
Some(common::long_operation::OperationStatus::Completed),
"import of {djot:?} did not complete"
);
}
fn char_range_of(db: &DbContext, needle: &str) -> (u32, u32) {
for bid in test_harness::get_all_block_ids(db).expect("block ids") {
let block = test_harness::block_controller::get(db, &bid)
.expect("get block")
.expect("block exists");
let text = test_harness::block_text_dto(db, &block);
if let Some(byte_idx) = text.find(needle) {
let char_offset = text[..byte_idx].chars().count() as u32;
let start = block.document_position as u32 + char_offset;
let end = start + needle.chars().count() as u32;
return (start, end);
}
}
panic!("no block contains {needle:?}");
}
fn comment(
uid: &str,
author: &str,
initials: &str,
range: (u32, u32),
body: &str,
) -> DocumentComment {
DocumentComment {
start: range.0,
end: range.1,
uid: uid.to_string(),
author: author.to_string(),
author_initials: initials.to_string(),
date: "2026-01-01T00:00:00Z".to_string(),
resolved: false,
body: body.to_string(),
replies: Vec::new(),
}
}
fn build_docx(db: &DbContext, comments: DocumentComments) -> Docx {
document_io_controller::build_docx_document(
db,
&ExportDocxDto {
output_path: "unused.docx".to_string(),
options: DocxExportOptions {
comments,
..Default::default()
},
},
)
.expect("build_docx_document")
}
fn paragraphs(docx: &Docx) -> Vec<&Paragraph> {
docx.document
.children
.iter()
.filter_map(|c| match c {
DocumentChild::Paragraph(p) => Some(&**p),
_ => None,
})
.collect()
}
#[derive(Debug, PartialEq, Eq)]
enum Sym {
Text(String),
Start(usize),
End(usize),
}
fn comment_end_id(end: &document_io::docx_rs::CommentRangeEnd) -> usize {
serde_json::to_value(end)
.ok()
.and_then(|v| v.get("id").and_then(|i| i.as_u64()))
.expect("CommentRangeEnd serializes its id") as usize
}
fn symbols(children: &[ParagraphChild]) -> Vec<Sym> {
children
.iter()
.filter_map(|c| match c {
ParagraphChild::Run(run) => {
let mut text = String::new();
for rc in &run.children {
if let RunChild::Text(t) = rc {
text.push_str(&t.text);
}
}
(!text.is_empty()).then_some(Sym::Text(text))
}
ParagraphChild::CommentStart(start) => Some(Sym::Start(start.id)),
ParagraphChild::CommentEnd(end) => Some(Sym::End(comment_end_id(end))),
_ => None,
})
.collect()
}
fn first_hyperlink(p: &Paragraph) -> &Hyperlink {
p.children
.iter()
.find_map(|c| match c {
ParagraphChild::Hyperlink(h) => Some(h),
_ => None,
})
.expect("paragraph has a hyperlink")
}
fn plain_text(symbols: &[Sym]) -> String {
symbols
.iter()
.filter_map(|s| match s {
Sym::Text(t) => Some(t.as_str()),
_ => None,
})
.collect()
}
#[test]
fn comment_boundary_splits_a_run_mid_hyperlink() {
let (db, ev, _) = setup().expect("setup");
import_djot(
&db,
&ev,
"Visit our [website link here](https://example.com) for more.",
);
let range = char_range_of(&db, "link");
let mut comments = DocumentComments::new();
comments.insert(comment(
"cmt-hyperlink-1",
"Editor One",
"EO",
range,
"Is this the right link text?",
));
let docx = build_docx(&db, comments);
let p = paragraphs(&docx)
.into_iter()
.find(|p| {
p.children
.iter()
.any(|c| matches!(c, ParagraphChild::Hyperlink(_)))
})
.expect("a paragraph with a hyperlink");
let link = first_hyperlink(p);
let syms = symbols(&link.children);
assert_eq!(plain_text(&syms), "website link here");
let start_idx = syms
.iter()
.position(|s| matches!(s, Sym::Start(_)))
.expect("a CommentStart inside the hyperlink");
let end_idx = syms
.iter()
.position(|s| matches!(s, Sym::End(_)))
.expect("a CommentEnd inside the hyperlink");
assert!(
start_idx < end_idx,
"CommentStart must precede CommentEnd: {syms:?}"
);
let inner_text: String = syms[start_idx + 1..end_idx]
.iter()
.filter_map(|s| match s {
Sym::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(inner_text, "link");
let top_level = symbols(&p.children);
assert!(
!top_level
.iter()
.any(|s| matches!(s, Sym::Start(_) | Sym::End(_))),
"the boundary is inside the hyperlink's own text, not at the paragraph level: {top_level:?}"
);
}
#[test]
fn two_comments_overlap_in_one_block() {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, "Alpha beta gamma delta epsilon.");
let range1 = char_range_of(&db, "beta gamma");
let range2 = char_range_of(&db, "gamma delta");
assert!(
range1.0 < range2.0 && range1.1 < range2.1,
"ranges must cross, not nest"
);
let mut comments = DocumentComments::new();
comments.insert(comment("cmt-a", "Author A", "AA", range1, "First note."));
comments.insert(comment("cmt-b", "Author B", "BB", range2, "Second note."));
let docx = build_docx(&db, comments);
let p = paragraphs(&docx)
.into_iter()
.find(|p| {
p.children
.iter()
.any(|c| matches!(c, ParagraphChild::Run(_)))
})
.expect("the prose paragraph");
let syms = symbols(&p.children);
assert_eq!(plain_text(&syms), "Alpha beta gamma delta epsilon.");
let starts: Vec<usize> = syms
.iter()
.filter_map(|s| match s {
Sym::Start(id) => Some(*id),
_ => None,
})
.collect();
let ends: Vec<usize> = syms
.iter()
.filter_map(|s| match s {
Sym::End(id) => Some(*id),
_ => None,
})
.collect();
assert_eq!(starts.len(), 2, "{syms:?}");
assert_eq!(ends.len(), 2, "{syms:?}");
assert_eq!(
starts[0], ends[0],
"the thread that opened first must be the one whose End comes first: {syms:?}"
);
assert_ne!(starts[0], starts[1], "two distinct threads");
let marker_order: Vec<&Sym> = syms
.iter()
.filter(|s| matches!(s, Sym::Start(_) | Sym::End(_)))
.collect();
assert!(
matches!(
marker_order.as_slice(),
[Sym::Start(_), Sym::Start(_), Sym::End(_), Sym::End(_)]
),
"expected Start, Start, End, End, got {marker_order:?}"
);
}
fn attr_after(xml: &str, marker: &str, attr: &str) -> String {
let marker_at = xml
.find(marker)
.unwrap_or_else(|| panic!("{marker:?} not found in {xml}"));
let tag_start = xml[..marker_at].rfind('<').expect("marker is inside a tag");
let tag_end = xml[tag_start..].find('>').expect("tag is closed") + tag_start;
let tag = &xml[tag_start..tag_end];
let needle = format!(r#"{attr}=""#);
let value_start = tag
.find(&needle)
.unwrap_or_else(|| panic!("{attr} not found on the tag containing {marker:?}: {tag}"))
+ needle.len();
let value_end = tag[value_start..]
.find('"')
.expect("attribute value is closed")
+ value_start;
tag[value_start..value_end].to_string()
}
#[test]
fn patch_writes_uid_initials_and_resolved_flag() {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, "The quick brown fox jumps over the lazy dog.");
let quick_range = char_range_of(&db, "quick brown");
let lazy_range = char_range_of(&db, "lazy dog");
let mut resolved_comment = comment(
"cmt-quick-1",
"Alice Editor",
"AE",
quick_range,
"Please reconsider this phrase.",
);
resolved_comment.resolved = true;
let unresolved_comment = comment(
"cmt-lazy-2",
"Bob Reviewer",
"BR",
lazy_range,
"Nice ending.",
);
let mut comments = DocumentComments::new();
comments.insert(resolved_comment);
comments.insert(unresolved_comment);
let xml_docx = document_io_controller::build_docx_xml_document(
&db,
&ExportDocxDto {
output_path: "unused.docx".to_string(),
options: DocxExportOptions {
comments,
..Default::default()
},
},
)
.expect("build_docx_xml_document");
let comments_xml = String::from_utf8(xml_docx.comments.clone()).expect("comments.xml is UTF-8");
let comments_extended_xml = String::from_utf8(xml_docx.comments_extended.clone())
.expect("commentsExtended.xml is UTF-8");
assert!(
comments_xml.contains(r#"xmlns:skrb="urn:ferntech:text-document:comment:1""#),
"comments.xml: {comments_xml}"
);
assert_eq!(
attr_after(&comments_xml, r#"w:author="Alice Editor""#, "w:initials"),
"AE"
);
assert_eq!(
attr_after(&comments_xml, r#"w:author="Alice Editor""#, "skrb:uid"),
"cmt-quick-1"
);
assert_eq!(
attr_after(&comments_xml, r#"w:author="Bob Reviewer""#, "w:initials"),
"BR"
);
assert_eq!(
attr_after(&comments_xml, r#"w:author="Bob Reviewer""#, "skrb:uid"),
"cmt-lazy-2"
);
let alice_para_id = {
let alice_at = comments_xml.find(r#"w:author="Alice Editor""#).unwrap();
let marker = "w14:paraId=\"";
let at = comments_xml[alice_at..].find(marker).unwrap() + alice_at + marker.len();
comments_xml[at..at + 8].to_string()
};
let bob_para_id = {
let bob_at = comments_xml.find(r#"w:author="Bob Reviewer""#).unwrap();
let marker = "w14:paraId=\"";
let at = comments_xml[bob_at..].find(marker).unwrap() + bob_at + marker.len();
comments_xml[at..at + 8].to_string()
};
assert_ne!(alice_para_id, bob_para_id);
assert_eq!(
attr_after(
&comments_extended_xml,
&format!(r#"w15:paraId="{alice_para_id}""#),
"w15:done"
),
"1",
"Alice's thread is resolved: {comments_extended_xml}"
);
assert_eq!(
attr_after(
&comments_extended_xml,
&format!(r#"w15:paraId="{bob_para_id}""#),
"w15:done"
),
"0",
"Bob's thread is not resolved: {comments_extended_xml}"
);
}
fn soffice_path() -> Option<std::path::PathBuf> {
std::process::Command::new("which")
.arg("soffice")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
}
#[test]
fn resolved_comment_thread_round_trips_through_libreoffice() {
let Some(_soffice) = soffice_path() else {
eprintln!("skipping: soffice not found on PATH");
return;
};
let (db, ev, _) = setup().expect("setup");
import_djot(
&db,
&ev,
"This manuscript opens with a sentence that needs review.\n\n\
A second, unrelated paragraph follows.",
);
let range = char_range_of(&db, "needs review");
let mut root = comment(
"cmt-root-1",
"Alice Editor",
"AE",
range,
"Please tighten this phrase.",
);
root.replies.push(CommentReply {
uid: "cmt-reply-1".to_string(),
author: "Bob Writer".to_string(),
author_initials: "BW".to_string(),
date: "2026-01-02T00:00:00Z".to_string(),
body: "Good catch, will fix in the next pass.".to_string(),
});
let mut comments = DocumentComments::new();
comments.insert(root);
let xml_docx = document_io_controller::build_docx_xml_document(
&db,
&ExportDocxDto {
output_path: "unused.docx".to_string(),
options: DocxExportOptions {
comments,
..Default::default()
},
},
)
.expect("build_docx_xml_document");
let dir = std::env::temp_dir().join(format!("docx_comment_export_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let docx_path = dir.join("comment_fixture.docx");
let file = std::fs::File::create(&docx_path).expect("create docx file");
xml_docx.pack(file).expect("pack docx");
let profile_dir = dir.join("lo_profile");
let output = std::process::Command::new("soffice")
.args([
"--headless",
"--norestore",
&format!("-env:UserInstallation=file://{}", profile_dir.display()),
"--convert-to",
"odt",
"--outdir",
])
.arg(&dir)
.arg(&docx_path)
.output()
.expect("run soffice");
assert!(
output.status.success(),
"soffice --convert-to odt failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let odt_path = dir.join("comment_fixture.odt");
assert!(
odt_path.exists(),
"soffice reported success but did not write {odt_path:?} — stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let odt_bytes = std::fs::read(&odt_path).expect("read converted odt");
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(odt_bytes)).expect("odt is a valid zip");
let mut content_xml = String::new();
std::io::Read::read_to_string(
&mut archive.by_name("content.xml").expect("content.xml present"),
&mut content_xml,
)
.expect("content.xml is valid utf-8");
assert!(
content_xml.contains("office:annotation"),
"no annotation found in LibreOffice's own ODF conversion"
);
assert!(
content_xml.contains("Alice Editor"),
"root comment's author did not survive the round trip"
);
assert!(
content_xml.contains("Please tighten this phrase"),
"root comment's body did not survive the round trip"
);
assert!(
content_xml.contains("Bob Writer"),
"reply's author did not survive the round trip"
);
assert!(
content_xml.contains("Good catch, will fix"),
"reply's body did not survive the round trip"
);
let _ = std::fs::remove_dir_all(&dir);
}