//! Ionosphere comment threading and byte range → word index mapping. //! //! This module provides: //! - [`build_comment_tree`]: organises a flat list of [`CommentData`] into a reply //! tree, separating anchored comments (those with a byte range on the transcript) //! from non-anchored ones. //! - [`map_byte_range_to_words`]: maps a byte range in the transcript full text to a //! `(first_word_index, last_word_index)` pair, with expansion to full word boundaries //! and clamping to the transcript length. use crate::catalog::CommentData; use vodplace_subtitle::TranscriptData; /// A comment node in the reply tree, with its nested replies. #[derive(Debug, Clone, PartialEq)] pub struct CommentNode { pub comment: CommentData, pub replies: Vec, } /// An anchored comment: a [`CommentNode`] together with the word-index range /// that the anchor byte range maps to. #[derive(Debug, Clone, PartialEq)] pub struct AnchoredComment { pub comment_node: CommentNode, /// First word index (inclusive) the anchor covers. pub word_start: usize, /// Last word index (inclusive) the anchor covers. pub word_end: usize, } /// The result of [`build_comment_tree`]. /// /// Anchored comments are sorted by `byte_start`. Non-anchored comments are /// sorted by `created_at`. Replies within each node are sorted by `created_at`. #[derive(Debug, Clone, PartialEq)] pub struct CommentTree { pub anchored: Vec, pub non_anchored: Vec, } /// Build a reply tree from a flat list of comments, optionally mapping anchored /// comments to word indices using `transcript`. /// /// ## Threading /// /// - Comments whose `subject_uri` is NOT in the comment set (i.e. it points to /// a talk or transcript URI) become top-level nodes. /// - Comments whose `subject_uri` IS one of the other comment URIs become a /// reply under that comment. /// - Orphaned replies (parent URI present in a comment's `subject_uri` but not /// actually in `comments`) are promoted to top-level. /// /// ## Anchoring /// /// A top-level comment with a [`ByteRangeData`] anchor is placed in /// `anchored`. Word indices are computed if `transcript` is provided; without a /// transcript the comment is demoted to `non_anchored`. pub fn build_comment_tree( comments: &[CommentData], transcript: Option<&TranscriptData>, ) -> CommentTree { use std::collections::HashMap; // Index comment URIs so we can distinguish reply subjects from external subjects. let uri_set: std::collections::HashSet<&str> = comments.iter().map(|c| c.uri.as_str()).collect(); // Separate top-level comments from replies. let (top_level_refs, reply_refs): (Vec<&CommentData>, Vec<&CommentData>) = comments.iter().partition(|c| !uri_set.contains(c.subject_uri.as_str())); // Build a map from parent URI → list of reply CommentData references. let mut reply_map: HashMap<&str, Vec<&CommentData>> = HashMap::new(); for reply in &reply_refs { reply_map .entry(reply.subject_uri.as_str()) .or_default() .push(reply); } const MAX_REPLY_DEPTH: usize = 50; /// Recursively construct a `CommentNode` with all nested replies. /// Stops at `MAX_REPLY_DEPTH` to prevent stack overflow from crafted chains. fn build_node<'a>( comment: &'a CommentData, reply_map: &HashMap<&str, Vec<&'a CommentData>>, depth: usize, ) -> CommentNode { let mut replies: Vec = if depth < MAX_REPLY_DEPTH { reply_map .get(comment.uri.as_str()) .map(|rs| rs.iter().map(|r| build_node(r, reply_map, depth + 1)).collect()) .unwrap_or_default() } else { Vec::new() }; replies.sort_by(|a, b| { a.comment .created_at .as_ref() .cmp(b.comment.created_at.as_ref()) }); CommentNode { comment: comment.clone(), replies, } } let mut anchored: Vec = Vec::new(); let mut non_anchored: Vec = Vec::new(); for comment in &top_level_refs { let node = build_node(comment, &reply_map, 0); match (comment.anchor.as_ref(), transcript) { (Some(range), Some(tx)) => { let (word_start, word_end) = map_byte_range_to_words(tx, range.byte_start, range.byte_end); anchored.push(AnchoredComment { comment_node: node, word_start, word_end, }); } // No anchor, or anchor but no transcript to map against → non-anchored. _ => { non_anchored.push(node); } } } // Sort anchored by byte_start; non-anchored by created_at. anchored.sort_by_key(|a| { a.comment_node .comment .anchor .as_ref() .map_or(0, |r| r.byte_start) }); non_anchored.sort_by(|a, b| { a.comment .created_at .as_ref() .cmp(b.comment.created_at.as_ref()) }); CommentTree { anchored, non_anchored, } } /// Map a byte range `[byte_start, byte_end)` to `(first_word_index, last_word_index)`. /// /// Any word whose byte span overlaps with the query range is included. This /// naturally implements the "expand to full word boundary" behaviour required by /// AC2.8: if the range starts or ends mid-word, that word is included. /// /// `byte_end` is clamped to `transcript.full_text.len()` before the lookup /// (AC2.7). If no words overlap — for example an empty transcript or a range /// entirely outside the text — `(0, 0)` is returned. pub fn map_byte_range_to_words( transcript: &TranscriptData, byte_start: usize, byte_end: usize, ) -> (usize, usize) { if transcript.words.is_empty() { return (0, 0); } // Clamp byte_end to the length of the full text (AC2.7). let text_len = transcript.full_text.len(); let clamped_end = byte_end.min(text_len); // If the range is empty after clamping, return (0, 0). if byte_start >= clamped_end && byte_start >= text_len { return (0, 0); } let mut first: Option = None; let mut last: Option = None; for (idx, word) in transcript.words.iter().enumerate() { let word_start = word.byte_offset; let word_end = word.byte_offset + word.byte_len; // Overlap check: ranges [byte_start, clamped_end) and [word_start, word_end) // overlap when byte_start < word_end && clamped_end > word_start. let query_end = if clamped_end == byte_start { // Zero-length range: treat as a single-byte probe at byte_start. byte_start + 1 } else { clamped_end }; if byte_start < word_end && query_end > word_start { if first.is_none() { first = Some(idx); } last = Some(idx); } } match (first, last) { (Some(f), Some(l)) => (f, l), _ => (0, 0), } } #[cfg(test)] mod tests { use super::*; use jacquard::deps::smol_str::SmolStr; use jacquard::types::{aturi::AtUri, string::Datetime}; use vodplace_subtitle::{TranscriptData, TranscriptWord}; use crate::catalog::{ByteRangeData, CommentData}; // ── helpers ─────────────────────────────────────────────────────────────── fn make_did(n: u8) -> jacquard::types::did::Did { format!("did:plc:{:0>24}", n).parse().unwrap() } fn make_handle(n: u8) -> jacquard::types::handle::Handle { format!("user{n}.test").parse().unwrap() } fn make_uri(s: &str) -> AtUri { s.parse().unwrap() } fn make_dt(s: &str) -> Datetime { s.parse().unwrap() } /// Build a comment where `subject_uri` is the talk URI (top-level). fn top_level( rkey: &str, subject: &str, anchor: Option, created_at: &str, ) -> CommentData { let uri_str = format!("at://did:plc:000000000000000000000001/tv.ionosphere.comment/{rkey}"); CommentData { uri: make_uri(&uri_str), text: SmolStr::new("comment text"), author_did: make_did(1), author_handle: make_handle(1), created_at: make_dt(created_at), anchor, subject_uri: make_uri(subject), facets: None, } } /// Build a comment that replies to `parent_uri`. fn reply(rkey: &str, parent_uri: &str, created_at: &str) -> CommentData { let uri_str = format!("at://did:plc:000000000000000000000002/tv.ionosphere.comment/{rkey}"); CommentData { uri: make_uri(&uri_str), text: SmolStr::new("reply text"), author_did: make_did(2), author_handle: make_handle(2), created_at: make_dt(created_at), anchor: None, subject_uri: make_uri(parent_uri), facets: None, } } /// Build a minimal TranscriptData from (text, words) pairs of (word, start_ms, end_ms). fn make_transcript(words_data: &[(&str, u64, u64)]) -> TranscriptData { // Reconstruct full_text with single spaces between words. let mut full_text = String::new(); let mut transcript_words = Vec::new(); let mut byte_cursor = 0usize; for (i, (word, start_ms, end_ms)) in words_data.iter().enumerate() { if i > 0 { full_text.push(' '); byte_cursor += 1; } let byte_offset = byte_cursor; let byte_len = word.len(); full_text.push_str(word); byte_cursor += byte_len; transcript_words.push(TranscriptWord { text: SmolStr::new(*word), start_ms: *start_ms, end_ms: *end_ms, byte_offset, byte_len, }); } TranscriptData { words: transcript_words, full_text: SmolStr::new(&full_text), } } // ── map_byte_range_to_words tests ──────────────────────────────────────── #[test] fn empty_transcript_returns_zero() { let tx = TranscriptData { words: vec![], full_text: SmolStr::new(""), }; assert_eq!(map_byte_range_to_words(&tx, 0, 5), (0, 0)); } #[test] fn exact_word_boundary() { // "hello world foo" // 01234 67890 1234 // 5 1 1111 let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); // "hello" occupies [0, 5) assert_eq!(map_byte_range_to_words(&tx, 0, 5), (0, 0)); // "world" occupies [6, 11) assert_eq!(map_byte_range_to_words(&tx, 6, 11), (1, 1)); // "foo" occupies [12, 15) assert_eq!(map_byte_range_to_words(&tx, 12, 15), (2, 2)); } #[test] fn exact_span_two_words() { // "hello world foo" let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); // Exact span of "hello world": [0, 11) assert_eq!(map_byte_range_to_words(&tx, 0, 11), (0, 1)); } #[test] fn mid_word_start_expands_to_include_word() { // "hello world foo" let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); // Range starts at byte 3 (middle of "hello"), ends at byte 8 (middle of "world"). // Should include both words: indices 0 and 1. assert_eq!(map_byte_range_to_words(&tx, 3, 8), (0, 1)); } #[test] fn byte_end_past_transcript_length_clamps() { // "hello world" — length 11 let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300)]); let text_len = tx.full_text.len(); // 11 // byte_end = 9999, should clamp to text_len and still return last word. assert_eq!(map_byte_range_to_words(&tx, 6, 9999), (1, 1)); // Also works when byte_start is 0. assert_eq!(map_byte_range_to_words(&tx, 0, 9999), (0, 1)); let _ = text_len; // suppress unused warning } #[test] fn overlapping_ranges_independent() { // "hello world foo" let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); let range_a = (0usize, 7usize); // overlaps "hello" and "world" let range_b = (4usize, 13usize); // overlaps "hello", "world", "foo" let result_a = map_byte_range_to_words(&tx, range_a.0, range_a.1); let result_b = map_byte_range_to_words(&tx, range_b.0, range_b.1); assert_eq!(result_a, (0, 1)); assert_eq!(result_b, (0, 2)); // They are computed independently — each returns its own result. assert_ne!(result_a, result_b); } // ── build_comment_tree tests ───────────────────────────────────────────── const TALK_URI: &str = "at://did:plc:talk000000000000000000001/tv.ionosphere.talk/self"; #[test] fn no_comments_produces_empty_tree() { let tree = build_comment_tree(&[], None); assert!(tree.anchored.is_empty()); assert!(tree.non_anchored.is_empty()); } #[test] fn top_level_non_anchored_comment() { let comments = vec![top_level( "aaaa", TALK_URI, None, "2024-01-01T00:00:00.000Z", )]; let tree = build_comment_tree(&comments, None); assert!(tree.anchored.is_empty()); assert_eq!(tree.non_anchored.len(), 1); assert!(tree.non_anchored[0].replies.is_empty()); } #[test] fn reply_nested_under_parent() { let parent_uri = "at://did:plc:000000000000000000000001/tv.ionosphere.comment/aaaa"; let comments = vec![ top_level("aaaa", TALK_URI, None, "2024-01-01T00:00:00.000Z"), reply("bbbb", parent_uri, "2024-01-01T00:01:00.000Z"), ]; let tree = build_comment_tree(&comments, None); assert_eq!(tree.non_anchored.len(), 1); assert_eq!(tree.non_anchored[0].replies.len(), 1); assert_eq!( tree.non_anchored[0].replies[0].comment.uri.as_str(), "at://did:plc:000000000000000000000002/tv.ionosphere.comment/bbbb" ); } #[test] fn orphaned_reply_treated_as_top_level() { // A comment whose subject_uri points to a URI not in the comment list. let unknown_parent = "at://did:plc:xxxxxxxxxxxxxxxxxxxxxxxxx/tv.ionosphere.comment/xxxx"; let comments = vec![reply("bbbb", unknown_parent, "2024-01-01T00:01:00.000Z")]; let tree = build_comment_tree(&comments, None); // Orphaned reply becomes top-level (non-anchored, no anchor field). assert_eq!(tree.non_anchored.len(), 1); assert!(tree.anchored.is_empty()); } #[test] fn anchored_comment_mapped_when_transcript_provided() { let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); let anchor = ByteRangeData { byte_start: 0, byte_end: 5, }; let comments = vec![top_level( "aaaa", TALK_URI, Some(anchor), "2024-01-01T00:00:00.000Z", )]; let tree = build_comment_tree(&comments, Some(&tx)); assert_eq!(tree.anchored.len(), 1); assert!(tree.non_anchored.is_empty()); let anchored = &tree.anchored[0]; assert_eq!(anchored.word_start, 0); assert_eq!(anchored.word_end, 0); } #[test] fn anchored_comment_without_transcript_demoted_to_non_anchored() { let anchor = ByteRangeData { byte_start: 0, byte_end: 5, }; let comments = vec![top_level( "aaaa", TALK_URI, Some(anchor), "2024-01-01T00:00:00.000Z", )]; let tree = build_comment_tree(&comments, None); // No transcript → cannot map → demoted. assert!(tree.anchored.is_empty()); assert_eq!(tree.non_anchored.len(), 1); } #[test] fn overlapping_anchored_ranges_both_present() { let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); let anchor_a = ByteRangeData { byte_start: 0, byte_end: 7, }; let anchor_b = ByteRangeData { byte_start: 4, byte_end: 13, }; let uri_b = format!( "at://did:plc:000000000000000000000003/tv.ionosphere.comment/cccc" ); let comments = vec![ top_level("aaaa", TALK_URI, Some(anchor_a), "2024-01-01T00:00:00.000Z"), CommentData { uri: uri_b.parse().unwrap(), text: SmolStr::new("another comment"), author_did: make_did(3), author_handle: make_handle(3), created_at: make_dt("2024-01-01T00:02:00.000Z"), anchor: Some(anchor_b), subject_uri: make_uri(TALK_URI), facets: None, }, ]; let tree = build_comment_tree(&comments, Some(&tx)); // Both overlapping anchored ranges produce separate AnchoredComment entries (AC2.9). assert_eq!(tree.anchored.len(), 2); assert!(tree.non_anchored.is_empty()); let a = &tree.anchored[0]; let b = &tree.anchored[1]; assert_eq!(a.word_start, 0); assert_eq!(a.word_end, 1); // "hello world" assert_eq!(b.word_start, 0); assert_eq!(b.word_end, 2); // "hello world foo" } #[test] fn replies_sorted_by_created_at() { let parent_uri = "at://did:plc:000000000000000000000001/tv.ionosphere.comment/aaaa"; let comments = vec![ top_level("aaaa", TALK_URI, None, "2024-01-01T00:00:00.000Z"), reply("cccc", parent_uri, "2024-01-01T00:03:00.000Z"), reply("bbbb", parent_uri, "2024-01-01T00:01:00.000Z"), ]; let tree = build_comment_tree(&comments, None); let replies = &tree.non_anchored[0].replies; assert_eq!(replies.len(), 2); // Should be sorted: bbbb (00:01) before cccc (00:03). let rkey_0 = replies[0] .comment .uri .as_str() .rsplit('/') .next() .unwrap(); let rkey_1 = replies[1] .comment .uri .as_str() .rsplit('/') .next() .unwrap(); assert_eq!(rkey_0, "bbbb"); assert_eq!(rkey_1, "cccc"); } }