diff --git a/.gitignore b/.gitignore index e8777ce..fcab843 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ temp_images/ tmp/ logo.inspirations/ flamegraph.svg +.claude/ +.claude diff --git a/fix_highlights.py b/fix_highlights.py new file mode 100644 index 0000000..3649331 --- /dev/null +++ b/fix_highlights.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +Script to fix incorrect word_range values in bookokrat comment files. +Removes word_range fields that are clearly character offsets instead of word indices. +""" + +import yaml +import sys + +def count_words(text): + """Count words in text, matching the Rust implementation.""" + word_count = 0 + in_word = False + + for ch in text: + if ch.isspace(): + in_word = False + elif not in_word: + word_count += 1 + in_word = True + + return word_count + +def fix_comment(comment): + """Fix a single comment entry.""" + # Only process paragraph comments with word_range + if comment.get('target_kind') != 'paragraph': + return comment + + word_range = comment.get('word_range') + if not word_range: + return comment + + content = comment.get('content', '') + actual_word_count = count_words(content) + + # The word_range should be [start, end] where end <= actual_word_count + # If the range is way larger than the content, it's likely character offsets + start, end = word_range + range_size = end - start + + # If the range suggests more than 3x the actual words, it's probably wrong + # Also if start is 0 and end > actual_word_count, remove it + if range_size > actual_word_count * 3 or (start == 0 and end > actual_word_count * 2): + print(f" Removing bad word_range {word_range} from paragraph {comment.get('paragraph_index')} " + f"(content has ~{actual_word_count} words)") + del comment['word_range'] + + return comment + +def main(): + if len(sys.argv) != 2: + print("Usage: python3 fix_highlights.py ") + sys.exit(1) + + yaml_file = sys.argv[1] + + print(f"Loading {yaml_file}...") + with open(yaml_file, 'r') as f: + comments = yaml.safe_load(f) or [] + + print(f"Found {len(comments)} comments") + + # Fix each comment + fixed_comments = [fix_comment(c) for c in comments] + + # Save with blank lines between entries + print(f"\nSaving fixed comments to {yaml_file}...") + with open(yaml_file, 'w') as f: + for i, comment in enumerate(fixed_comments): + if i > 0: + f.write('\n') # Add blank line before each entry except the first + yaml.dump([comment], f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + print("Done!") + +if __name__ == '__main__': + main() diff --git a/migrate_word_ranges.py b/migrate_word_ranges.py new file mode 100644 index 0000000..5c9dfa3 --- /dev/null +++ b/migrate_word_ranges.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +Script to fix word_range values in bookokrat comments by extracting the actual +text from the EPUB and recalculating word positions. +""" + +import yaml +import sys +from pathlib import Path +import zipfile +from html.parser import HTMLParser + + +class TextExtractor(HTMLParser): + """Extract plain text from HTML.""" + def __init__(self): + super().__init__() + self.text_parts = [] + self.skip_tags = {'script', 'style', 'head'} + self.current_tag = None + + def handle_starttag(self, tag, attrs): + if tag.lower() in self.skip_tags: + self.current_tag = tag + _ = attrs # Unused but required by interface + + def handle_endtag(self, tag): + if tag.lower() == self.current_tag: + self.current_tag = None + + def handle_data(self, data): + if not self.current_tag: + self.text_parts.append(data) + + def get_text(self): + return ''.join(self.text_parts) + + +def extract_epub_chapter(epub_path, chapter_href): + """Extract text content from an EPUB chapter.""" + try: + with zipfile.ZipFile(epub_path, 'r') as epub: + # Try common paths + possible_paths = [ + chapter_href, + f'OEBPS/{chapter_href.split("/")[-1]}', + chapter_href.replace('OEBPS/', '') + ] + + for path in possible_paths: + try: + html_content = epub.read(path).decode('utf-8') + parser = TextExtractor() + parser.feed(html_content) + return parser.get_text() + except KeyError: + continue + + print(f" WARNING: Could not find {chapter_href} in EPUB") + return None + except Exception as e: + print(f" ERROR extracting chapter: {e}") + return None + + +def count_words(text): + """Count words in text, matching Rust implementation.""" + word_count = 0 + in_word = False + + for ch in text: + if ch.isspace(): + in_word = False + elif not in_word: + word_count += 1 + in_word = True + + return word_count + + +def find_text_in_paragraph(paragraph_text, search_text): + """ + Find the word range of search_text within paragraph_text. + Returns (start_word, end_word) or None if not found. + """ + # Normalize whitespace + para_normalized = ' '.join(paragraph_text.split()) + search_normalized = ' '.join(search_text.split()) + + # Try to find the text + if search_normalized not in para_normalized: + # Try case-insensitive + para_lower = para_normalized.lower() + search_lower = search_normalized.lower() + if search_lower not in para_lower: + return None + char_pos = para_lower.index(search_lower) + else: + char_pos = para_normalized.index(search_normalized) + + # Convert character position to word index + words_before = count_words(para_normalized[:char_pos]) + words_in_selection = count_words(para_normalized[char_pos:char_pos + len(search_normalized)]) + + return (words_before, words_before + words_in_selection) + + +def split_into_paragraphs(text): + """Split text into paragraphs.""" + # Split on double newlines or significant whitespace + paragraphs = [] + current = [] + + for line in text.split('\n'): + line = line.strip() + if line: + current.append(line) + elif current: + paragraphs.append(' '.join(current)) + current = [] + + if current: + paragraphs.append(' '.join(current)) + + return paragraphs + + +def fix_comment_with_epub(comment, epub_path): + """Fix a comment's word_range by extracting text from the EPUB.""" + # Only process paragraph comments with word_range + if comment.get('target_kind') != 'paragraph': + return comment, False + + word_range = comment.get('word_range') + if not word_range: + return comment, False + + chapter_href = comment.get('chapter_href') + paragraph_index = comment.get('paragraph_index') + selected_text = comment.get('selected_text') + + # If no selected_text was saved, we can't fix this accurately + if not selected_text: + # Check if word_range looks suspicious (too large) + _start, end = word_range + if end > 200: # Likely character count, not word count + print(f" Paragraph {paragraph_index}: No selected_text saved, removing suspicious word_range {word_range}") + del comment['word_range'] + return comment, True + return comment, False + + # Extract chapter content + chapter_text = extract_epub_chapter(epub_path, chapter_href) + if not chapter_text: + return comment, False + + # Split into paragraphs + paragraphs = split_into_paragraphs(chapter_text) + + if paragraph_index >= len(paragraphs): + print(f" WARNING: Paragraph index {paragraph_index} out of range (only {len(paragraphs)} paragraphs)") + return comment, False + + paragraph_text = paragraphs[paragraph_index] + + # Find the selected text in the paragraph + new_word_range = find_text_in_paragraph(paragraph_text, selected_text) + + if new_word_range: + old_range = tuple(word_range) + if old_range != new_word_range: + print(f" Paragraph {paragraph_index}: Fixed word_range from {old_range} to {new_word_range}") + comment['word_range'] = list(new_word_range) + return comment, True + else: + print(f" Paragraph {paragraph_index}: word_range {old_range} already correct") + return comment, False + else: + print(f" WARNING: Could not find selected text in paragraph {paragraph_index}") + print(f" Selected: {selected_text[:100]}...") + print(f" Paragraph: {paragraph_text[:100]}...") + return comment, False + + +def main(): + if len(sys.argv) != 3: + print("Usage: python3 migrate_word_ranges.py ") + sys.exit(1) + + yaml_file = sys.argv[1] + epub_file = sys.argv[2] + + if not Path(yaml_file).exists(): + print(f"ERROR: YAML file not found: {yaml_file}") + sys.exit(1) + + if not Path(epub_file).exists(): + print(f"ERROR: EPUB file not found: {epub_file}") + sys.exit(1) + + print(f"Loading comments from {yaml_file}...") + with open(yaml_file, 'r') as f: + comments = yaml.safe_load(f) or [] + + print(f"Found {len(comments)} comments\n") + print(f"Extracting text from {epub_file}...\n") + + # Fix each comment + fixed_comments = [] + changed_count = 0 + + for i, comment in enumerate(comments): + fixed_comment, was_changed = fix_comment_with_epub(comment, epub_file) + fixed_comments.append(fixed_comment) + if was_changed: + changed_count += 1 + + print(f"\n{'='*60}") + print(f"Fixed {changed_count} out of {len(comments)} comments") + print(f"{'='*60}\n") + + # Backup original file + backup_file = yaml_file + '.backup' + print(f"Creating backup at {backup_file}") + with open(backup_file, 'w') as f: + with open(yaml_file, 'r') as orig: + f.write(orig.read()) + + # Save fixed comments with blank lines between entries + print(f"Saving fixed comments to {yaml_file}...") + with open(yaml_file, 'w') as f: + for i, comment in enumerate(fixed_comments): + if i > 0: + f.write('\n') # Add blank line before each entry + yaml.dump([comment], f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + print("Done!") + print(f"\nOriginal file backed up to: {backup_file}") + + +if __name__ == '__main__': + main() diff --git a/src/comments.rs b/src/comments.rs index 0226622..2157a80 100644 --- a/src/comments.rs +++ b/src/comments.rs @@ -5,19 +5,41 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +/// Unified comment target format - always uses paragraph_range for consistency #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "target_kind", rename_all = "snake_case")] pub enum CommentTarget { + /// Legacy format for backward compatibility - converted to ParagraphRange on save + #[serde(skip_serializing)] Paragraph { paragraph_index: usize, #[serde(default, skip_serializing_if = "Option::is_none")] word_range: Option<(usize, usize)>, }, + /// Legacy format for backward compatibility - converted to ParagraphRange on save + #[serde(skip_serializing)] CodeBlock { paragraph_index: usize, /// Inclusive line range within the code block. line_range: (usize, usize), }, + /// Standard format for all comments + /// - Single paragraph: start_paragraph_index == end_paragraph_index + /// - Full paragraph(s): word offsets are None + /// - Partial selection: word offsets specify the range + /// - List items: list_item_index specifies which bullet (0-indexed) + ParagraphRange { + start_paragraph_index: usize, + end_paragraph_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + start_word_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + end_word_offset: Option, + /// For list items: which bullet in the list (0-indexed) + /// None means all bullets or not a list + #[serde(default, skip_serializing_if = "Option::is_none")] + list_item_index: Option, + }, } impl CommentTarget { @@ -29,13 +51,36 @@ impl CommentTarget { | CommentTarget::CodeBlock { paragraph_index, .. } => *paragraph_index, + CommentTarget::ParagraphRange { + start_paragraph_index, + .. + } => *start_paragraph_index, } } + /// Returns word range for single-paragraph selections + /// For ParagraphRange where start == end, returns the word offsets pub fn word_range(&self) -> Option<(usize, usize)> { match self { CommentTarget::Paragraph { word_range, .. } => *word_range, CommentTarget::CodeBlock { .. } => None, + CommentTarget::ParagraphRange { + start_paragraph_index, + end_paragraph_index, + start_word_offset, + end_word_offset, + .. + } => { + // For single-paragraph ranges, return word offsets if both are present + if start_paragraph_index == end_paragraph_index { + match (start_word_offset, end_word_offset) { + (Some(start), Some(end)) => Some((*start, *end)), + _ => None, + } + } else { + None + } + } } } @@ -43,6 +88,7 @@ impl CommentTarget { match self { CommentTarget::Paragraph { .. } => None, CommentTarget::CodeBlock { line_range, .. } => Some(*line_range), + CommentTarget::ParagraphRange { .. } => None, } } @@ -50,6 +96,7 @@ impl CommentTarget { match self { CommentTarget::Paragraph { .. } => 0, CommentTarget::CodeBlock { .. } => 1, + CommentTarget::ParagraphRange { .. } => 2, } } @@ -59,6 +106,22 @@ impl CommentTarget { .map(|(start, end)| (start, end)) .unwrap_or((0, 0)), CommentTarget::CodeBlock { line_range, .. } => *line_range, + CommentTarget::ParagraphRange { + start_word_offset, + end_word_offset, + .. + } => (start_word_offset.unwrap_or(0), end_word_offset.unwrap_or(0)), + } + } + + pub fn paragraph_range(&self) -> Option<(usize, usize)> { + match self { + CommentTarget::ParagraphRange { + start_paragraph_index, + end_paragraph_index, + .. + } => Some((*start_paragraph_index, *end_paragraph_index)), + _ => None, } } } @@ -68,15 +131,49 @@ pub struct Comment { pub chapter_href: String, pub target: CommentTarget, pub content: String, + pub context: Option, // The text that was highlighted/annotated + pub highlight_only: bool, // True if this is just a highlight without a note pub updated_at: DateTime, } +impl Comment { + /// Normalize to the standard format (ParagraphRange) + pub fn normalize(&mut self) { + self.target = match &self.target { + CommentTarget::Paragraph { + paragraph_index, + word_range, + } => CommentTarget::ParagraphRange { + start_paragraph_index: *paragraph_index, + end_paragraph_index: *paragraph_index, + start_word_offset: word_range.map(|(start, _)| start), + end_word_offset: word_range.map(|(_, end)| end), + list_item_index: None, + }, + CommentTarget::CodeBlock { + paragraph_index, .. + } => CommentTarget::ParagraphRange { + start_paragraph_index: *paragraph_index, + end_paragraph_index: *paragraph_index, + start_word_offset: None, + end_word_offset: None, + list_item_index: None, + }, + CommentTarget::ParagraphRange { .. } => return, // Already normalized + }; + } +} + #[derive(Serialize, Deserialize)] struct CommentModernSerde { pub chapter_href: String, #[serde(flatten)] pub target: CommentTarget, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, pub content: String, + #[serde(default)] + pub highlight_only: bool, pub updated_at: DateTime, } @@ -87,7 +184,13 @@ struct CommentLegacySerde { pub paragraph_index: usize, #[serde(default)] pub word_range: Option<(usize, usize)>, + #[serde(default)] + pub context: Option, + #[serde(default)] + pub selected_text: Option, // Old field name for backward compat pub content: String, + #[serde(default)] + pub highlight_only: bool, pub updated_at: DateTime, } @@ -107,6 +210,9 @@ impl From for Comment { word_range: legacy.word_range, }, content: legacy.content, + // Support both old and new field names + context: legacy.context.or(legacy.selected_text), + highlight_only: legacy.highlight_only, updated_at: legacy.updated_at, } } @@ -118,6 +224,8 @@ impl From for Comment { chapter_href: modern.chapter_href, target: modern.target, content: modern.content, + context: modern.context, + highlight_only: modern.highlight_only, updated_at: modern.updated_at, } } @@ -125,11 +233,17 @@ impl From for Comment { impl From<&Comment> for CommentModernSerde { fn from(comment: &Comment) -> Self { + // Normalize the comment to standard format + let mut normalized = comment.clone(); + normalized.normalize(); + CommentModernSerde { - chapter_href: comment.chapter_href.clone(), - target: comment.target.clone(), - content: comment.content.clone(), - updated_at: comment.updated_at, + chapter_href: normalized.chapter_href, + target: normalized.target, + context: normalized.context, + content: normalized.content, + highlight_only: normalized.highlight_only, + updated_at: normalized.updated_at, } } } @@ -139,6 +253,7 @@ impl Serialize for Comment { where S: Serializer, { + // Always normalize to standard format when saving CommentModernSerde::from(self).serialize(serializer) } } @@ -164,9 +279,29 @@ impl Comment { matches!(self.target, CommentTarget::Paragraph { .. }) } + pub fn is_paragraph_range_comment(&self) -> bool { + matches!(self.target, CommentTarget::ParagraphRange { .. }) + } + pub fn matches_location(&self, chapter_href: &str, target: &CommentTarget) -> bool { self.chapter_href == chapter_href && self.target == *target } + + pub fn covers_node(&self, node_index: usize) -> bool { + match &self.target { + CommentTarget::Paragraph { + paragraph_index, .. + } + | CommentTarget::CodeBlock { + paragraph_index, .. + } => *paragraph_index == node_index, + CommentTarget::ParagraphRange { + start_paragraph_index, + end_paragraph_index, + .. + } => node_index >= *start_paragraph_index && node_index <= *end_paragraph_index, + } + } } pub struct BookComments { @@ -205,16 +340,35 @@ impl BookComments { comments_by_location: HashMap::new(), }; + let original_count = comments.len(); for comment in comments { + let is_duplicate = book_comments.comments.iter().any(|existing| { + existing.chapter_href == comment.chapter_href + && existing.target == comment.target + && existing.content == comment.content + && existing.updated_at == comment.updated_at + }); + + if is_duplicate { + continue; + } + book_comments.add_to_indices(&comment); book_comments.comments.push(comment); } + if book_comments.comments.len() < original_count { + let _ = book_comments.save_to_disk(); + } + Ok(book_comments) } pub fn add_comment(&mut self, comment: Comment) -> Result<()> { - if matches!(comment.target, CommentTarget::Paragraph { .. }) { + if matches!( + comment.target, + CommentTarget::Paragraph { .. } | CommentTarget::ParagraphRange { .. } + ) { if let Some(existing_idx) = self.find_comment_index(&comment.chapter_href, &comment.target) { @@ -247,6 +401,23 @@ impl BookComments { self.save_to_disk() } + /// Update the context field of a comment (for backfilling) + pub fn update_comment_context( + &mut self, + chapter_href: &str, + target: &CommentTarget, + new_context: String, + ) -> Result<()> { + let idx = self + .find_comment_index(chapter_href, target) + .context("Comment not found")?; + + self.comments[idx].context = Some(new_context); + self.comments[idx].updated_at = Utc::now(); + + self.save_to_disk() + } + pub fn delete_comment(&mut self, chapter_href: &str, target: &CommentTarget) -> Result<()> { let idx = self .find_comment_index(chapter_href, target) @@ -326,7 +497,21 @@ impl BookComments { fn save_to_disk(&self) -> Result<()> { let yaml = serde_yaml::to_string(&self.comments).context("Failed to serialize comments")?; - fs::write(&self.file_path, yaml).context("Failed to write comments file")?; + // Add blank lines between entries for better readability + let formatted_yaml = yaml + .lines() + .fold((String::new(), false), |(mut acc, prev_was_dash), line| { + let is_dash = line.starts_with("- "); + if is_dash && prev_was_dash { + acc.push('\n'); + } + acc.push_str(line); + acc.push('\n'); + (acc, is_dash) + }) + .0; + + fs::write(&self.file_path, formatted_yaml).context("Failed to write comments file")?; Ok(()) } @@ -339,23 +524,55 @@ impl BookComments { fn add_to_indices(&mut self, comment: &Comment) { let idx = self.comments.len(); - self.comments_by_location + let chapter_map = self + .comments_by_location .entry(comment.chapter_href.clone()) - .or_default() - .entry(comment.node_index()) - .or_default() - .push(idx); + .or_default(); + + match &comment.target { + CommentTarget::ParagraphRange { + start_paragraph_index, + end_paragraph_index, + .. + } => { + for node_idx in *start_paragraph_index..=*end_paragraph_index { + chapter_map.entry(node_idx).or_default().push(idx); + } + } + _ => { + chapter_map + .entry(comment.node_index()) + .or_default() + .push(idx); + } + } } fn rebuild_indices(&mut self) { self.comments_by_location.clear(); for (idx, comment) in self.comments.iter().enumerate() { - self.comments_by_location + let chapter_map = self + .comments_by_location .entry(comment.chapter_href.clone()) - .or_default() - .entry(comment.node_index()) - .or_default() - .push(idx); + .or_default(); + + match &comment.target { + CommentTarget::ParagraphRange { + start_paragraph_index, + end_paragraph_index, + .. + } => { + for node_idx in *start_paragraph_index..=*end_paragraph_index { + chapter_map.entry(node_idx).or_default().push(idx); + } + } + _ => { + chapter_map + .entry(comment.node_index()) + .or_default() + .push(idx); + } + } } } @@ -401,6 +618,8 @@ mod tests { word_range: None, }, content: content.to_string(), + selected_text: None, + highlight_only: false, updated_at: Utc::now(), } } @@ -418,6 +637,8 @@ mod tests { line_range, }, content: content.to_string(), + selected_text: None, + highlight_only: false, updated_at: Utc::now(), } } diff --git a/src/export/exporter.rs b/src/export/exporter.rs new file mode 100644 index 0000000..413ae9f --- /dev/null +++ b/src/export/exporter.rs @@ -0,0 +1,421 @@ +use crate::comments::{BookComments, Comment}; +use crate::export::filename::sanitize_filename; +use crate::export::template::TemplateEngine; +use crate::parsing::html_to_markdown::HtmlToMarkdownConverter; +use crate::parsing::markdown_renderer::MarkdownRenderer; +use crate::widget::export_menu::{ExportContent, ExportFormat, ExportOrganization}; +use anyhow::{Context, Result}; +use chrono::Local; +use epub::doc::EpubDoc; +use log::{debug, info}; +use std::collections::HashMap; +use std::fs; +use std::io::{Read, Seek}; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub enum ExportError { + NoAnnotations, + ExportDirNotFound, + WriteError(String), +} + +impl std::fmt::Display for ExportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExportError::NoAnnotations => write!(f, "No annotations found to export"), + ExportError::ExportDirNotFound => write!(f, "Export directory not found"), + ExportError::WriteError(msg) => write!(f, "Failed to write export: {}", msg), + } + } +} + +impl std::error::Error for ExportError {} + +pub struct AnnotationExporter; + +impl AnnotationExporter { + pub fn export( + book_comments: &BookComments, + epub: &mut EpubDoc, + book_title: &str, + export_dir: &Path, + format: ExportFormat, + content: ExportContent, + organization: ExportOrganization, + frontmatter_template: &str, + ) -> Result> { + let comments = book_comments.get_all_comments(); + + if comments.is_empty() { + return Err(ExportError::NoAnnotations.into()); + } + + if !export_dir.exists() { + return Err(ExportError::ExportDirNotFound.into()); + } + + info!( + "Exporting {} annotations in {:?} format with {:?} organization", + comments.len(), + format, + organization + ); + + match organization { + ExportOrganization::SingleFile => Self::export_single_file( + book_comments, + epub, + book_title, + export_dir, + format, + content, + frontmatter_template, + ), + ExportOrganization::ChapterBased => Self::export_chapter_based( + book_comments, + epub, + book_title, + export_dir, + format, + content, + frontmatter_template, + ), + } + } + + fn export_single_file( + book_comments: &BookComments, + epub: &mut EpubDoc, + book_title: &str, + export_dir: &Path, + format: ExportFormat, + content: ExportContent, + frontmatter_template: &str, + ) -> Result> { + let comments = book_comments.get_all_comments(); + let extension = Self::format_extension(format); + let sanitized_title = sanitize_filename(book_title); + let filename = format!("{}.{}", sanitized_title, extension); + let filepath = export_dir.join(&filename); + + let mut output = String::new(); + + // Add frontmatter + let template_vars = Self::build_template_vars(book_title, None, comments.len()); + let frontmatter = TemplateEngine::render(frontmatter_template, &template_vars); + output.push_str(&frontmatter); + output.push('\n'); + + // Group comments by chapter + let chapters = Self::group_comments_by_chapter(comments); + + // Get chapter titles + let chapter_titles = Self::extract_chapter_titles(epub); + + for (chapter_href, chapter_comments) in chapters { + let chapter_title = chapter_titles + .get(&chapter_href) + .map(|s| s.as_str()) + .unwrap_or(&chapter_href); + + output.push_str(&Self::format_chapter_header(chapter_title, format)); + output.push('\n'); + + for (idx, comment) in chapter_comments.iter().enumerate() { + let annotation_text = + Self::format_annotation(comment, idx + 1, format, content, epub)?; + output.push_str(&annotation_text); + output.push('\n'); + } + + output.push('\n'); + } + + fs::write(&filepath, output) + .with_context(|| format!("Failed to write to {}", filepath.display()))?; + + info!("Exported annotations to: {}", filepath.display()); + Ok(vec![filepath]) + } + + fn export_chapter_based( + book_comments: &BookComments, + epub: &mut EpubDoc, + book_title: &str, + export_dir: &Path, + format: ExportFormat, + content: ExportContent, + frontmatter_template: &str, + ) -> Result> { + let comments = book_comments.get_all_comments(); + let extension = Self::format_extension(format); + let sanitized_title = sanitize_filename(book_title); + + let chapters = Self::group_comments_by_chapter(comments); + let chapter_titles = Self::extract_chapter_titles(epub); + + let mut exported_files = Vec::new(); + + for (chapter_href, chapter_comments) in chapters { + let chapter_title = chapter_titles + .get(&chapter_href) + .map(|s| s.as_str()) + .unwrap_or(&chapter_href); + + // Get chapter index from href + let chapter_index = Self::get_chapter_index_from_href(epub, &chapter_href); + + for (annotation_num, comment) in chapter_comments.iter().enumerate() { + let filename = format!( + "{}-ch{}-{:02}.{}", + sanitized_title, + chapter_index + 1, + annotation_num + 1, + extension + ); + let filepath = export_dir.join(&filename); + + let mut output = String::new(); + + // Add frontmatter + let template_vars = Self::build_template_vars(book_title, Some(chapter_title), 1); + let frontmatter = TemplateEngine::render(frontmatter_template, &template_vars); + output.push_str(&frontmatter); + output.push('\n'); + + // Add chapter header + output.push_str(&Self::format_chapter_header(chapter_title, format)); + output.push('\n'); + + let annotation_text = Self::format_annotation(comment, 1, format, content, epub)?; + output.push_str(&annotation_text); + output.push('\n'); + + fs::write(&filepath, output) + .with_context(|| format!("Failed to write to {}", filepath.display()))?; + + debug!("Exported chapter annotation to: {}", filepath.display()); + exported_files.push(filepath); + } + } + + info!("Exported {} chapter files", exported_files.len()); + Ok(exported_files) + } + + fn format_annotation( + comment: &Comment, + number: usize, + format: ExportFormat, + content: ExportContent, + epub: &mut EpubDoc, + ) -> Result { + let mut output = String::new(); + + // Format timestamp + let timestamp = comment.updated_at.format("%Y-%m-%d %H:%M").to_string(); + + match format { + ExportFormat::Markdown => { + output.push_str(&format!("### Annotation {}\n\n", number)); + output.push_str(&format!("**Date:** {}\n\n", timestamp)); + + if matches!(content, ExportContent::AnnotationsWithContext) { + if let Some(context) = Self::extract_context(comment, epub)? { + output.push_str("> "); + output.push_str(&context.replace('\n', "\n> ")); + output.push_str("\n\n"); + } + } + + output.push_str(&format!("**Note:** {}\n\n", comment.content)); + } + ExportFormat::OrgMode => { + output.push_str(&format!("*** Annotation {}\n", number)); + output.push_str(&format!(":PROPERTIES:\n:DATE: {}\n:END:\n\n", timestamp)); + + if matches!(content, ExportContent::AnnotationsWithContext) { + if let Some(context) = Self::extract_context(comment, epub)? { + output.push_str("#+BEGIN_QUOTE\n"); + output.push_str(&context); + output.push_str("\n#+END_QUOTE\n\n"); + } + } + + output.push_str(&format!("{}\n\n", comment.content)); + } + ExportFormat::PlainText => { + output.push_str(&format!("Annotation {}\n", number)); + output.push_str(&format!("Date: {}\n\n", timestamp)); + + if matches!(content, ExportContent::AnnotationsWithContext) { + if let Some(context) = Self::extract_context(comment, epub)? { + output.push_str("Context:\n"); + output.push_str(&context); + output.push_str("\n\n"); + } + } + + output.push_str(&format!("Note:\n{}\n\n", comment.content)); + output.push_str("---\n\n"); + } + } + + Ok(output) + } + + fn extract_context( + comment: &Comment, + epub: &mut EpubDoc, + ) -> Result> { + // If context is available, use it + if let Some(ref selected) = comment.context { + return Ok(Some(selected.clone())); + } + + // Otherwise, extract from EPUB + let original_chapter = epub.get_current_chapter(); + + // Navigate to comment's chapter + let chapter_path = PathBuf::from(&comment.chapter_href); + if let Some(chapter_idx) = epub.resource_uri_to_chapter(&chapter_path) { + if epub.set_current_chapter(chapter_idx) { + if let Some((content_bytes, _)) = epub.get_current_str() { + // Parse HTML to Markdown AST + let mut converter = HtmlToMarkdownConverter::new(); + let doc = converter.convert(&content_bytes); + + // Find the paragraph node + let node_index = comment.target.node_index(); + if let Some(node) = doc.blocks.get(node_index) { + // Create a temporary Document with just this node for rendering + use crate::markdown::Document; + let temp_doc: Document = Document { + blocks: vec![node.clone()], + }; + + // Use MarkdownRenderer to convert to text + let renderer = MarkdownRenderer::new(); + let paragraph_text = renderer.render(&temp_doc); + + // If there's a word_range, extract just that portion + if let Some((start, end)) = comment.target.word_range() { + let words: Vec<&str> = paragraph_text.split_whitespace().collect(); + if end <= words.len() { + let selected_words = &words[start..end]; + epub.set_current_chapter(original_chapter); + return Ok(Some(selected_words.join(" "))); + } + } + + // No word range or extraction failed, return full paragraph + epub.set_current_chapter(original_chapter); + return Ok(Some(paragraph_text)); + } + } + } + } + + // Restore original chapter + epub.set_current_chapter(original_chapter); + Ok(None) + } + + fn group_comments_by_chapter(comments: &[Comment]) -> Vec<(String, Vec<&Comment>)> { + let mut chapters: HashMap> = HashMap::new(); + + for comment in comments { + chapters + .entry(comment.chapter_href.clone()) + .or_default() + .push(comment); + } + + let mut result: Vec<_> = chapters.into_iter().collect(); + result.sort_by(|a, b| a.0.cmp(&b.0)); + result + } + + fn extract_chapter_titles(epub: &mut EpubDoc) -> HashMap { + let mut titles = HashMap::new(); + let original_chapter = epub.get_current_chapter(); + + for idx in 0..epub.get_num_chapters() { + if epub.set_current_chapter(idx) { + if let Some((content, _)) = epub.get_current_str() { + let title = Self::extract_title_from_html(&content); + + if let Some(spine_item) = epub.spine.get(idx) { + if let Some(resource) = epub.resources.get(&spine_item.idref) { + let href = resource.path.to_string_lossy().to_string(); + titles.insert(href, title); + } + } + } + } + } + + epub.set_current_chapter(original_chapter); + titles + } + + fn extract_title_from_html(html: &str) -> String { + use regex::Regex; + + // Try to find h1, h2, or title tags + let title_regex = Regex::new(r"<(?:h1|h2|title)[^>]*>(.*?)").unwrap(); + + if let Some(captures) = title_regex.captures(html) { + if let Some(title_match) = captures.get(1) { + let title = title_match.as_str(); + // Strip HTML tags + let tag_strip = Regex::new(r"<[^>]+>").unwrap(); + let clean = tag_strip.replace_all(title, ""); + return clean.trim().to_string(); + } + } + + "Untitled".to_string() + } + + fn get_chapter_index_from_href(epub: &EpubDoc, href: &str) -> usize { + let path = PathBuf::from(href); + epub.resource_uri_to_chapter(&path).unwrap_or(0) + } + + fn format_chapter_header(title: &str, format: ExportFormat) -> String { + match format { + ExportFormat::Markdown => format!("## {}\n", title), + ExportFormat::OrgMode => format!("** {}\n", title), + ExportFormat::PlainText => format!("{}\n{}\n", title, "=".repeat(title.len())), + } + } + + fn format_extension(format: ExportFormat) -> &'static str { + match format { + ExportFormat::Markdown => "md", + ExportFormat::OrgMode => "org", + ExportFormat::PlainText => "txt", + } + } + + fn build_template_vars( + book_title: &str, + chapter_title: Option<&str>, + annotation_count: usize, + ) -> HashMap { + let mut vars = HashMap::new(); + vars.insert("book_title".to_string(), book_title.to_string()); + vars.insert( + "export_date".to_string(), + Local::now().format("%Y-%m-%d").to_string(), + ); + vars.insert("annotation_count".to_string(), annotation_count.to_string()); + vars.insert( + "chapter_title".to_string(), + chapter_title.unwrap_or("All Chapters").to_string(), + ); + vars + } +} diff --git a/src/export/filename.rs b/src/export/filename.rs new file mode 100644 index 0000000..2663988 --- /dev/null +++ b/src/export/filename.rs @@ -0,0 +1,99 @@ +use regex::Regex; + +/// Sanitize a filename for cross-platform compatibility +/// Removes/replaces characters that are invalid on Windows, macOS, or Linux +pub fn sanitize_filename(name: &str) -> String { + // Invalid characters for Windows: < > : " / \ | ? * + // Also remove control characters (0-31) + let invalid_chars = Regex::new(r#"[<>:"/\\|?*\x00-\x1F]"#).unwrap(); + let sanitized = invalid_chars.replace_all(name, "_"); + + // Trim leading/trailing spaces and dots (problematic on Windows) + let sanitized = sanitized.trim_matches(|c| c == ' ' || c == '.'); + + // Handle reserved Windows names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) + let reserved = Regex::new(r"(?i)^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$").unwrap(); + if reserved.is_match(sanitized) { + return format!("_{}", sanitized); + } + + // Limit length to 200 characters (leave room for extensions and numbering) + let sanitized = if sanitized.len() > 200 { + &sanitized[..200] + } else { + &sanitized + }; + + // If empty after sanitization, use a default + if sanitized.is_empty() { + "untitled".to_string() + } else { + sanitized.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_basic() { + assert_eq!(sanitize_filename("Normal Book"), "Normal Book"); + assert_eq!(sanitize_filename("Book: A Tale"), "Book_ A Tale"); + assert_eq!(sanitize_filename("Book/Chapter"), "Book_Chapter"); + assert_eq!(sanitize_filename("Book\\Chapter"), "Book_Chapter"); + assert_eq!(sanitize_filename("Book|Chapter"), "Book_Chapter"); + } + + #[test] + fn test_sanitize_special_chars() { + assert_eq!(sanitize_filename("Book<>Test"), "Book__Test"); + assert_eq!(sanitize_filename("Book?*Test"), "Book__Test"); + assert_eq!(sanitize_filename("Book\"Test"), "Book_Test"); + } + + #[test] + fn test_sanitize_reserved() { + assert_eq!(sanitize_filename("CON"), "_CON"); + assert_eq!(sanitize_filename("con"), "_con"); + assert_eq!(sanitize_filename("COM1"), "_COM1"); + assert_eq!(sanitize_filename("LPT9"), "_LPT9"); + assert_eq!(sanitize_filename("AUX"), "_AUX"); + assert_eq!(sanitize_filename("PRN"), "_PRN"); + assert_eq!(sanitize_filename("NUL"), "_NUL"); + } + + #[test] + fn test_sanitize_empty() { + assert_eq!(sanitize_filename(""), "untitled"); + assert_eq!(sanitize_filename("..."), "untitled"); + assert_eq!(sanitize_filename(" "), "untitled"); + assert_eq!(sanitize_filename(" . "), "untitled"); + } + + #[test] + fn test_sanitize_trim() { + assert_eq!(sanitize_filename(" Book "), "Book"); + assert_eq!(sanitize_filename("..Book.."), "Book"); + assert_eq!(sanitize_filename(" . Book . "), "Book"); + } + + #[test] + fn test_sanitize_long_name() { + let long_name = "a".repeat(250); + let result = sanitize_filename(&long_name); + assert_eq!(result.len(), 200); + } + + #[test] + fn test_sanitize_unicode() { + assert_eq!(sanitize_filename("Book 📖 Test"), "Book 📖 Test"); + assert_eq!(sanitize_filename("日本語"), "日本語"); + } + + #[test] + fn test_sanitize_control_chars() { + assert_eq!(sanitize_filename("Book\x00Test"), "Book_Test"); + assert_eq!(sanitize_filename("Book\x1FTest"), "Book_Test"); + } +} diff --git a/src/export/mod.rs b/src/export/mod.rs new file mode 100644 index 0000000..65b65e3 --- /dev/null +++ b/src/export/mod.rs @@ -0,0 +1,7 @@ +pub mod exporter; +pub mod filename; +pub mod template; + +pub use exporter::{AnnotationExporter, ExportError}; +pub use filename::sanitize_filename; +pub use template::TemplateEngine; diff --git a/src/export/template.rs b/src/export/template.rs new file mode 100644 index 0000000..d88a1a2 --- /dev/null +++ b/src/export/template.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; + +pub struct TemplateEngine; + +impl TemplateEngine { + /// Render a template string by replacing {{variable}} placeholders with actual values + pub fn render(template: &str, variables: &HashMap) -> String { + let mut result = template.to_string(); + + for (key, value) in variables { + let placeholder = format!("{{{{{}}}}}", key); + result = result.replace(&placeholder, value); + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_template_rendering() { + let template = "Title: {{book_title}}\nDate: {{export_date}}"; + let mut vars = HashMap::new(); + vars.insert("book_title".to_string(), "Test Book".to_string()); + vars.insert("export_date".to_string(), "2024-01-01".to_string()); + + let result = TemplateEngine::render(template, &vars); + assert_eq!(result, "Title: Test Book\nDate: 2024-01-01"); + } + + #[test] + fn test_multiple_same_variable() { + let template = "{{name}} is {{name}}"; + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "Bob".to_string()); + + let result = TemplateEngine::render(template, &vars); + assert_eq!(result, "Bob is Bob"); + } + + #[test] + fn test_missing_variable() { + let template = "Title: {{book_title}}\nMissing: {{unknown}}"; + let mut vars = HashMap::new(); + vars.insert("book_title".to_string(), "Test Book".to_string()); + + let result = TemplateEngine::render(template, &vars); + assert_eq!(result, "Title: Test Book\nMissing: {{unknown}}"); + } + + #[test] + fn test_empty_template() { + let template = ""; + let vars = HashMap::new(); + + let result = TemplateEngine::render(template, &vars); + assert_eq!(result, ""); + } + + #[test] + fn test_no_variables() { + let template = "Static text with no variables"; + let vars = HashMap::new(); + + let result = TemplateEngine::render(template, &vars); + assert_eq!(result, "Static text with no variables"); + } +} diff --git a/src/lib.rs b/src/lib.rs index e5e71f1..74354f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod book_manager; pub mod bookmarks; pub mod color_mode; pub mod comments; +pub mod export; pub use inputs::event_source; pub mod components; pub mod images; diff --git a/src/main_app.rs b/src/main_app.rs index 083a96b..9f32ed2 100644 --- a/src/main_app.rs +++ b/src/main_app.rs @@ -22,6 +22,7 @@ use crate::system_command::{RealSystemCommandExecutor, SystemCommandExecutor}; use crate::table_of_contents::TocItem; use crate::theme::{current_theme, current_theme_name}; use crate::types::LinkInfo; +use crate::widget::export_menu::{ExportMenu, ExportMenuAction}; use crate::widget::help_popup::{HelpPopup, HelpPopupAction}; use crate::widget::theme_selector::{ThemeSelector, ThemeSelectorAction}; use image::GenericImageView; @@ -122,6 +123,7 @@ pub struct App { help_popup: Option, comments_viewer: Option, theme_selector: Option, + export_menu: Option, notifications: NotificationManager, help_bar_area: Rect, zen_mode: bool, @@ -160,6 +162,7 @@ pub enum PopupWindow { Help, CommentsViewer, ThemeSelector, + ExportMenu, } impl Default for App { @@ -360,6 +363,7 @@ impl App { help_popup: None, comments_viewer: None, theme_selector: None, + export_menu: None, notifications: NotificationManager::new(), help_bar_area: Rect::default(), zen_mode: false, @@ -527,6 +531,8 @@ impl App { match BookComments::new(&path_buf, self.comments_dir.as_deref()) { Ok(comments) => { + let comment_count = comments.get_all_comments().len(); + debug!("Loaded {} comments for book: {:?}", comment_count, path_buf); let comments_arc = Arc::new(Mutex::new(comments)); self.text_reader.set_book_comments(comments_arc); } @@ -1044,6 +1050,63 @@ impl App { return; } + if matches!( + self.focused_panel, + FocusedPanel::Popup(PopupWindow::ExportMenu) + ) { + let click_x = mouse_event.column; + let click_y = mouse_event.row; + + if let Some(ref mut menu) = self.export_menu { + // Check if click is outside popup area - close it + if menu.is_outside_popup_area(click_x, click_y) { + self.export_menu = None; + self.close_popup_to_previous(); + return; + } + + // Handle single or double click + let click_type = self + .mouse_tracker + .detect_click_type(mouse_event.column, mouse_event.row); + + match click_type { + ClickType::Single | ClickType::Triple => { + menu.handle_mouse_click(mouse_event.column, mouse_event.row); + } + ClickType::Double => { + if menu.handle_mouse_click(mouse_event.column, mouse_event.row) { + // Select on double-click + if let Some(action) = menu.handle_key( + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ), + &mut self.key_sequence, + ) { + match action { + ExportMenuAction::Export { + format, + content, + organization, + } => { + self.handle_export(format, content, organization); + self.export_menu = None; + self.close_popup_to_previous(); + } + ExportMenuAction::Close => { + self.export_menu = None; + self.close_popup_to_previous(); + } + } + } + } + } + } + } + return; + } + if matches!( self.focused_panel, FocusedPanel::Popup(PopupWindow::ThemeSelector) @@ -1615,6 +1678,90 @@ impl App { } } + fn handle_export( + &mut self, + format: crate::widget::export_menu::ExportFormat, + content: crate::widget::export_menu::ExportContent, + organization: crate::widget::export_menu::ExportOrganization, + ) { + use crate::export::AnnotationExporter; + use std::path::PathBuf; + + let Some(book) = &mut self.current_book else { + self.show_error("No book loaded"); + return; + }; + + let book_title = book + .file + .split('/') + .next_back() + .unwrap_or("Unknown") + .trim_end_matches(".epub"); + + let export_dir = PathBuf::from(settings::get_export_directory()); + let frontmatter_template = settings::get_frontmatter_template(); + + // Get book comments + let comments_arc = self.text_reader.get_comments(); + let comments = match comments_arc.lock() { + Ok(guard) => guard, + Err(e) => { + error!("Failed to lock comments: {}", e); + self.show_error("Failed to access annotations"); + return; + } + }; + + match AnnotationExporter::export( + &comments, + &mut book.epub, + book_title, + &export_dir, + format, + content, + organization, + &frontmatter_template, + ) { + Ok(files) => { + let file_count = files.len(); + let msg = if file_count == 1 { + format!("Exported annotations to: {}", files[0].display()) + } else { + format!( + "Exported {} annotation files to: {}", + file_count, + export_dir.display() + ) + }; + self.show_info(&msg); + info!("Export successful: {} files", file_count); + } + Err(e) => { + if let Some(export_err) = e.downcast_ref::() { + use crate::export::ExportError; + match export_err { + ExportError::NoAnnotations => { + self.show_warning("No annotations to export".to_string()); + } + ExportError::ExportDirNotFound => { + self.show_error(format!( + "Export directory not found: {}", + export_dir.display() + )); + } + ExportError::WriteError(msg) => { + self.show_error(format!("Export failed: {}", msg)); + } + } + } else { + error!("Export failed: {}", e); + self.show_error(format!("Export failed: {}", e)); + } + } + } + } + pub fn get_scroll_offset(&self) -> usize { self.text_reader.get_scroll_offset() } @@ -1925,6 +2072,22 @@ impl App { theme_selector.render(f, f.area()); } } + + if matches!( + self.focused_panel, + FocusedPanel::Popup(PopupWindow::ExportMenu) + ) { + let dim_block = Block::default().style( + Style::default() + .bg(Color::Rgb(10, 10, 10)) + .add_modifier(Modifier::DIM), + ); + f.render_widget(dim_block, f.area()); + + if let Some(ref mut export_menu) = self.export_menu { + export_menu.render(f, f.area()); + } + } } fn render_default_content(&self, f: &mut ratatui::Frame, area: Rect, content: &str) { @@ -2107,15 +2270,17 @@ impl App { } _ => "Search mode active".to_string(), } - } else if self.text_reader.has_text_selection() { - "a: Add comment | c/Ctrl+C: Copy to clipboard | ESC: Clear selection".to_string() + } else if self.text_reader.has_text_selection() || self.text_reader.is_visual_mode_active() + { + "a: Add comment | h: Highlight only | y/c/Ctrl+C: Copy | ESC: Clear selection" + .to_string() } else { let help_text = match self.focused_panel { FocusedPanel::Main(MainPanel::NavigationList) => { "j/k: Navigate | Enter: Select | h/l: Fold/Unfold | H/L: Fold/Unfold All | Tab: Switch | q: Quit" } FocusedPanel::Main(MainPanel::Content) => { - "j/k: Scroll | h/l: Chapter | Ctrl+d/u: Half-screen | Tab: Switch | Space+o: Open | q: Quit" + "j/k: Scroll | h/l: Chapter | Ctrl+d/u: Half-screen | n/N: Cursor Mode | Tab: Switch | Space+o: Open | q: Quit" } FocusedPanel::Popup(PopupWindow::ReadingHistory) => { "j/k/Scroll: Navigate | Enter/DblClick: Open | ESC: Close" @@ -2136,6 +2301,9 @@ impl App { FocusedPanel::Popup(PopupWindow::ThemeSelector) => { "j/k: Navigate | Enter: Apply | ESC: Close" } + FocusedPanel::Popup(PopupWindow::ExportMenu) => { + "j/k: Navigate | Enter/l: Select | h/ESC: Back | ESC: Close" + } }; help_text.to_string() }; @@ -2350,6 +2518,20 @@ impl App { self.key_sequence.clear(); true } + " e" => { + // Handle Space->e to open export menu + if self.current_book.is_some() { + if let FocusedPanel::Main(panel) = self.focused_panel { + self.previous_main_panel = panel; + } + self.export_menu = Some(ExportMenu::new()); + self.focused_panel = FocusedPanel::Popup(PopupWindow::ExportMenu); + } else { + self.show_warning("No book loaded".to_string()); + } + self.key_sequence.clear(); + true + } " o" => { // Handle Space->o to open current EPUB with system viewer (global) self.open_with_system_viewer(); @@ -2863,6 +3045,34 @@ impl App { return None; } + // If export menu popup is shown, handle keys for it + if self.focused_panel == FocusedPanel::Popup(PopupWindow::ExportMenu) { + let action = if let Some(ref mut menu) = self.export_menu { + menu.handle_key(key, &mut self.key_sequence) + } else { + None + }; + + if let Some(action) = action { + match action { + ExportMenuAction::Close => { + self.close_popup_to_previous(); + self.export_menu = None; + } + ExportMenuAction::Export { + format, + content, + organization, + } => { + self.handle_export(format, content, organization); + self.export_menu = None; + self.close_popup_to_previous(); + } + } + } + return None; + } + if self.is_search_input_mode() { match key.code { KeyCode::Char(c) => self.handle_search_input(c), @@ -3119,6 +3329,39 @@ impl App { let visual_mode = self.text_reader.get_visual_mode(); match key.code { + KeyCode::Char('a') => { + let success = self.text_reader.convert_visual_to_text_selection() + && self.text_reader.start_comment_input(); + + if success { + debug!("Started comment input mode from visual selection"); + } else { + debug!("Failed to start comment input from visual selection"); + self.notifications + .show_warning("Cannot annotate this selection"); + } + + // Always exit visual mode when 'a' is pressed + self.text_reader.exit_visual_mode(); + return None; + } + KeyCode::Char('h') => { + let success = self.text_reader.convert_visual_to_text_selection() + && self.text_reader.create_highlight_only(); + + if success { + debug!("Created highlight-only annotation from visual selection"); + self.notifications.show_info("Highlight added"); + } else { + debug!("Failed to create highlight from visual selection"); + self.notifications + .show_warning("Cannot highlight this selection"); + } + + // Exit visual mode + self.text_reader.exit_visual_mode(); + return None; + } KeyCode::Char('y') => { if let Some(text) = self.text_reader.yank_visual_selection() { let _ = self.text_reader.copy_to_clipboard(text); @@ -3214,6 +3457,11 @@ impl App { } } + // Check for global hotkeys first (works across all panels) + if self.handle_global_hotkeys(key) { + return None; + } + match key.code { KeyCode::Char('/') => { if self.is_main_panel(MainPanel::Content) { diff --git a/src/markdown.rs b/src/markdown.rs index 36297e5..42ba604 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -433,11 +433,11 @@ impl Text { self.0.len() } - pub fn iter(&self) -> std::slice::Iter { + pub fn iter(&self) -> std::slice::Iter<'_, TextOrInline> { self.0.iter() } - pub fn iter_mut(&mut self) -> std::slice::IterMut { + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, TextOrInline> { self.0.iter_mut() } diff --git a/src/panic_handler.rs b/src/panic_handler.rs index 1a7400e..26f88fa 100644 --- a/src/panic_handler.rs +++ b/src/panic_handler.rs @@ -33,7 +33,3 @@ fn restore_terminal() { let _ = execute!(io::stderr(), crossterm::cursor::Show); let _ = writeln!(io::stderr()); } - -/// Initialize human-panic metadata for release builds -#[cfg(not(debug_assertions))] -use human_panic::Metadata; diff --git a/src/settings.rs b/src/settings.rs index fb713e9..996b201 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -49,6 +49,15 @@ pub struct Settings { #[serde(default)] pub margin: u16, + #[serde(default = "default_annotation_highlight_color")] + pub annotation_highlight_color: String, + + #[serde(default = "default_export_directory")] + pub export_directory: String, + + #[serde(default = "default_frontmatter_template")] + pub frontmatter_template: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub custom_themes: Vec, } @@ -61,12 +70,43 @@ fn default_theme() -> String { "Oceanic Next".to_string() } +fn default_annotation_highlight_color() -> String { + "7FB4CA".to_string() // Cyan (base0C) from Kanagawa theme +} + +fn default_export_directory() -> String { + // Expand ~ to home directory + if let Some(home) = home::home_dir() { + home.join("bookokrat") + .join("notes") + .to_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| "~/bookokrat/notes".to_string()) + } else { + "~/bookokrat/notes".to_string() + } +} + +fn default_frontmatter_template() -> String { + r#"--- +title: "{{book_title}}" +export_date: "{{export_date}}" +annotation_count: {{annotation_count}} +chapter: "{{chapter_title}}" +--- +"# + .to_string() +} + impl Default for Settings { fn default() -> Self { Self { version: CURRENT_VERSION, theme: default_theme(), margin: 0, + annotation_highlight_color: default_annotation_highlight_color(), + export_directory: default_export_directory(), + frontmatter_template: default_frontmatter_template(), custom_themes: Vec::new(), } } @@ -101,13 +141,24 @@ pub fn load_settings() { debug!("Loaded settings from {:?}", path); // Run migrations if needed - if settings.version < CURRENT_VERSION { + let needs_migration = settings.version < CURRENT_VERSION; + if needs_migration { + info!( + "Migrating settings from v{} to v{}", + settings.version, CURRENT_VERSION + ); migrate_settings(&mut settings); - save_settings_to_file(&settings, &path); } if let Ok(mut global) = SETTINGS.write() { - *global = settings; + *global = settings.clone(); + } + + // Only save settings if a migration occurred + // This preserves user's custom formatting and comments + if needs_migration { + info!("Saving migrated settings to {:?}", path); + save_settings_to_file(&settings, &path); } } Err(e) => { @@ -121,11 +172,6 @@ pub fn load_settings() { } fn migrate_settings(settings: &mut Settings) { - info!( - "Migrating settings from v{} to v{}", - settings.version, CURRENT_VERSION - ); - // Future migrations go here: // if settings.version < 2 { // migrate_v1_to_v2(settings); @@ -161,6 +207,52 @@ fn generate_settings_yaml(settings: &Settings) -> String { content.push_str(&format!("theme: \"{}\"\n", settings.theme)); content.push_str(&format!("margin: {}\n", settings.margin)); content.push('\n'); + content.push_str( + "# Annotation highlight color (hex color without #, e.g., \"7FB4CA\" for cyan)\n", + ); + content.push_str("# Set to \"none\" or \"disabled\" to disable highlighting\n"); + content.push_str("# Common color options:\n"); + content.push_str(&format!( + "annotation_highlight_color: \"{}\"\n", + settings.annotation_highlight_color + )); + content.push_str("# annotation_highlight_color: \"none\" # Disable highlighting\n"); + content.push_str("# annotation_highlight_color: \"076678\" # Deep Teal\n"); + content.push_str("# annotation_highlight_color: \"FB4934\" # Vibrant Red\n"); + content.push_str("# annotation_highlight_color: \"B8BB26\" # Mossy Green\n"); + content.push_str("# annotation_highlight_color: \"FABD2F\" # Golden Yellow\n"); + content.push_str("# annotation_highlight_color: \"83A598\" # Soft Blue\n"); + content.push_str("# annotation_highlight_color: \"D3869B\" # Pastel Purple\n"); + content.push_str("# annotation_highlight_color: \"8EC07C\" # Aqua Mint\n"); + content.push_str("# annotation_highlight_color: \"FE8019\" # Bright Orange\n"); + content.push_str("# annotation_highlight_color: \"D65D0E\" # Burnt Sienna\n"); + content.push_str("# annotation_highlight_color: \"A89984\" # Warm Gray\n"); + content.push_str("# annotation_highlight_color: \"B16286\" # Deep Magenta\n"); + content.push('\n'); + + content.push_str( + "# ============================================================================\n", + ); + content.push_str("# Export Settings\n"); + content.push_str( + "# ============================================================================\n", + ); + content.push_str( + "# Directory where annotation exports will be saved (default: current directory)\n", + ); + content.push_str(&format!( + "export_directory: \"{}\"\n", + settings.export_directory + )); + content.push('\n'); + + content.push_str("# Frontmatter template for exported annotations\n"); + content.push_str("# Available variables: {{book_title}}, {{export_date}}, {{annotation_count}}, {{chapter_title}}\n"); + content.push_str("frontmatter_template: |\n"); + for line in settings.frontmatter_template.lines() { + content.push_str(&format!(" {}\n", line)); + } + content.push('\n'); content.push_str(CUSTOM_THEMES_TEMPLATE); @@ -257,3 +349,38 @@ pub fn get_custom_themes() -> Vec { .map(|s| s.custom_themes.clone()) .unwrap_or_default() } + +pub fn get_annotation_highlight_color() -> String { + SETTINGS + .read() + .map(|s| s.annotation_highlight_color.clone()) + .unwrap_or_else(|_| default_annotation_highlight_color()) +} + +pub fn get_export_directory() -> String { + SETTINGS + .read() + .map(|s| s.export_directory.clone()) + .unwrap_or_else(|_| default_export_directory()) +} + +pub fn set_export_directory(dir: String) { + if let Ok(mut settings) = SETTINGS.write() { + settings.export_directory = dir; + } + save_settings(); +} + +pub fn get_frontmatter_template() -> String { + SETTINGS + .read() + .map(|s| s.frontmatter_template.clone()) + .unwrap_or_else(|_| default_frontmatter_template()) +} + +pub fn set_frontmatter_template(template: String) { + if let Ok(mut settings) = SETTINGS.write() { + settings.frontmatter_template = template; + } + save_settings(); +} diff --git a/src/theme.rs b/src/theme.rs index 674e702..f537357 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -404,9 +404,9 @@ impl Base16Palette { pub fn get_panel_colors(&self, is_focused: bool) -> (Color, Color, Color) { if is_focused { - (self.base_07, self.base_04, self.base_00) + (self.base_05, self.base_04, self.base_00) // Use base_05 (Default text) when focused } else { - (self.base_03, self.base_03, self.base_00) + (self.base_04, self.base_03, self.base_00) // Use base_04 (Dark foreground) when unfocused } } diff --git a/src/widget/comments_viewer.rs b/src/widget/comments_viewer.rs index ac873f0..29c5381 100644 --- a/src/widget/comments_viewer.rs +++ b/src/widget/comments_viewer.rs @@ -694,6 +694,7 @@ impl CommentsViewer { } } CommentTarget::Paragraph { .. } => format!("Note // {timestamp}"), + CommentTarget::ParagraphRange { .. } => format!("Multi-paragraph note // {timestamp}"), }; if entry.comment_count() > 1 { diff --git a/src/widget/export_menu.rs b/src/widget/export_menu.rs new file mode 100644 index 0000000..dc2da0c --- /dev/null +++ b/src/widget/export_menu.rs @@ -0,0 +1,382 @@ +use crate::inputs::key_seq::KeySeq; +use crate::main_app::VimNavMotions; +use crate::theme::current_theme; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + Markdown, + OrgMode, + PlainText, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportContent { + AnnotationsOnly, + AnnotationsWithContext, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportOrganization { + SingleFile, + ChapterBased, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MenuStep { + Format, + Content, + Organization, +} + +pub enum ExportMenuAction { + Close, + Export { + format: ExportFormat, + content: ExportContent, + organization: ExportOrganization, + }, +} + +pub struct ExportMenu { + current_step: MenuStep, + state: ListState, + last_popup_area: Option, + + // Selected options (accumulated as user progresses) + selected_format: Option, + selected_content: Option, + selected_organization: Option, +} + +impl ExportMenu { + pub fn new() -> Self { + let mut state = ListState::default(); + state.select(Some(0)); + + Self { + current_step: MenuStep::Format, + state, + last_popup_area: None, + selected_format: None, + selected_content: None, + selected_organization: None, + } + } + + pub fn render(&mut self, f: &mut Frame, area: Rect) { + let popup_area = centered_rect(50, 40, area); + self.last_popup_area = Some(popup_area); + + f.render_widget(Clear, popup_area); + + let palette = current_theme(); + + let (title, items) = self.get_current_menu_items(); + + let list_items: Vec = items + .iter() + .map(|item| { + ListItem::new(Line::from(Span::styled( + item, + Style::default().fg(palette.base_05), + ))) + }) + .collect(); + + let list = List::new(list_items) + .block( + Block::default() + .title(format!(" {} ", title)) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.base_0c)) + .style(Style::default().bg(palette.base_00)), + ) + .highlight_style( + Style::default() + .bg(palette.base_02) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("» "); + + f.render_stateful_widget(list, popup_area, &mut self.state); + } + + fn get_current_menu_items(&self) -> (String, Vec) { + match self.current_step { + MenuStep::Format => ( + "Export Annotations - Select Format".to_string(), + vec![ + "Markdown (.md)".to_string(), + "Org-mode (.org)".to_string(), + "Plain Text (.txt)".to_string(), + ], + ), + MenuStep::Content => ( + "Export Annotations - Content".to_string(), + vec![ + "Annotations only".to_string(), + "Annotations with context".to_string(), + ], + ), + MenuStep::Organization => ( + "Export Annotations - Organization".to_string(), + vec![ + "All notes in single file".to_string(), + "Separate files per chapter".to_string(), + ], + ), + } + } + + fn select_current_item(&mut self) -> Option { + let selected_idx = self.state.selected()?; + + match self.current_step { + MenuStep::Format => { + self.selected_format = Some(match selected_idx { + 0 => ExportFormat::Markdown, + 1 => ExportFormat::OrgMode, + 2 => ExportFormat::PlainText, + _ => return None, + }); + self.current_step = MenuStep::Content; + self.state.select(Some(0)); + None + } + MenuStep::Content => { + self.selected_content = Some(match selected_idx { + 0 => ExportContent::AnnotationsOnly, + 1 => ExportContent::AnnotationsWithContext, + _ => return None, + }); + self.current_step = MenuStep::Organization; + self.state.select(Some(0)); + None + } + MenuStep::Organization => { + self.selected_organization = Some(match selected_idx { + 0 => ExportOrganization::SingleFile, + 1 => ExportOrganization::ChapterBased, + _ => return None, + }); + + // All selections complete - trigger export + Some(ExportMenuAction::Export { + format: self.selected_format?, + content: self.selected_content?, + organization: self.selected_organization?, + }) + } + } + } + + fn go_back(&mut self) -> bool { + match self.current_step { + MenuStep::Format => false, // Can't go back from first step + MenuStep::Content => { + self.current_step = MenuStep::Format; + self.state.select(Some(0)); + true + } + MenuStep::Organization => { + self.current_step = MenuStep::Content; + self.state.select(Some(0)); + true + } + } + } + + pub fn handle_key( + &mut self, + key: crossterm::event::KeyEvent, + key_seq: &mut KeySeq, + ) -> Option { + use crossterm::event::{KeyCode, KeyModifiers}; + + match key.code { + KeyCode::Char('j') => { + self.handle_j(); + None + } + KeyCode::Char('k') => { + self.handle_k(); + None + } + KeyCode::Char('g') if key_seq.handle_key('g') == "gg" => { + self.handle_gg(); + None + } + KeyCode::Char('G') => { + self.handle_upper_g(); + None + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.handle_ctrl_d(); + None + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.handle_ctrl_u(); + None + } + KeyCode::Esc => { + // Try to go back first, close if at first step + if !self.go_back() { + Some(ExportMenuAction::Close) + } else { + None + } + } + KeyCode::Char('h') => { + // Vim-style 'h' to go back + if !self.go_back() { + Some(ExportMenuAction::Close) + } else { + None + } + } + KeyCode::Enter | KeyCode::Char('l') => { + // Enter or vim 'l' to select/proceed + self.select_current_item() + } + _ => None, + } + } + + pub fn handle_mouse_click(&mut self, x: u16, y: u16) -> bool { + if let Some(popup_area) = self.last_popup_area { + if x >= popup_area.x + && x < popup_area.x + popup_area.width + && y > popup_area.y + && y < popup_area.y + popup_area.height - 1 + { + let relative_y = y.saturating_sub(popup_area.y).saturating_sub(1); + let offset = self.state.offset(); + let new_index = offset + relative_y as usize; + + let item_count = match self.current_step { + MenuStep::Format => 3, + MenuStep::Content => 2, + MenuStep::Organization => 2, + }; + + if new_index < item_count { + self.state.select(Some(new_index)); + return true; + } + } + } + false + } + + pub fn is_outside_popup_area(&self, x: u16, y: u16) -> bool { + if let Some(popup_area) = self.last_popup_area { + x < popup_area.x + || x >= popup_area.x + popup_area.width + || y < popup_area.y + || y >= popup_area.y + popup_area.height + } else { + true + } + } +} + +impl VimNavMotions for ExportMenu { + fn handle_h(&mut self) { + // Already handled in handle_key as go_back + } + + fn handle_j(&mut self) { + let item_count = match self.current_step { + MenuStep::Format => 3, + MenuStep::Content => 2, + MenuStep::Organization => 2, + }; + + let i = match self.state.selected() { + Some(i) => { + if i >= item_count - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + + fn handle_k(&mut self) { + let item_count: usize = match self.current_step { + MenuStep::Format => 3, + MenuStep::Content => 2, + MenuStep::Organization => 2, + }; + + let i = match self.state.selected() { + Some(i) => { + if i == 0 { + item_count.saturating_sub(1) + } else { + i - 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + + fn handle_l(&mut self) { + // Select current (handled in handle_key) + } + + fn handle_ctrl_d(&mut self) { + // Half page down + self.handle_j(); + } + + fn handle_ctrl_u(&mut self) { + // Half page up + self.handle_k(); + } + + fn handle_gg(&mut self) { + self.state.select(Some(0)); + } + + fn handle_upper_g(&mut self) { + let item_count: usize = match self.current_step { + MenuStep::Format => 3, + MenuStep::Content => 2, + MenuStep::Organization => 2, + }; + self.state.select(Some(item_count.saturating_sub(1))); + } +} + +// Helper function for centered rect +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs index e8923b1..1a701f6 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -1,6 +1,7 @@ pub mod book_search; pub mod book_stat; pub mod comments_viewer; +pub mod export_menu; pub mod help_popup; pub mod navigation_panel; pub mod reading_history; diff --git a/src/widget/navigation_panel/table_of_contents.rs b/src/widget/navigation_panel/table_of_contents.rs index f6d00c3..5ecfc83 100644 --- a/src/widget/navigation_panel/table_of_contents.rs +++ b/src/widget/navigation_panel/table_of_contents.rs @@ -424,7 +424,7 @@ impl TableOfContents { } /// Get the selected item (either back button or TOC item) - pub fn get_selected_item(&self) -> Option { + pub fn get_selected_item(&self) -> Option> { if let Some(ref current_book_info) = self.current_book_info { if self.selected_index == 0 { Some(SelectedTocItem::BackToBooks) diff --git a/src/widget/text_reader/comments.rs b/src/widget/text_reader/comments.rs index 47592f6..aca5a7c 100644 --- a/src/widget/text_reader/comments.rs +++ b/src/widget/text_reader/comments.rs @@ -18,18 +18,280 @@ impl crate::markdown_text_reader::MarkdownTextReader { /// Rebuild the comment lookup for the current chapter pub fn rebuild_chapter_comments(&mut self) { + use log::debug; + self.current_chapter_comments.clear(); if let Some(chapter_file) = &self.current_chapter_file { if let Some(comments_arc) = &self.book_comments { if let Ok(comments) = comments_arc.lock() { - for comment in comments.get_chapter_comments(chapter_file) { - self.current_chapter_comments - .entry(comment.node_index()) - .or_default() - .push(comment.clone()); + let chapter_comments = comments.get_chapter_comments(chapter_file); + debug!( + "Rebuilding comments for chapter {}: found {} comments", + chapter_file, + chapter_comments.len() + ); + for comment in chapter_comments { + debug!( + " Comment at node {}: highlight_only={}, has_word_range={}", + comment.node_index(), + comment.highlight_only, + comment.target.word_range().is_some() + ); + match &comment.target { + CommentTarget::ParagraphRange { + start_paragraph_index, + end_paragraph_index, + .. + } => { + for node_idx in *start_paragraph_index..=*end_paragraph_index { + let list = + self.current_chapter_comments.entry(node_idx).or_default(); + + let already_exists = list.iter().any(|c| { + c.chapter_href == comment.chapter_href + && c.target == comment.target + && c.content == comment.content + }); + + if !already_exists { + list.push(comment.clone()); + } + } + } + _ => { + let list = self + .current_chapter_comments + .entry(comment.node_index()) + .or_default(); + + let already_exists = list.iter().any(|c| { + c.chapter_href == comment.chapter_href + && c.target == comment.target + && c.content == comment.content + }); + + if !already_exists { + list.push(comment.clone()); + } + } + } + } + } + } + } + + // After rebuilding, extract missing context for old comments + self.backfill_missing_context(); + } + + /// Extract context for comments that don't have it + /// This provides backward compatibility for old comments + fn backfill_missing_context(&mut self) { + use log::{debug, info, warn}; + + // Check if we have rendered content to extract from + if self.rendered_content.lines.is_empty() { + return; + } + + let chapter_file = match &self.current_chapter_file { + Some(file) => file.clone(), + None => return, + }; + + let comments_arc = match &self.book_comments { + Some(arc) => arc.clone(), + None => return, + }; + + let mut comments_to_update = Vec::new(); + + // Find comments with missing context + if let Ok(comments) = comments_arc.lock() { + let chapter_comments = comments.get_chapter_comments(&chapter_file); + for comment in chapter_comments { + if comment.context.is_none() { + debug!( + "Found comment without context at node {}: extracting...", + comment.node_index() + ); + if let Some(extracted) = self.extract_context_for_comment(&comment.target) { + comments_to_update.push((comment.clone(), extracted)); + } + } + } + } + + // Update comments with extracted context + if !comments_to_update.is_empty() { + info!( + "Backfilling context for {} comments in chapter {}", + comments_to_update.len(), + chapter_file + ); + + if let Ok(mut comments) = comments_arc.lock() { + for (comment, extracted_context) in comments_to_update { + if let Err(e) = comments.update_comment_context( + &chapter_file, + &comment.target, + extracted_context, + ) { + warn!("Failed to update comment with context: {}", e); + } + } + } + } + } + + /// Extract the highlighted text for a given comment target + fn extract_context_for_comment(&self, target: &CommentTarget) -> Option { + use log::debug; + + match target { + CommentTarget::ParagraphRange { + start_paragraph_index, + end_paragraph_index, + start_word_offset, + end_word_offset, + list_item_index, + } => { + let start_node = *start_paragraph_index; + let end_node = *end_paragraph_index; + + // Collect all lines for the node range + let mut text_parts = Vec::new(); + + for node_idx in start_node..=end_node { + // If list_item_index is set AND this is the start node, + // filter lines to only that specific bullet + let node_lines: Vec<&str> = if let Some(target_bullet_idx) = list_item_index { + if node_idx == start_node { + // For the start node (list), track which bullet each line belongs to + let mut current_bullet_idx = None; + let mut bullet_counter = 0; + + self.rendered_content + .lines + .iter() + .filter_map(|line| { + if line.node_index != Some(node_idx) { + return None; + } + + // Check if this line starts a new bullet + if matches!(line.line_type, LineType::ListItem { .. }) { + let text = line.raw_text.trim_start(); + if text.starts_with('•') + || text.starts_with('-') + || text.chars().next().map_or(false, |c| c.is_numeric()) + { + current_bullet_idx = Some(bullet_counter); + bullet_counter += 1; + } + } + + // Only include lines that belong to the target bullet + if current_bullet_idx == Some(*target_bullet_idx) { + Some(line.raw_text.as_str()) + } else { + None + } + }) + .collect() + } else { + // For subsequent nodes in a multi-paragraph range, get all lines + self.rendered_content + .lines + .iter() + .filter(|line| line.node_index == Some(node_idx)) + .map(|line| line.raw_text.as_str()) + .collect() + } + } else { + // No list_item_index - get all lines for this node + self.rendered_content + .lines + .iter() + .filter(|line| line.node_index == Some(node_idx)) + .map(|line| line.raw_text.as_str()) + .collect() + }; + + if node_lines.is_empty() { + continue; + } + + // Join lines for this node + let node_text = node_lines.join(" "); + + // For SINGLE-node selections with list_item_index, return just that bullet + if start_node == end_node && list_item_index.is_some() { + return Some(node_text); + } + + // If this is a single node with word offsets, extract just that range + if start_node == end_node + && start_word_offset.is_some() + && end_word_offset.is_some() + { + let words: Vec<&str> = node_text.split_whitespace().collect(); + let start = start_word_offset.unwrap(); + let end = end_word_offset.unwrap().min(words.len()); + + if start < words.len() { + let selected_words = &words[start..end]; + return Some(selected_words.join(" ")); + } + } else { + // Multi-node or full node - add all text + text_parts.push(node_text); } } + + if text_parts.is_empty() { + debug!( + "Could not extract context for nodes {}-{}", + start_node, end_node + ); + None + } else { + Some(text_parts.join(" ")) + } + } + CommentTarget::Paragraph { + paragraph_index, + word_range, + } => { + // Legacy format - extract using paragraph index and word range + let node_lines: Vec<&str> = self + .rendered_content + .lines + .iter() + .filter(|line| line.node_index == Some(*paragraph_index)) + .map(|line| line.raw_text.as_str()) + .collect(); + + if node_lines.is_empty() { + return None; + } + + let node_text = node_lines.join(" "); + + if let Some((start, end)) = word_range { + let words: Vec<&str> = node_text.split_whitespace().collect(); + if *start < words.len() { + let end_idx = (*end).min(words.len()); + return Some(words[*start..end_idx].join(" ")); + } + } + + Some(node_text) + } + CommentTarget::CodeBlock { .. } => { + // For code blocks, we don't extract context since it's code + None } } } @@ -123,52 +385,140 @@ impl crate::markdown_text_reader::MarkdownTextReader { return None; } - let node_idx = (start.line..=end.line).find_map(|idx| { + let start_node_idx = (start.line..=end.line).find_map(|idx| { self.rendered_content .lines .get(idx) .and_then(|line| line.node_index) })?; + let mut end_node_idx = start_node_idx; + let mut is_single_node = true; + for idx in start.line..=end.line { if let Some(line) = self.rendered_content.lines.get(idx) { if let Some(line_node_idx) = line.node_index { - if line_node_idx != node_idx { - return None; + if line_node_idx != start_node_idx { + is_single_node = false; + end_node_idx = end_node_idx.max(line_node_idx); } } } } - let mut has_code = false; - let mut min_code = usize::MAX; - let mut max_code = 0; - - for idx in start.line..=end.line { - if let Some(line) = self.rendered_content.lines.get(idx) { - if let Some(meta) = &line.code_line { - if meta.node_index == node_idx { - has_code = true; - min_code = min_code.min(meta.line_index); - max_code = max_code.max(meta.line_index); + if is_single_node { + let mut has_code = false; + let mut min_code = usize::MAX; + let mut max_code = 0; + let mut is_list_item = false; + + for idx in start.line..=end.line { + if let Some(line) = self.rendered_content.lines.get(idx) { + if let Some(meta) = &line.code_line { + if meta.node_index == start_node_idx { + has_code = true; + min_code = min_code.min(meta.line_index); + max_code = max_code.max(meta.line_index); + } + } + // Check if any line in the selection is a list item + if matches!(line.line_type, LineType::ListItem { .. }) { + is_list_item = true; } } } - } - if has_code { - return Some(CommentTarget::CodeBlock { - paragraph_index: node_idx, - line_range: (min_code, max_code), - }); + if has_code { + return Some(CommentTarget::CodeBlock { + paragraph_index: start_node_idx, + line_range: (min_code, max_code), + }); + } + + // For list items, determine which bullet is selected + let list_item_index = if is_list_item { + self.determine_list_item_index(start_node_idx, start.line) + } else { + None + }; + + // For list items, don't use word ranges - each bullet is atomic + let word_range = if is_list_item { + None + } else { + self.compute_paragraph_word_range(start_node_idx, start, end) + }; + + Some(CommentTarget::ParagraphRange { + start_paragraph_index: start_node_idx, + end_paragraph_index: start_node_idx, + start_word_offset: word_range.map(|(start, _)| start), + end_word_offset: word_range.map(|(_, end)| end), + list_item_index, + }) + } else { + // Multi-paragraph range + // Check if the start node is a list item + let start_is_list_item = self + .rendered_content + .lines + .get(start.line) + .map(|line| matches!(line.line_type, LineType::ListItem { .. })) + .unwrap_or(false); + + // For multi-paragraph selections starting from a list item, + // determine which bullet to indicate where the selection starts + let list_item_index = if start_is_list_item { + self.determine_list_item_index(start_node_idx, start.line) + } else { + None + }; + + // For multi-paragraph ranges, don't use word offsets - they're too complex + // Just indicate the full range of nodes involved + Some(CommentTarget::ParagraphRange { + start_paragraph_index: start_node_idx, + end_paragraph_index: end_node_idx, + start_word_offset: None, + end_word_offset: None, + list_item_index, + }) } + } + + /// Determine which bullet item (0-indexed) contains the given line + /// Returns None if not in a list or cannot determine + fn determine_list_item_index(&self, node_idx: usize, line_num: usize) -> Option { + // Count how many bullet "starts" we've seen before this line + // A bullet start is indicated by a line starting with "• " or "1. " etc. + let mut bullet_count = 0; + let mut current_bullet = None; - let word_range = self.compute_paragraph_word_range(node_idx, start, end); + for (idx, line) in self.rendered_content.lines.iter().enumerate() { + if line.node_index != Some(node_idx) { + continue; + } + + // Check if this line starts a new bullet (has the bullet prefix) + // List items have their text with the bullet prefix in raw_text + if matches!(line.line_type, LineType::ListItem { .. }) { + let text = line.raw_text.trim_start(); + if text.starts_with('•') + || text.starts_with('-') + || text.chars().next().map_or(false, |c| c.is_numeric()) + { + // This is a bullet start line + if idx <= line_num { + current_bullet = Some(bullet_count); + bullet_count += 1; + } else { + break; + } + } + } + } - Some(CommentTarget::Paragraph { - paragraph_index: node_idx, - word_range, - }) + current_bullet } /// Handle input events when in comment mode @@ -198,6 +548,11 @@ impl crate::markdown_text_reader::MarkdownTextReader { if !comment_text.trim().is_empty() { if let Some(target) = self.comment_input.target.clone() { + // Extract the selected text before clearing the selection + let selected_text = self + .text_selection + .extract_selected_text(&self.raw_text_lines); + if let Some(comments_arc) = &self.book_comments { if let Ok(mut comments) = comments_arc.lock() { use chrono::Utc; @@ -219,6 +574,8 @@ impl crate::markdown_text_reader::MarkdownTextReader { chapter_href: chapter_file.clone(), target, content: comment_text.clone(), + context: selected_text, + highlight_only: false, updated_at: Utc::now(), }; @@ -242,6 +599,53 @@ impl crate::markdown_text_reader::MarkdownTextReader { self.cache_generation += 1; } + /// Create a highlight-only comment (no note text) + pub fn create_highlight_only(&mut self) -> bool { + if !self.has_text_selection() { + return false; + } + + if let Some((start, end)) = self.text_selection.get_selection_range() { + let (norm_start, norm_end) = self.normalize_selection_points(&start, &end); + if let Some(target) = self.compute_selection_target(&norm_start, &norm_end) { + let selected_text = self + .text_selection + .extract_selected_text(&self.raw_text_lines); + + if let Some(chapter_file) = &self.current_chapter_file { + if let Some(comments_arc) = &self.book_comments { + if let Ok(mut comments) = comments_arc.lock() { + use chrono::Utc; + + let comment = Comment { + chapter_href: chapter_file.clone(), + target, + content: String::new(), // No text content for highlight-only + context: selected_text, + highlight_only: true, + updated_at: Utc::now(), + }; + + if let Err(e) = comments.add_comment(comment) { + warn!("Failed to add highlight: {e}"); + return false; + } else { + debug!("Saved highlight-only comment"); + } + } + } + } + + self.rebuild_chapter_comments(); + self.text_selection.clear_selection(); + self.cache_generation += 1; + return true; + } + } + + false + } + /// Check if we're currently in comment input mode pub fn is_comment_input_active(&self) -> bool { self.comment_input.is_active() @@ -365,6 +769,24 @@ impl crate::markdown_text_reader::MarkdownTextReader { } } + /// Determines if a comment should be rendered at the given node_idx + /// For single-paragraph comments, always render + /// For multi-paragraph comments, only render at the last paragraph in the range + pub fn should_render_comment_at_node(&self, comment: &Comment, node_idx: usize) -> bool { + match &comment.target { + CommentTarget::Paragraph { + paragraph_index, .. + } => *paragraph_index == node_idx, + CommentTarget::CodeBlock { + paragraph_index, .. + } => *paragraph_index == node_idx, + CommentTarget::ParagraphRange { + end_paragraph_index, + .. + } => *end_paragraph_index == node_idx, + } + } + #[allow(clippy::too_many_arguments)] pub fn render_comment_as_quote( &mut self, @@ -376,12 +798,17 @@ impl crate::markdown_text_reader::MarkdownTextReader { _is_focused: bool, indent: usize, ) { + // Skip rendering if this is a highlight-only comment (no text) + if comment.highlight_only { + return; + } + // Skip rendering if we're currently editing this comment if self.is_editing_this_comment(comment) { return; } - if !comment.is_paragraph_comment() { + if !comment.is_paragraph_comment() && !comment.is_paragraph_range_comment() { return; } @@ -500,42 +927,134 @@ impl crate::markdown_text_reader::MarkdownTextReader { start: &SelectionPoint, end: &SelectionPoint, ) -> Option<(usize, usize)> { - let mut offsets = Vec::new(); - let mut cumulative = 0; + let mut line_data = Vec::new(); + // Collect all lines for this node with their word and character positions for (idx, line) in self.rendered_content.lines.iter().enumerate() { if line.node_index == Some(node_idx) { - let len = line.raw_text.chars().count(); - offsets.push((idx, cumulative, len)); - cumulative += len; + line_data.push((idx, line.raw_text.clone())); } } - if offsets.is_empty() { + if line_data.is_empty() { return None; } - let total_len = cumulative; + // Build cumulative word and character positions for each line + let mut cumulative_words = 0; + let mut cumulative_chars = 0; + let mut line_info = Vec::new(); + + for (line_idx, text) in &line_data { + let char_count = text.chars().count(); + let word_count = Self::count_words_in_text(text); + + line_info.push(( + *line_idx, + cumulative_words, + word_count, + cumulative_chars, + char_count, + )); + + cumulative_words += word_count; + cumulative_chars += char_count; + } + + let total_words = cumulative_words; - let start_offset = offsets + // Find start word index + let start_word = line_info .iter() - .find(|(line_idx, _, _)| *line_idx == start.line) - .map(|(_, base, len)| base + start.column.min(*len))?; - - let end_offset = offsets + .find(|(line_idx, _, _, _, _)| *line_idx == start.line) + .and_then(|(_, base_words, _, _base_chars, char_count)| { + let line_text = line_data + .iter() + .find(|(idx, _)| *idx == start.line) + .map(|(_, text)| text)?; + let char_offset = start.column.min(*char_count); + let word_offset = Self::char_offset_to_word_index(line_text, char_offset); + Some(base_words + word_offset) + })?; + + // Find end word index + let end_word = line_info .iter() - .find(|(line_idx, _, _)| *line_idx == end.line) - .map(|(_, base, len)| base + end.column.min(*len)) - .unwrap_or(total_len); + .find(|(line_idx, _, _, _, _)| *line_idx == end.line) + .and_then(|(_, base_words, _, _, char_count)| { + let line_text = line_data + .iter() + .find(|(idx, _)| *idx == end.line) + .map(|(_, text)| text)?; + let char_offset = end.column.min(*char_count); + let word_offset = Self::char_offset_to_word_index(line_text, char_offset); + Some(base_words + word_offset) + }) + .unwrap_or(total_words); + + if start_word >= end_word { + return None; + } - if start_offset >= end_offset { + // If we're selecting (nearly) the entire paragraph, don't use word offsets + // Check if we're starting at or near the beginning (0 or 1) and ending at or near the end + if start_word <= 1 && end_word >= total_words.saturating_sub(1) { return None; } - if start_offset == 0 && end_offset >= total_len { + Some((start_word, end_word)) + } + + /// Count the number of words in a text string + fn count_words_in_text(text: &str) -> usize { + let mut word_count = 0; + let mut in_word = false; + + for ch in text.chars() { + if ch.is_whitespace() { + in_word = false; + } else if !in_word { + word_count += 1; + in_word = true; + } + } + + word_count + } + + /// Convert character offset to word index within a line + fn char_offset_to_word_index(text: &str, char_offset: usize) -> usize { + let chars: Vec = text.chars().collect(); + let safe_offset = char_offset.min(chars.len()); + + // Count completed words before the offset + let text_before: String = chars.iter().take(safe_offset).collect(); + Self::count_words_in_text(&text_before) + } + + fn compute_word_offset_in_node( + &self, + node_idx: usize, + point: &SelectionPoint, + ) -> Option { + let mut offsets = Vec::new(); + let mut cumulative = 0; + + for (idx, line) in self.rendered_content.lines.iter().enumerate() { + if line.node_index == Some(node_idx) { + let len = line.raw_text.chars().count(); + offsets.push((idx, cumulative, len)); + cumulative += len; + } + } + + if offsets.is_empty() { return None; } - Some((start_offset, end_offset.min(total_len))) + offsets + .iter() + .find(|(line_idx, _, _)| *line_idx == point.line) + .map(|(_, base, len)| base + point.column.min(*len)) } } diff --git a/src/widget/text_reader/navigation.rs b/src/widget/text_reader/navigation.rs index 48fd7ba..18f40a5 100644 --- a/src/widget/text_reader/navigation.rs +++ b/src/widget/text_reader/navigation.rs @@ -264,6 +264,8 @@ impl crate::markdown_text_reader::MarkdownTextReader { } pub fn set_current_chapter_file(&mut self, chapter_file: Option) { + use log::debug; + debug!("Setting current_chapter_file to: {:?}", chapter_file); self.current_chapter_file = chapter_file; self.rebuild_chapter_comments(); } diff --git a/src/widget/text_reader/rendering.rs b/src/widget/text_reader/rendering.rs index 90b5a7b..6ac52e8 100644 --- a/src/widget/text_reader/rendering.rs +++ b/src/widget/text_reader/rendering.rs @@ -200,6 +200,10 @@ impl crate::markdown_text_reader::MarkdownTextReader { } List { kind, items } => { + use log::debug; + if let Some(idx) = node_index { + debug!("Rendering LIST at node {}, {} items", idx, items.len()); + } self.render_list( kind, items, @@ -923,6 +927,12 @@ impl crate::markdown_text_reader::MarkdownTextReader { TextOrInline::Inline(Inline::Image { url, .. }) => { // If we have accumulated text before the image, render it first if !current_rich_spans.is_empty() { + // Apply word-range highlighting for any annotations on this paragraph + if let Some(node_idx) = node_index { + current_rich_spans = + self.apply_comment_highlighting(current_rich_spans, node_idx); + } + self.render_text_spans( ¤t_rich_spans, None, // no prefix @@ -950,6 +960,11 @@ impl crate::markdown_text_reader::MarkdownTextReader { // Render any remaining text spans if !current_rich_spans.is_empty() { + // Apply word-range highlighting for any annotations on this paragraph + if let Some(node_idx) = node_index { + current_rich_spans = self.apply_comment_highlighting(current_rich_spans, node_idx); + } + let add_empty_line = context == RenderContext::TopLevel; self.render_text_spans( ¤t_rich_spans, @@ -983,15 +998,17 @@ impl crate::markdown_text_reader::MarkdownTextReader { let comments_to_render = self.current_chapter_comments.get(&node_idx).cloned(); if let Some(paragraph_comments) = comments_to_render { for comment in paragraph_comments { - self.render_comment_as_quote( - &comment, - lines, - total_height, - width, - palette, - is_focused, - indent, - ); + if self.should_render_comment_at_node(&comment, node_idx) { + self.render_comment_as_quote( + &comment, + lines, + total_height, + width, + palette, + is_focused, + indent, + ); + } } } } @@ -1300,7 +1317,8 @@ impl crate::markdown_text_reader::MarkdownTextReader { if allowed == 0 { comment_line = "…".to_string(); } else { - let truncated: String = comment_line.chars().take(allowed).collect(); + let truncated: String = + comment_line.chars().take(allowed).collect(); comment_line = format!("{truncated}…"); } } @@ -1469,6 +1487,58 @@ impl crate::markdown_text_reader::MarkdownTextReader { .extend(self.render_text_or_inline(item, palette, is_focused)); } + // Apply comment highlighting for this list item + // All items in a list share the same node_index (the List's node) + // Check if this specific bullet should be highlighted + if let Some(list_node_idx) = node_index { + if let Some(comments) = + self.current_chapter_comments.get(&list_node_idx) + { + let should_highlight = comments.iter().any(|c| { + use crate::comments::CommentTarget; + match &c.target { + CommentTarget::ParagraphRange { + start_paragraph_index, + end_paragraph_index, + list_item_index, + .. + } => { + // Check if this comment targets this list + if *start_paragraph_index == list_node_idx { + // This list is the start node + if *end_paragraph_index == list_node_idx { + // Single-node range: check list_item_index + list_item_index.map_or(true, |target_idx| { + target_idx == idx + }) + } else if let Some(target_idx) = list_item_index + { + // Multi-paragraph range starting from this list: + // only highlight the specific bullet it starts from + *target_idx == idx + } else { + // Multi-paragraph range with no list_item_index: + // highlight all bullets + true + } + } else { + // This list is not the start node, check if covered + c.covers_node(list_node_idx) + } + } + _ => { + // Other comment types - use default logic + c.node_index() == list_node_idx + } + } + }); + if should_highlight { + content_rich_spans = self + .apply_full_paragraph_highlighting(content_rich_spans); + } + } + } + let lines_before = lines.len(); self.render_text_spans( @@ -1484,14 +1554,13 @@ impl crate::markdown_text_reader::MarkdownTextReader { first_block_line_count = lines.len() - lines_before; - for (i, line) in lines[lines_before..].iter_mut().enumerate() { + for line in lines[lines_before..].iter_mut() { line.line_type = LineType::ListItem { kind: kind.clone(), indent, }; - if i == 0 && idx == 0 { - line.node_index = node_index; - } + // All list items (and all their wrapped lines) belong to the same List node + line.node_index = node_index; } } _ => { @@ -1537,21 +1606,31 @@ impl crate::markdown_text_reader::MarkdownTextReader { if let Some(node_idx) = node_index { let comments_to_render = self.current_chapter_comments.get(&node_idx).cloned(); if let Some(paragraph_comments) = comments_to_render { - if !paragraph_comments.is_empty() { + let mut has_comments_to_render = false; + for comment in ¶graph_comments { + if self.should_render_comment_at_node(comment, node_idx) { + has_comments_to_render = true; + break; + } + } + + if has_comments_to_render { lines.push(RenderedLine::empty()); self.raw_text_lines.push(String::new()); *total_height += 1; for comment in paragraph_comments { - self.render_comment_as_quote( - &comment, - lines, - total_height, - width, - palette, - is_focused, - indent, - ); + if self.should_render_comment_at_node(&comment, node_idx) { + self.render_comment_as_quote( + &comment, + lines, + total_height, + width, + palette, + is_focused, + indent, + ); + } } return; // render_comment_as_quote already adds empty line after } @@ -2529,6 +2608,304 @@ impl crate::markdown_text_reader::MarkdownTextReader { self.raw_text_lines.push(String::new()); *total_height += 1; } + + /// Apply comment highlighting to text spans for a given node + /// Handles both word-range highlighting and full-paragraph highlighting for multi-paragraph comments + fn apply_comment_highlighting(&self, spans: Vec, node_idx: usize) -> Vec { + use log::debug; + + if let Some(comments) = self.current_chapter_comments.get(&node_idx) { + debug!( + "=== HIGHLIGHT CHECK Node {}: Found {} comment(s) ===", + node_idx, + comments.len() + ); + + for (i, c) in comments.iter().enumerate() { + debug!( + " Comment {}: is_paragraph={}, is_range={}, word_range={:?}", + i, + c.is_paragraph_comment(), + c.is_paragraph_range_comment(), + c.target.word_range() + ); + } + + // Check if any comment requires full paragraph highlighting: + // 1. Multi-paragraph range comments covering this node + // 2. Single paragraph comments/ranges without specific word offsets + let has_full_highlight = comments.iter().any(|c| { + // Multi-paragraph range comments covering this node + if c.is_paragraph_range_comment() && c.covers_node(node_idx) { + // Check if this is a multi-paragraph range + if let Some((start, end)) = c.target.paragraph_range() { + if start != end { + debug!( + "Node {}: Applying full highlight (multi-paragraph range {}-{})", + node_idx, start, end + ); + return true; + } + } + } + // Single paragraph comments/ranges without word_range get full highlighting + // This handles both legacy Paragraph format and normalized ParagraphRange format + if c.target.word_range().is_none() && c.node_index() == node_idx { + debug!("Node {}: Applying full highlight (no word range)", node_idx); + return true; + } + false + }); + + if has_full_highlight { + debug!("Node {}: APPLYING FULL PARAGRAPH HIGHLIGHT", node_idx); + return self.apply_full_paragraph_highlighting(spans); + } + + // Otherwise, apply specific word-range highlighting + let word_ranges: Vec<(usize, usize)> = comments + .iter() + .filter_map(|c| c.target.word_range()) + .collect(); + + if !word_ranges.is_empty() { + debug!( + "Node {}: APPLYING WORD-RANGE HIGHLIGHT: {:?}", + node_idx, word_ranges + ); + return self.apply_word_ranges_highlighting(spans, &word_ranges); + } + + debug!("Node {}: NO HIGHLIGHTING APPLIED (fell through)", node_idx); + } + spans + } + + /// Apply full paragraph highlighting (for multi-paragraph comments) + fn apply_full_paragraph_highlighting(&self, spans: Vec) -> Vec { + use crate::color_mode::smart_color; + use crate::settings; + use crate::widget::text_reader::types::RichSpan; + + let highlight_color_hex = settings::get_annotation_highlight_color(); + + if highlight_color_hex.is_empty() + || highlight_color_hex.eq_ignore_ascii_case("none") + || highlight_color_hex.eq_ignore_ascii_case("disabled") + { + return spans; + } + + let highlight_color = match u32::from_str_radix(&highlight_color_hex, 16) { + Ok(value) => smart_color(value), + Err(_) => smart_color(0x7FB4CA), + }; + + spans + .into_iter() + .map(|rich_span| match rich_span { + RichSpan::Text(span) => { + let new_style = span.style.bg(highlight_color); + RichSpan::Text(span.style(new_style)) + } + RichSpan::Link { span, info } => { + let new_style = span.style.bg(highlight_color); + RichSpan::Link { + span: span.style(new_style), + info, + } + } + }) + .collect() + } + + /// Apply multiple word-range highlights to spans for annotated text + /// This highlights the text that has been annotated with a background color + fn apply_word_ranges_highlighting( + &self, + spans: Vec, + word_ranges: &[(usize, usize)], + ) -> Vec { + use crate::color_mode::smart_color; + use crate::settings; + use crate::widget::text_reader::types::RichSpan; + + if word_ranges.is_empty() { + return spans; + } + + // Get the configurable highlight color from settings + let highlight_color_hex = settings::get_annotation_highlight_color(); + + // Check if highlighting is disabled + if highlight_color_hex.is_empty() + || highlight_color_hex.eq_ignore_ascii_case("none") + || highlight_color_hex.eq_ignore_ascii_case("disabled") + { + return spans; // Skip highlighting entirely + } + + let highlight_color = match u32::from_str_radix(&highlight_color_hex, 16) { + Ok(value) => smart_color(value), + Err(_) => smart_color(0x7FB4CA), // Fallback to default cyan + }; + + // Convert all word ranges to character ranges + let mut char_ranges: Vec<(usize, usize)> = Vec::new(); + + for &(start_word, end_word) in word_ranges { + let mut current_char = 0; + let mut word_count = 0; + let mut start_char = None; + let mut end_char = None; + + for rich_span in &spans { + let text = match rich_span { + RichSpan::Text(span) => span.content.to_string(), + RichSpan::Link { span, .. } => span.content.to_string(), + }; + let chars: Vec = text.chars().collect(); + + let mut i = 0; + while i < chars.len() { + if chars[i].is_whitespace() { + current_char += 1; + i += 1; + continue; + } + + // Start of a word + let word_start = current_char; + while i < chars.len() && !chars[i].is_whitespace() { + current_char += 1; + i += 1; + } + + // We've completed a word + if word_count == start_word && start_char.is_none() { + start_char = Some(word_start); + } + word_count += 1; + if word_count == end_word { + end_char = Some(current_char); + break; + } + } + + if end_char.is_some() { + break; + } + } + + if let Some(start) = start_char { + let end = end_char.unwrap_or(current_char); + char_ranges.push((start, end)); + } + } + + if char_ranges.is_empty() { + return spans; + } + + // Sort and merge overlapping ranges + char_ranges.sort_by_key(|r| r.0); + let mut merged_ranges: Vec<(usize, usize)> = Vec::new(); + for range in char_ranges { + if let Some(last) = merged_ranges.last_mut() { + if range.0 <= last.1 { + // Overlapping or adjacent, merge + last.1 = last.1.max(range.1); + } else { + merged_ranges.push(range); + } + } else { + merged_ranges.push(range); + } + } + + // Helper to check if a character position is in any highlight range + let is_highlighted = |pos: usize| -> bool { + merged_ranges + .iter() + .any(|(start, end)| pos >= *start && pos < *end) + }; + + // Apply highlighting to all ranges in one pass + let mut result_spans = Vec::new(); + let mut current_pos = 0; + + for rich_span in spans { + let (span, maybe_link) = match rich_span { + RichSpan::Text(span) => (span, None), + RichSpan::Link { span, info } => (span, Some(info)), + }; + + let text = span.content.to_string(); + let chars: Vec = text.chars().collect(); + let char_count = chars.len(); + let span_start = current_pos; + let span_end = current_pos + char_count; + + // Check if this span overlaps with any highlight range + let has_highlight = merged_ranges + .iter() + .any(|(start, end)| !(span_end <= *start || span_start >= *end)); + + if !has_highlight { + // No overlap, keep span as is + if let Some(link_info) = maybe_link { + result_spans.push(RichSpan::Link { + span, + info: link_info, + }); + } else { + result_spans.push(RichSpan::Text(span)); + } + } else { + // Span has highlighted portions - split it + let mut i = 0; + while i < char_count { + let char_pos = span_start + i; + let is_current_highlighted = is_highlighted(char_pos); + + // Find the end of this segment (highlighted or not) + let mut j = i + 1; + while j < char_count { + let next_pos = span_start + j; + if is_highlighted(next_pos) != is_current_highlighted { + break; + } + j += 1; + } + + let segment: String = chars[i..j].iter().collect(); + let segment_style = if is_current_highlighted { + span.style.bg(highlight_color) + } else { + span.style + }; + + if is_current_highlighted { + // Highlighted text loses link status + result_spans.push(RichSpan::Text(Span::styled(segment, segment_style))); + } else if let Some(ref link_info) = maybe_link { + result_spans.push(RichSpan::Link { + span: Span::styled(segment, segment_style), + info: link_info.clone(), + }); + } else { + result_spans.push(RichSpan::Text(Span::styled(segment, segment_style))); + } + + i = j; + } + } + + current_pos = span_end; + } + + result_spans + } } #[cfg(test)] diff --git a/src/widget/text_reader/selection.rs b/src/widget/text_reader/selection.rs index e068e9e..a812adb 100644 --- a/src/widget/text_reader/selection.rs +++ b/src/widget/text_reader/selection.rs @@ -96,6 +96,26 @@ impl crate::markdown_text_reader::MarkdownTextReader { self.text_selection.has_selection() } + pub fn convert_visual_to_text_selection(&mut self) -> bool { + if let Some((start_line, start_col, end_line, end_col)) = self.get_visual_selection_range() + { + use crate::widget::text_reader::text_selection::SelectionPoint; + + self.text_selection.start = Some(SelectionPoint { + line: start_line, + column: start_col, + }); + self.text_selection.end = Some(SelectionPoint { + line: end_line, + column: end_col.saturating_sub(1), + }); + self.text_selection.is_selecting = false; + true + } else { + false + } + } + pub fn copy_selection_to_clipboard(&mut self) -> Result<(), String> { if let Some(selected_text) = self .text_selection diff --git a/tests/svg_snapshots.rs b/tests/svg_snapshots.rs index 44cd942..cf1af4e 100644 --- a/tests/svg_snapshots.rs +++ b/tests/svg_snapshots.rs @@ -141,6 +141,7 @@ fn seed_sample_comments(app: &mut App) { word_range: None, }, content: "Launch plan looks solid.".to_string(), + selected_text: None, updated_at: base_time, }); @@ -151,6 +152,7 @@ fn seed_sample_comments(app: &mut App) { word_range: None, }, content: "Need to revisit risk section.".to_string(), + selected_text: None, updated_at: base_time + chrono::Duration::minutes(5), }); @@ -166,6 +168,7 @@ fn seed_sample_comments(app: &mut App) { word_range: None, }, content: "Great anecdote here.".to_string(), + selected_text: None, updated_at: base_time + chrono::Duration::minutes(10), }); }