diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 6585642..3bf05e3 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -60,6 +60,17 @@ pub enum Encoding { Utf16Be, } +impl From for i32 { + fn from(val: Encoding) -> Self { + match val { + Encoding::Utf8 => 0, + Encoding::Utf8WithBom => 1, + Encoding::Utf16Le => 2, + Encoding::Utf16Be => 3, + } + } +} + /// Line ending style preservation #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub enum LineEnding { @@ -69,6 +80,25 @@ pub enum LineEnding { Auto, } +impl LineEnding { + /// Detects line ending style from text content + pub fn detect(text: &str) -> LineEnding { + let crlf_count = text.matches("\r\n").count(); + let lf_count = text.matches('\n').count() - crlf_count; + if crlf_count > lf_count { LineEnding::CrLf } else { LineEnding::Lf } + } +} + +impl From for i32 { + fn from(val: LineEnding) -> Self { + match val { + LineEnding::Lf => 0, + LineEnding::CrLf => 1, + LineEnding::Auto => 2, + } + } +} + /// Document content with metadata for opening #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct DocContent { diff --git a/crates/store/src/file_utils.rs b/crates/store/src/file_utils.rs new file mode 100644 index 0000000..64d6e67 --- /dev/null +++ b/crates/store/src/file_utils.rs @@ -0,0 +1,43 @@ +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use super::text_utils; +use writer_core::AppError; + +const INDEXABLE_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "txt"]; + +pub fn is_indexable_text_path(path: &Path) -> bool { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_lowercase(); + INDEXABLE_EXTENSIONS.contains(&extension.as_str()) +} + +pub fn read_file_text_with_detection(path: &Path) -> Result { + let mut file = File::open(path).map_err(|e| AppError::io(format!("Failed to open file: {}", e)))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|e| AppError::io(format!("Failed to read file: {}", e)))?; + let (text, _encoding) = text_utils::detect_and_decode(&bytes)?; + Ok(text) +} + +pub fn collect_file_paths_recursive(dir: &Path, files: &mut Vec) -> Result<(), AppError> { + let entries = std::fs::read_dir(dir).map_err(|e| AppError::io(format!("Failed to read directory: {}", e)))?; + + for entry in entries { + let entry = entry.map_err(|e| AppError::io(format!("Failed to read entry: {}", e)))?; + let path = entry.path(); + + if path.is_file() { + files.push(path); + } else if path.is_dir() { + collect_file_paths_recursive(&path, files)?; + } + } + + Ok(()) +} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index d44a585..8c61db7 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -3,67 +3,37 @@ use rusqlite::{Connection, OptionalExtension, params, params_from_iter, types::V use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fs::File; -use std::hash::{Hash, Hasher}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use writer_core::{ AppError, DocContent, DocId, DocListOptions, DocMeta, DocSortField, Encoding, ErrorCode, LineEnding, - LocationDescriptor, LocationId, SavePolicy, SaveResult, SearchFilters, SearchHit, SearchMatch, SortOrder, + LocationDescriptor, LocationId, SavePolicy, SaveResult, SearchFilters, SearchHit, SortOrder, is_conflicted_filename, }; -/// Manages the SQLite database for the application -pub struct Store { - conn: Arc>, -} +mod file_utils; +mod settings; +mod text_utils; -fn default_true() -> bool { - true -} - -fn default_editor_font_size() -> u16 { - 16 -} +pub use settings::StyleCheckSettings; +pub use settings::UiLayoutSettings; -fn default_editor_font_family() -> String { - "IBM Plex Mono".to_string() -} +const UI_LAYOUT_SETTINGS_KEY: &str = "ui_layout"; +const STYLE_CHECK_SETTINGS_KEY: &str = "style_check"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct UiLayoutSettings { - pub sidebar_collapsed: bool, - pub top_bars_collapsed: bool, - pub status_bar_collapsed: bool, - #[serde(default = "default_true")] - pub line_numbers_visible: bool, - #[serde(default = "default_true")] - pub text_wrapping_enabled: bool, - #[serde(default = "default_true")] - pub syntax_highlighting_enabled: bool, - #[serde(default = "default_editor_font_size")] - pub editor_font_size: u16, - #[serde(default = "default_editor_font_family")] - pub editor_font_family: String, +pub struct StyleCheckPattern { + pub text: String, + pub category: String, + pub replacement: Option, } -impl Default for UiLayoutSettings { - fn default() -> Self { - Self { - sidebar_collapsed: false, - top_bars_collapsed: false, - status_bar_collapsed: false, - line_numbers_visible: true, - text_wrapping_enabled: true, - syntax_highlighting_enabled: true, - editor_font_size: default_editor_font_size(), - editor_font_family: default_editor_font_family(), - } - } +/// Manages the SQLite database for the application +pub struct Store { + conn: Arc>, } -const UI_LAYOUT_SETTINGS_KEY: &str = "ui_layout"; - impl Store { /// Opens or creates the store at the given path pub fn open(path: &PathBuf) -> Result { @@ -234,6 +204,59 @@ impl Store { Ok(()) } + pub fn style_check_get(&self) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + + let maybe_value = conn + .query_row( + "SELECT value FROM app_settings WHERE key = ?1", + params![STYLE_CHECK_SETTINGS_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| AppError::io(format!("Failed to query style check settings: {}", e)))?; + + match maybe_value { + Some(value) => serde_json::from_str::(&value).map_err(|e| { + AppError::new( + ErrorCode::Parse, + format!("Failed to parse persisted style check settings: {}", e), + ) + }), + None => Ok(StyleCheckSettings::default()), + } + } + + pub fn style_check_set(&self, settings: &StyleCheckSettings) -> Result<(), AppError> { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + + let settings_json = serde_json::to_string(settings).map_err(|e| { + AppError::new( + ErrorCode::Parse, + format!("Failed to serialize style check settings: {}", e), + ) + })?; + let updated_at = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO app_settings (key, value, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at", + params![STYLE_CHECK_SETTINGS_KEY, settings_json, updated_at], + ) + .map_err(|e| AppError::io(format!("Failed to persist style check settings: {}", e)))?; + + Ok(()) + } + /// Adds a new location pub fn location_add(&self, name: String, root_path: PathBuf) -> Result { let path_str = root_path.to_string_lossy().to_string(); @@ -528,14 +551,15 @@ impl Store { let created_at = metadata.created().ok().map(DateTime::::from); let is_conflict = is_conflicted_filename(filename); - let text_content = if is_indexable_text_path(path) { std::fs::read_to_string(path).ok() } else { None }; + let text_content = + if file_utils::is_indexable_text_path(path) { std::fs::read_to_string(path).ok() } else { None }; - let word_count = text_content.as_ref().map(|content| count_words(content)); + let word_count = text_content.as_ref().map(|content| text_utils::count_words(content)); let title = text_content .as_ref() - .and_then(|content| extract_title(content, &rel_path)) - .or_else(|| extract_title("", &rel_path)); - let content_hash = text_content.as_ref().map(|content| hash_text(content)); + .and_then(|content| text_utils::extract_title(content, &rel_path)) + .or_else(|| text_utils::extract_title("", &rel_path)); + let content_hash = text_content.as_ref().map(|content| text_utils::hash_text(content)); Ok(DocMeta { id: DocId { location_id, rel_path }, @@ -569,11 +593,11 @@ impl Store { file.read_to_end(&mut bytes) .map_err(|e| AppError::io(format!("Failed to read file: {}", e)))?; - let (text, encoding) = detect_and_decode(&bytes)?; + let (text, encoding) = text_utils::detect_and_decode(&bytes)?; - let line_ending = detect_line_ending(&text); - let word_count = count_words(&text); - let title = extract_title(&text, &doc_id.rel_path); + let line_ending = LineEnding::detect(&text); + let word_count = text_utils::count_words(&text); + let title = text_utils::extract_title(&text, &doc_id.rel_path); let metadata = std::fs::metadata(&full_path).map_err(|e| AppError::io(format!("Failed to read metadata: {}", e)))?; @@ -596,7 +620,7 @@ impl Store { size_bytes: metadata.len(), mtime, created_at, - content_hash: Some(hash_text(&text)), + content_hash: Some(text_utils::hash_text(&text)), encoding, line_ending, is_conflict, @@ -645,9 +669,9 @@ impl Store { let created_at = metadata.created().ok().map(DateTime::::from); - let line_ending = detect_line_ending(text); - let word_count = count_words(text); - let title = extract_title(text, &doc_id.rel_path); + let line_ending = LineEnding::detect(text); + let word_count = text_utils::count_words(text); + let title = text_utils::extract_title(text, &doc_id.rel_path); let new_meta = DocMeta { id: doc_id.clone(), @@ -660,7 +684,7 @@ impl Store { size_bytes: metadata.len(), mtime, created_at, - content_hash: Some(hash_text(text)), + content_hash: Some(text_utils::hash_text(text)), encoding: Encoding::Utf8, line_ending, is_conflict, @@ -720,6 +744,8 @@ impl Store { let mtime_str = meta.mtime.to_rfc3339(); let created_at_str = meta.created_at.map(|timestamp| timestamp.to_rfc3339()); let updated_at_str = Utc::now().to_rfc3339(); + let encoding: i32 = meta.encoding.into(); + let line_ending: i32 = meta.line_ending.into(); conn.execute( "INSERT INTO documents @@ -759,8 +785,8 @@ impl Store { mtime_str, created_at_str, meta.content_hash.clone(), - encoding_to_i32(meta.encoding), - line_ending_to_i32(meta.line_ending), + encoding, + line_ending, meta.is_conflict as i32, meta.title, meta.word_count.map(|n| n as i64), @@ -773,15 +799,14 @@ impl Store { } fn index_document_text(&self, doc_id: &DocId, meta: &DocMeta, text: &str) -> Result<(), AppError> { - if !is_indexable_text_path(&doc_id.rel_path) { + if !file_utils::is_indexable_text_path(&doc_id.rel_path) { self.remove_fts_entry(doc_id)?; return Ok(()); } - let title = meta - .title - .clone() - .unwrap_or_else(|| extract_title(text, &doc_id.rel_path).unwrap_or_else(|| "Untitled".to_string())); + let title = meta.title.clone().unwrap_or_else(|| { + text_utils::extract_title(text, &doc_id.rel_path).unwrap_or_else(|| "Untitled".to_string()) + }); self.upsert_fts_entry(doc_id, &title, text) } @@ -870,8 +895,8 @@ impl Store { let meta = self.read_doc_metadata(&full_path, doc_id.location_id, doc_id.rel_path.clone(), &filename)?; self.update_doc_in_catalog(doc_id, &meta)?; - if is_indexable_text_path(&full_path) { - let text = read_file_text_with_detection(&full_path)?; + if file_utils::is_indexable_text_path(&full_path) { + let text = file_utils::read_file_text_with_detection(&full_path)?; self.index_document_text(doc_id, &meta, &text)?; } else { self.remove_fts_entry(doc_id)?; @@ -890,7 +915,7 @@ impl Store { } let mut file_paths = Vec::new(); - collect_file_paths_recursive(&location.root_path, &mut file_paths)?; + file_utils::collect_file_paths_recursive(&location.root_path, &mut file_paths)?; let mut seen_rel_paths = HashSet::new(); let mut indexed = 0usize; @@ -916,8 +941,8 @@ impl Store { let meta = self.read_doc_metadata(&full_path, location_id, rel_path, &filename)?; self.update_doc_in_catalog(&doc_id, &meta)?; - if is_indexable_text_path(&full_path) { - match read_file_text_with_detection(&full_path) { + if file_utils::is_indexable_text_path(&full_path) { + match file_utils::read_file_text_with_detection(&full_path) { Ok(text) => { self.index_document_text(&doc_id, &meta, &text)?; indexed += 1; @@ -1064,8 +1089,8 @@ impl Store { let title: String = row.get(2)?; let snippet_marked: String = row.get(3)?; let full_content: String = row.get(4)?; - let (snippet, matches) = extract_highlight_matches(&snippet_marked); - let (line, column) = locate_query_position(&full_content, normalized_query); + let (snippet, matches) = text_utils::extract_highlight_matches(&snippet_marked); + let (line, column) = text_utils::locate_query_position(&full_content, normalized_query); Ok(SearchHit { location_id: LocationId(location_id), rel_path, title, snippet, line, column, matches }) }) @@ -1081,174 +1106,6 @@ impl Store { } } -/// Detects encoding from byte BOM and decodes to string -fn detect_and_decode(bytes: &[u8]) -> Result<(String, Encoding), AppError> { - if bytes.starts_with(&[0xef, 0xbb, 0xbf]) { - let text = String::from_utf8_lossy(&bytes[3..]).into_owned(); - Ok((text, Encoding::Utf8WithBom)) - } else if bytes.starts_with(&[0xff, 0xfe]) { - let u16_vec: Vec = bytes[2..] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let text = String::from_utf16(&u16_vec).map_err(|e| AppError::io(format!("Invalid UTF-16 LE: {}", e)))?; - Ok((text, Encoding::Utf16Le)) - } else if bytes.starts_with(&[0xfe, 0xff]) { - let u16_vec: Vec = bytes[2..] - .chunks_exact(2) - .map(|c| u16::from_be_bytes([c[0], c[1]])) - .collect(); - let text = String::from_utf16(&u16_vec).map_err(|e| AppError::io(format!("Invalid UTF-16 BE: {}", e)))?; - Ok((text, Encoding::Utf16Be)) - } else { - let text = String::from_utf8_lossy(bytes).into_owned(); - Ok((text, Encoding::Utf8)) - } -} - -/// Detects line ending style from text content -fn detect_line_ending(text: &str) -> LineEnding { - let crlf_count = text.matches("\r\n").count(); - let lf_count = text.matches('\n').count() - crlf_count; - if crlf_count > lf_count { LineEnding::CrLf } else { LineEnding::Lf } -} - -/// Counts words in text (simple whitespace-based) -fn count_words(text: &str) -> usize { - text.split_whitespace().count() -} - -/// Extracts title from markdown (first H1) or filename -fn extract_title(text: &str, rel_path: &Path) -> Option { - for line in text.lines() { - let trimmed = line.trim(); - if let Some(title) = trimmed.strip_prefix("# ") { - return Some(title.trim().to_string()); - } - } - - rel_path.file_stem().and_then(|s| s.to_str()).map(|s| s.to_string()) -} - -/// Converts Encoding to i32 for database storage -fn encoding_to_i32(enc: Encoding) -> i32 { - match enc { - Encoding::Utf8 => 0, - Encoding::Utf8WithBom => 1, - Encoding::Utf16Le => 2, - Encoding::Utf16Be => 3, - } -} - -/// Converts LineEnding to i32 for database storage -fn line_ending_to_i32(le: LineEnding) -> i32 { - match le { - LineEnding::Lf => 0, - LineEnding::CrLf => 1, - LineEnding::Auto => 2, - } -} - -fn is_indexable_text_path(path: &Path) -> bool { - const INDEXABLE_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "txt"]; - let extension = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("") - .to_lowercase(); - INDEXABLE_EXTENSIONS.contains(&extension.as_str()) -} - -fn hash_text(text: &str) -> String { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - text.hash(&mut hasher); - format!("{:016x}", hasher.finish()) -} - -fn read_file_text_with_detection(path: &Path) -> Result { - let mut file = File::open(path).map_err(|e| AppError::io(format!("Failed to open file: {}", e)))?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .map_err(|e| AppError::io(format!("Failed to read file: {}", e)))?; - let (text, _encoding) = detect_and_decode(&bytes)?; - Ok(text) -} - -fn collect_file_paths_recursive(dir: &Path, files: &mut Vec) -> Result<(), AppError> { - let entries = std::fs::read_dir(dir).map_err(|e| AppError::io(format!("Failed to read directory: {}", e)))?; - - for entry in entries { - let entry = entry.map_err(|e| AppError::io(format!("Failed to read entry: {}", e)))?; - let path = entry.path(); - - if path.is_file() { - files.push(path); - } else if path.is_dir() { - collect_file_paths_recursive(&path, files)?; - } - } - - Ok(()) -} - -fn extract_highlight_matches(snippet: &str) -> (String, Vec) { - let mut plain = String::new(); - let mut matches = Vec::new(); - let mut start_index: Option = None; - - let mut i = 0usize; - while i < snippet.len() { - if snippet[i..].starts_with("<<") { - start_index = Some(plain.len()); - i += 2; - continue; - } - - if snippet[i..].starts_with(">>") { - if let Some(start) = start_index.take() { - matches.push(SearchMatch { start, end: plain.len() }); - } - i += 2; - continue; - } - - if let Some(ch) = snippet[i..].chars().next() { - plain.push(ch); - i += ch.len_utf8(); - } else { - break; - } - } - - (plain, matches) -} - -fn locate_query_position(content: &str, query: &str) -> (usize, usize) { - let term = query - .split_whitespace() - .find(|token| !matches!(token.to_ascii_uppercase().as_str(), "AND" | "OR" | "NOT")) - .unwrap_or(query) - .trim_matches('"') - .to_lowercase(); - - if term.is_empty() { - return (1, 1); - } - - let content_lower = content.to_lowercase(); - if let Some(byte_index) = content_lower.find(&term) { - let prefix = &content[..byte_index]; - let line = prefix.matches('\n').count() + 1; - let column = prefix - .rsplit_once('\n') - .map(|(_, tail)| tail.chars().count() + 1) - .unwrap_or_else(|| prefix.chars().count() + 1); - (line, column) - } else { - (1, 1) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1534,7 +1391,7 @@ mod tests { #[test] fn test_detect_encoding_utf8() { let bytes = b"Hello, World!"; - let (text, enc) = detect_and_decode(bytes).unwrap(); + let (text, enc) = text_utils::detect_and_decode(bytes).unwrap(); assert_eq!(text, "Hello, World!"); assert!(matches!(enc, Encoding::Utf8)); } @@ -1542,49 +1399,27 @@ mod tests { #[test] fn test_detect_encoding_utf8_bom() { let bytes = vec![0xef, 0xbb, 0xbf, b'H', b'i']; - let (text, enc) = detect_and_decode(&bytes).unwrap(); + let (text, enc) = text_utils::detect_and_decode(&bytes).unwrap(); assert_eq!(text, "Hi"); assert!(matches!(enc, Encoding::Utf8WithBom)); } #[test] + /// TODO: move to core fn test_detect_line_ending_lf() { let text = "line1\nline2\nline3"; - let le = detect_line_ending(text); + let le = LineEnding::detect(text); assert!(matches!(le, LineEnding::Lf)); } #[test] + /// TODO: move to core fn test_detect_line_ending_crlf() { let text = "line1\r\nline2\r\nline3"; - let le = detect_line_ending(text); + let le = LineEnding::detect(text); assert!(matches!(le, LineEnding::CrLf)); } - #[test] - fn test_count_words() { - assert_eq!(count_words("Hello world"), 2); - assert_eq!(count_words("One two three four"), 4); - assert_eq!(count_words(""), 0); - assert_eq!(count_words(" multiple spaces "), 2); - } - - #[test] - fn test_extract_title_from_heading() { - let text = "# My Title\n\nSome content"; - let path = Path::new("file.md"); - let title = extract_title(text, path); - assert_eq!(title, Some("My Title".to_string())); - } - - #[test] - fn test_extract_title_from_filename() { - let text = "No heading here"; - let path = Path::new("my_document.md"); - let title = extract_title(text, path); - assert_eq!(title, Some("my_document".to_string())); - } - #[test] fn test_ui_layout_settings_defaults() { let (store, _temp) = create_test_store(); diff --git a/crates/store/src/settings.rs b/crates/store/src/settings.rs new file mode 100644 index 0000000..66bcbc7 --- /dev/null +++ b/crates/store/src/settings.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; + +use super::StyleCheckPattern; + +fn default_true() -> bool { + true +} + +fn default_editor_font_size() -> u16 { + 16 +} + +fn default_editor_font_family() -> String { + "IBM Plex Mono".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StyleCheckCategorySettings { + pub filler: bool, + pub redundancy: bool, + pub cliche: bool, +} + +impl Default for StyleCheckCategorySettings { + fn default() -> Self { + Self { filler: true, redundancy: true, cliche: true } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct StyleCheckSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub categories: StyleCheckCategorySettings, + #[serde(default)] + pub custom_patterns: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UiLayoutSettings { + pub sidebar_collapsed: bool, + pub top_bars_collapsed: bool, + pub status_bar_collapsed: bool, + #[serde(default = "default_true")] + pub line_numbers_visible: bool, + #[serde(default = "default_true")] + pub text_wrapping_enabled: bool, + #[serde(default = "default_true")] + pub syntax_highlighting_enabled: bool, + #[serde(default = "default_editor_font_size")] + pub editor_font_size: u16, + #[serde(default = "default_editor_font_family")] + pub editor_font_family: String, +} + +impl Default for UiLayoutSettings { + fn default() -> Self { + Self { + sidebar_collapsed: false, + top_bars_collapsed: false, + status_bar_collapsed: false, + line_numbers_visible: true, + text_wrapping_enabled: true, + syntax_highlighting_enabled: true, + editor_font_size: default_editor_font_size(), + editor_font_family: default_editor_font_family(), + } + } +} diff --git a/crates/store/src/text_utils.rs b/crates/store/src/text_utils.rs new file mode 100644 index 0000000..fec28ef --- /dev/null +++ b/crates/store/src/text_utils.rs @@ -0,0 +1,141 @@ +use std::{ + hash::{Hash, Hasher}, + path::Path, +}; + +use writer_core::{AppError, Encoding, SearchMatch}; + +/// Counts words in text (simple whitespace-based) +pub fn count_words(text: &str) -> usize { + text.split_whitespace().count() +} + +pub fn hash_text(text: &str) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +/// Extracts title from markdown (first H1) or filename +pub fn extract_title(text: &str, rel_path: &Path) -> Option { + for line in text.lines() { + let trimmed = line.trim(); + if let Some(title) = trimmed.strip_prefix("# ") { + return Some(title.trim().to_string()); + } + } + + rel_path.file_stem().and_then(|s| s.to_str()).map(|s| s.to_string()) +} + +pub fn extract_highlight_matches(snippet: &str) -> (String, Vec) { + let mut plain = String::new(); + let mut matches = Vec::new(); + let mut start_index: Option = None; + + let mut i = 0usize; + while i < snippet.len() { + if snippet[i..].starts_with("<<") { + start_index = Some(plain.len()); + i += 2; + continue; + } + + if snippet[i..].starts_with(">>") { + if let Some(start) = start_index.take() { + matches.push(SearchMatch { start, end: plain.len() }); + } + i += 2; + continue; + } + + if let Some(ch) = snippet[i..].chars().next() { + plain.push(ch); + i += ch.len_utf8(); + } else { + break; + } + } + + (plain, matches) +} + +/// Detects encoding from byte BOM and decodes to string +pub fn detect_and_decode(bytes: &[u8]) -> Result<(String, Encoding), AppError> { + if bytes.starts_with(&[0xef, 0xbb, 0xbf]) { + let text = String::from_utf8_lossy(&bytes[3..]).into_owned(); + Ok((text, Encoding::Utf8WithBom)) + } else if bytes.starts_with(&[0xff, 0xfe]) { + let u16_vec: Vec = bytes[2..] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let text = String::from_utf16(&u16_vec).map_err(|e| AppError::io(format!("Invalid UTF-16 LE: {}", e)))?; + Ok((text, Encoding::Utf16Le)) + } else if bytes.starts_with(&[0xfe, 0xff]) { + let u16_vec: Vec = bytes[2..] + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + let text = String::from_utf16(&u16_vec).map_err(|e| AppError::io(format!("Invalid UTF-16 BE: {}", e)))?; + Ok((text, Encoding::Utf16Be)) + } else { + let text = String::from_utf8_lossy(bytes).into_owned(); + Ok((text, Encoding::Utf8)) + } +} + +pub fn locate_query_position(content: &str, query: &str) -> (usize, usize) { + let term = query + .split_whitespace() + .find(|token| !matches!(token.to_ascii_uppercase().as_str(), "AND" | "OR" | "NOT")) + .unwrap_or(query) + .trim_matches('"') + .to_lowercase(); + + if term.is_empty() { + return (1, 1); + } + + let content_lower = content.to_lowercase(); + if let Some(byte_index) = content_lower.find(&term) { + let prefix = &content[..byte_index]; + let line = prefix.matches('\n').count() + 1; + let column = prefix + .rsplit_once('\n') + .map(|(_, tail)| tail.chars().count() + 1) + .unwrap_or_else(|| prefix.chars().count() + 1); + (line, column) + } else { + (1, 1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_count_words() { + assert_eq!(count_words("Hello world"), 2); + assert_eq!(count_words("One two three four"), 4); + assert_eq!(count_words(""), 0); + assert_eq!(count_words(" multiple spaces "), 2); + } + + #[test] + fn test_extract_title_from_heading() { + let text = "# My Title\n\nSome content"; + let path = Path::new("file.md"); + let title = extract_title(text, path); + assert_eq!(title, Some("My Title".to_string())); + } + + #[test] + fn test_extract_title_from_filename() { + let text = "No heading here"; + let path = Path::new("my_document.md"); + let title = extract_title(text, path); + assert_eq!(title, Some("my_document".to_string())); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1384624..dc3d66b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,7 +10,7 @@ use writer_core::{ SaveResult, SearchFilters, SearchHit, }; use writer_md::{MarkdownEngine, MarkdownProfile, PdfRenderResult, RenderResult}; -use writer_store::{Store, UiLayoutSettings}; +use writer_store::{Store, StyleCheckSettings, UiLayoutSettings}; /// Application state shared across commands pub struct AppState { @@ -605,3 +605,29 @@ pub fn markdown_render_for_pdf( } } } + +#[tauri::command] +pub fn style_check_get(state: State<'_, AppState>) -> Result, ()> { + tracing::debug!("Loading persisted style check settings"); + + match state.store.style_check_get() { + Ok(settings) => Ok(CommandResult::ok(settings)), + Err(e) => { + tracing::error!("Failed to load style check settings: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn style_check_set(state: State<'_, AppState>, settings: StyleCheckSettings) -> Result, ()> { + tracing::debug!("Persisting style check settings"); + + match state.store.style_check_set(&settings) { + Ok(()) => Ok(CommandResult::ok(true)), + Err(e) => { + tracing::error!("Failed to persist style check settings: {}", e); + Ok(CommandResult::err(e)) + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index df7903c..0e8a72e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,8 +3,8 @@ use tauri::Manager; mod commands; use commands::{ doc_exists, doc_list, doc_open, doc_save, location_add_via_dialog, location_list, location_remove, - location_validate, markdown_render, markdown_render_for_pdf, search, ui_layout_get, ui_layout_set, watch_disable, - watch_enable, + location_validate, markdown_render, markdown_render_for_pdf, search, style_check_get, style_check_set, + ui_layout_get, ui_layout_set, watch_disable, watch_enable, }; pub use commands::AppState; @@ -61,7 +61,9 @@ pub fn run() { markdown_render, markdown_render_for_pdf, ui_layout_get, - ui_layout_set + ui_layout_set, + style_check_get, + style_check_set ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index dbf0922..83cd170 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { logger } from "$logger"; import type { PdfExportOptions, PdfRenderResult } from "$pdf/types"; -import { renderMarkdownForPdf, runCmd, uiLayoutGet, uiLayoutSet } from "$ports"; +import { renderMarkdownForPdf, runCmd, styleCheckGet, styleCheckSet, uiLayoutGet, uiLayoutSet } from "$ports"; import type { DocMeta, DocRef, Tab } from "$types"; import { useCallback, useEffect, useMemo, useState } from "react"; import { AppHeaderBar } from "./components/layout/AppHeaderBar"; @@ -21,6 +21,7 @@ import { useWorkspaceSync } from "./hooks/useWorkspaceSync"; import { useLayoutActions, useLayoutState } from "./state/appStore"; import "@fontsource-variable/ibm-plex-sans"; import "./App.css"; +import { PatternCategory } from "$editor/pattern-matcher"; // TODO: make shared utils module function formatDraftDate(date: Date): string { @@ -276,6 +277,7 @@ function App() { fontSize: layoutState.editorFontSize, fontFamily: layoutState.editorFontFamily, posHighlightingEnabled: layoutState.posHighlightingEnabled, + styleCheckSettings: layoutState.styleCheckSettings, onChange: handleEditorChange, onSave: handleSave, onCursorMove: handleCursorMove, @@ -352,6 +354,14 @@ function App() { onSetFocusDimmingMode: layoutActions.setFocusDimmingMode, posHighlightingEnabled: layoutState.posHighlightingEnabled, onSetPosHighlightingEnabled: layoutActions.setPosHighlightingEnabled, + styleCheckSettings: layoutState.styleCheckSettings, + onSetStyleCheckEnabled: (enabled: boolean) => + layoutActions.setStyleCheckSettings({ ...layoutState.styleCheckSettings, enabled }), + onSetStyleCheckCategory: (category: PatternCategory, enabled: boolean) => + layoutActions.setStyleCheckCategory(category, enabled), + onAddCustomPattern: (pattern: { text: string; category: PatternCategory; replacement?: string }) => + layoutActions.addCustomPattern(pattern), + onRemoveCustomPattern: (index: number) => layoutActions.removeCustomPattern(index), onClose: handleSettingsClose, }), [isLayoutSettingsOpen, layoutState, layoutActions, handleSettingsClose], @@ -443,6 +453,18 @@ function App() { } })); + void runCmd(styleCheckGet((settings) => { + if (isCancelled) { + return; + } + + layoutActions.setStyleCheckSettings({ + enabled: settings.enabled, + categories: settings.categories, + customPatterns: settings.custom_patterns, + }); + }, () => {})); + return () => { isCancelled = true; }; @@ -481,6 +503,29 @@ function App() { layoutState.editorFontFamily, ]); + useEffect(() => { + if (!layoutSettingsHydrated) { + return; + } + + void runCmd( + styleCheckSet( + { + enabled: layoutState.styleCheckSettings.enabled, + categories: layoutState.styleCheckSettings.categories, + custom_patterns: layoutState.styleCheckSettings.customPatterns, + }, + () => {}, + () => {}, + ), + ); + }, [ + layoutSettingsHydrated, + layoutState.styleCheckSettings.enabled, + layoutState.styleCheckSettings.categories, + layoutState.styleCheckSettings.customPatterns, + ]); + const appHeaderBarProps = useMemo( () => ({ onToggleSidebar: layoutActions.toggleSidebarCollapsed, diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a212b35..5e063b1 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,6 +1,6 @@ import { focusDimming, focusDimmingTheme } from "$editor/focus-dimming"; import { posHighlighting, posHighlightingTheme } from "$editor/pos-highlighting"; -import { styleCheck, styleCheckTheme } from "$editor/style-check"; +import { type StyleMatch, styleCheck, styleCheckTheme } from "$editor/style-check"; import { typewriterScroll } from "$editor/typewriter-scroll"; import { oxocarbonDark } from "$themes/oxocarbon-dark"; import { oxocarbonLight } from "$themes/oxocarbon-light"; @@ -34,10 +34,11 @@ export type EditorProps = { onSave?: () => void; onCursorMove?: (line: number, column: number) => void; onSelectionChange?: (from: number, to: number | null) => void; + onStyleMatchesChange?: (matches: StyleMatch[]) => void; className?: string; }; -type EditorCallbacks = Pick; +type EditorCallbacks = Pick; type CreateEditorStateOptions = { doc: string; @@ -53,6 +54,7 @@ type CreateEditorStateOptions = { focusDimmingMode: FocusDimmingMode; posHighlightingEnabled: boolean; styleCheckSettings: StyleCheckSettings; + onStyleMatchesChange?: (matches: StyleMatch[]) => void; }; const EDITOR_FONT_FAMILY_MAP: Record = { @@ -82,6 +84,7 @@ function createEditorState( focusDimmingMode, posHighlightingEnabled, styleCheckSettings, + onStyleMatchesChange, }: CreateEditorStateOptions, ): CMEditorState { const themeExtension = theme === "dark" ? oxocarbonDark : oxocarbonLight; @@ -100,7 +103,12 @@ function createEditorState( styleCheck({ enabled: styleCheckSettings.enabled, categories: styleCheckSettings.categories, - customPatterns: [], + customPatterns: styleCheckSettings.customPatterns.map((p) => ({ + text: p.text, + category: p.category, + replacement: p.replacement, + })), + onMatchesChange: onStyleMatchesChange, }), styleCheckTheme, ] @@ -142,17 +150,18 @@ export function Editor( typewriterScrollingEnabled = false, focusDimmingMode = "off", posHighlightingEnabled = false, - styleCheckSettings = { enabled: false, categories: { filler: true, redundancy: true, cliche: true } }, + styleCheckSettings = { enabled: false, categories: { filler: true, redundancy: true, cliche: true }, customPatterns: [] }, onChange, onSave, onCursorMove, onSelectionChange, + onStyleMatchesChange, className = "", }: EditorProps, ) { const containerRef = useRef(null); const viewRef = useRef(null); - const callbacksRef = useRef({ onChange, onSave, onCursorMove, onSelectionChange }); + const callbacksRef = useRef({ onChange, onSave, onCursorMove, onSelectionChange, onStyleMatchesChange }); const debounceMsRef = useRef(debounceMs); const onChangeTimeoutRef = useRef | null>(null); const initialTextRef = useRef(initialText); @@ -171,8 +180,8 @@ export function Editor( const [isReady, setIsReady] = useState(false); useEffect(() => { - callbacksRef.current = { onChange, onSave, onCursorMove, onSelectionChange }; - }, [onChange, onSave, onCursorMove, onSelectionChange]); + callbacksRef.current = { onChange, onSave, onCursorMove, onSelectionChange, onStyleMatchesChange }; + }, [onChange, onSave, onCursorMove, onSelectionChange, onStyleMatchesChange]); useEffect(() => { debounceMsRef.current = debounceMs; @@ -231,6 +240,7 @@ export function Editor( placeholder: currentPresentation.placeholder, updateListener: createUpdateListener(), onSave: () => callbacksRef.current.onSave?.(), + onStyleMatchesChange: callbacksRef.current.onStyleMatchesChange, typewriterScrollingEnabled: currentPresentation.typewriterScrollingEnabled, focusDimmingMode: currentPresentation.focusDimmingMode, posHighlightingEnabled: currentPresentation.posHighlightingEnabled, diff --git a/src/components/layout/DiagnosticsPanel.tsx b/src/components/layout/DiagnosticsPanel.tsx new file mode 100644 index 0000000..4fdfcf7 --- /dev/null +++ b/src/components/layout/DiagnosticsPanel.tsx @@ -0,0 +1,152 @@ +import { CATEGORY_COLORS, CATEGORY_LABELS } from "$editor/constants"; +import type { StyleMatch } from "$editor/style-check"; +import { XIcon } from "$icons"; +import { useCallback, useMemo } from "react"; + +type DiagnosticsPanelProps = { + isVisible: boolean; + matches: StyleMatch[]; + sidebarCollapsed: boolean; + topOffset: number; + onSelectMatch: (match: StyleMatch) => void; + onClose: () => void; +}; + +type GroupedMatches = { filler: StyleMatch[]; redundancy: StyleMatch[]; cliche: StyleMatch[] }; + +function groupMatches(matches: StyleMatch[]): GroupedMatches { + return { + filler: matches.filter((m) => m.category === "filler"), + redundancy: matches.filter((m) => m.category === "redundancy"), + cliche: matches.filter((m) => m.category === "cliche"), + }; +} + +function MatchItem({ match, onClick }: { match: StyleMatch; onClick: (match: StyleMatch) => void }) { + const matchStyle = useMemo( + () => ({ backgroundColor: `${CATEGORY_COLORS[match.category]}20`, color: CATEGORY_COLORS[match.category] }), + [match.category], + ); + + const clickHandler = useCallback(() => onClick(match), [onClick, match]); + + return ( + + ); +} + +function CategorySection( + { category, matches, onSelectMatch }: { + category: keyof GroupedMatches; + matches: StyleMatch[]; + onSelectMatch: (match: StyleMatch) => void; + }, +) { + const labelStyle = useMemo(() => ({ backgroundColor: CATEGORY_COLORS[category] }), [category]); + if (matches.length === 0) { + return null; + } + + return ( +
+
+ +

{CATEGORY_LABELS[category]}

+ {matches.length} +
+
+ {matches.map((match, index) => { + const k = `${category}-${index}-${match.from}`; + return ; + })} +
+
+ ); +} + +const DiagnosticsPanelHeader = ({ totalCount, onClose }: { totalCount: number; onClose: () => void }) => ( + <> +
+

Style Check

+

+ {totalCount === 0 ? "No issues found" : `${totalCount} issue${totalCount === 1 ? "" : "s"} found`} +

+
+ + +); + +const DiagnosticsPanelContent = ( + { grouped, totalCount, onSelectMatch }: { + grouped: GroupedMatches; + totalCount: number; + onSelectMatch: (match: StyleMatch) => void; + }, +) => { + if (totalCount === 0) { + return ( +
+
+

Great writing! No style issues detected.

+
+
+ ); + } + + return ( +
+ + + +
+ ); +}; + +export function DiagnosticsPanel( + { isVisible, matches, sidebarCollapsed, topOffset, onSelectMatch, onClose }: DiagnosticsPanelProps, +) { + const grouped = useMemo(() => groupMatches(matches), [matches]); + const totalCount = useMemo(() => matches.length, [matches]); + const panelStyle = useMemo( + () => ({ + left: sidebarCollapsed ? 16 : 256 + 16, + top: topOffset + 16, + width: 320, + maxHeight: "calc(100vh - 80px)", + }), + [sidebarCollapsed, topOffset], + ); + + if (!isVisible) { + return null; + } + + return ( +
+
+ +
+ + +
+ ); +} diff --git a/src/components/layout/LayoutSettingsPanel.tsx b/src/components/layout/LayoutSettingsPanel.tsx index ab5708f..a80a7af 100644 --- a/src/components/layout/LayoutSettingsPanel.tsx +++ b/src/components/layout/LayoutSettingsPanel.tsx @@ -1,6 +1,13 @@ +import { PatternCategory } from "$editor/pattern-matcher"; import { XIcon } from "$icons"; -import type { EditorFontFamily, FocusDimmingMode, FocusModeSettings } from "$types"; -import { type ChangeEvent, useCallback } from "react"; +import type { + EditorFontFamily, + FocusDimmingMode, + FocusModeSettings, + StyleCheckPattern, + StyleCheckSettings, +} from "$types"; +import { type ChangeEvent, ChangeEventHandler, useCallback, useMemo, useState } from "react"; type LayoutSettingsPanelProps = { isVisible: boolean; @@ -14,6 +21,7 @@ type LayoutSettingsPanelProps = { editorFontFamily: EditorFontFamily; focusModeSettings: FocusModeSettings; posHighlightingEnabled: boolean; + styleCheckSettings: StyleCheckSettings; onSetSidebarCollapsed: (value: boolean) => void; onSetTopBarsCollapsed: (value: boolean) => void; onSetStatusBarCollapsed: (value: boolean) => void; @@ -25,6 +33,10 @@ type LayoutSettingsPanelProps = { onSetTypewriterScrollingEnabled: (enabled: boolean) => void; onSetFocusDimmingMode: (mode: FocusDimmingMode) => void; onSetPosHighlightingEnabled: (value: boolean) => void; + onSetStyleCheckEnabled: (enabled: boolean) => void; + onSetStyleCheckCategory: (category: keyof StyleCheckSettings["categories"], enabled: boolean) => void; + onAddCustomPattern: (pattern: { text: string; category: PatternCategory; replacement?: string }) => void; + onRemoveCustomPattern: (index: number) => void; onClose: () => void; }; @@ -148,6 +160,238 @@ function DimmingModeRow({ value, setter }: DimmingModeRowProps) { ); } +const ToggleCustom = ( + { showCustom, setShowCustom, settings }: { + showCustom: boolean; + setShowCustom: (value: boolean) => void; + settings: StyleCheckSettings; + }, +) => { + const handleClick = useCallback(() => setShowCustom(!showCustom), [showCustom, setShowCustom]); + const label = useMemo(() => { + const count = settings.customPatterns.length; + const tag = showCustom ? "Hide" : "Show"; + return `${tag} Custom Patterns (${count})`; + }, [showCustom, settings.customPatterns.length]); + return ( + + ); +}; + +const PatternLabel = ({ pattern }: { pattern: StyleCheckPattern }) => ( + + [{pattern.category}]{" "} + {pattern.text} + {pattern.replacement && → {pattern.replacement}} + +); + +const RemovePatternButton = ( + { index, onRemovePattern }: { index: number; onRemovePattern: (index: number) => void }, +) => { + const handleClick = useCallback(() => onRemovePattern(index), [index, onRemovePattern]); + return ( + + ); +}; + +const CustomPattern = ( + { settings, onRemovePattern }: { settings: StyleCheckSettings; onRemovePattern: (index: number) => void }, +) => ( +
+ {settings.customPatterns.map((pattern, index) => { + const key = `${pattern.category}-${pattern.text}`; + return ( +
+ + +
+ ); + })} +
+); + +const AddPatternForm = ( + { + pattern: [patternValue, setPatternValue], + category: [categoryValue, setCategoryValue], + replacement: [replacementValue, setReplacementValue], + addPattern, + }: { + pattern: [string, React.Dispatch>]; + category: [string, React.Dispatch>]; + replacement: [string, React.Dispatch>]; + addPattern: () => void; + }, +) => { + const handlePatternChange: ChangeEventHandler = useCallback( + (e) => setPatternValue(e.target.value), + [setPatternValue], + ); + + const handleReplacementChange: ChangeEventHandler = useCallback( + (e) => setReplacementValue(e.target.value), + [setReplacementValue], + ); + + const handleCategoryChange: ChangeEventHandler = useCallback( + (e) => setCategoryValue(e.target.value as PatternCategory), + [setCategoryValue], + ); + + return ( +
+ + + + +
+ ); +}; + +const CustomPatternSection = ( + { settings, onRemovePattern, custom: [showCustom, setShowCustom], pattern, category, replacement, addPattern }: { + settings: StyleCheckSettings; + pattern: [string, React.Dispatch>]; + category: [PatternCategory, React.Dispatch>]; + replacement: [string, React.Dispatch>]; + custom: [boolean, React.Dispatch>]; + addPattern: () => void; + onRemovePattern: (index: number) => void; + }, +) => ( +
+ + + {showCustom && ( +
+ {settings.customPatterns.length > 0 && } + + +
+ )} +
+); + +type StyleCheckSectionProps = { + settings: StyleCheckSettings; + onSetEnabled: (enabled: boolean) => void; + onSetCategory: (category: keyof StyleCheckSettings["categories"], enabled: boolean) => void; + onAddPattern: (pattern: { text: string; category: PatternCategory; replacement?: string }) => void; + onRemovePattern: (index: number) => void; +}; + +function StyleCheckSection( + { settings, onSetEnabled, onSetCategory, onAddPattern, onRemovePattern }: StyleCheckSectionProps, +) { + const customState = useState(false); + const patternState = useState(""); + const categoryState = useState("filler"); + const replacementState = useState(""); + + const toggleFiller = useCallback(() => { + onSetCategory("filler", !settings.categories.filler); + }, [onSetCategory, settings.categories.filler]); + + const toggleRedundancy = useCallback(() => { + onSetCategory("redundancy", !settings.categories.redundancy); + }, [onSetCategory, settings.categories.redundancy]); + + const toggleCliche = useCallback(() => { + onSetCategory("cliche", !settings.categories.cliche); + }, [onSetCategory, settings.categories.cliche]); + + const handleAddPattern = useCallback(() => { + const [newPattern, setNewPattern] = patternState; + const [newCategory] = categoryState; + const [newReplacement, setNewReplacement] = replacementState; + if (newPattern.trim()) { + onAddPattern({ + text: newPattern.trim().toLowerCase(), + category: newCategory, + replacement: newReplacement.trim() || undefined, + }); + setNewPattern(""); + setNewReplacement(""); + } + }, [patternState, categoryState, replacementState, onAddPattern]); + + return ( +
+ + + {settings.enabled && ( +
+

Categories

+ + + + + +
+ )} +
+ ); +} + export function LayoutSettingsPanel( { isVisible, @@ -161,6 +405,7 @@ export function LayoutSettingsPanel( editorFontFamily, focusModeSettings, posHighlightingEnabled, + styleCheckSettings, onSetSidebarCollapsed, onSetTopBarsCollapsed, onSetStatusBarCollapsed, @@ -172,6 +417,10 @@ export function LayoutSettingsPanel( onSetTypewriterScrollingEnabled, onSetFocusDimmingMode, onSetPosHighlightingEnabled, + onSetStyleCheckEnabled, + onSetStyleCheckCategory, + onAddCustomPattern, + onRemoveCustomPattern, onClose, }: LayoutSettingsPanelProps, ) { @@ -274,6 +523,13 @@ export function LayoutSettingsPanel( description="Color text by grammatical role (nouns, verbs, adjectives, etc.)." isVisible={posHighlightingEnabled} onToggle={togglePosHighlighting} /> + + ); diff --git a/src/editor/constants.ts b/src/editor/constants.ts new file mode 100644 index 0000000..f5d13b3 --- /dev/null +++ b/src/editor/constants.ts @@ -0,0 +1,3 @@ +export const CATEGORY_LABELS = { filler: "Fillers & Weak Language", redundancy: "Redundancies", cliche: "Clichés" }; + +export const CATEGORY_COLORS = { filler: "#f97316", redundancy: "#eab308", cliche: "#ef4444" }; diff --git a/src/editor/style-check.ts b/src/editor/style-check.ts index 6124cb5..76b8c3c 100644 --- a/src/editor/style-check.ts +++ b/src/editor/style-check.ts @@ -37,6 +37,7 @@ export type StyleCheckConfig = { enabled: boolean; categories: { filler: boolean; redundancy: boolean; cliche: boolean }; customPatterns: Pattern[]; + onMatchesChange?: (matches: StyleMatch[]) => void; }; const DEFAULT_CONFIG: StyleCheckConfig = { @@ -133,13 +134,17 @@ function createStyleCheckPlugin(config: StyleCheckConfig) { const result = scanViewport(view, this.matcher); this.decorations = result.decorations; this.matches = result.matches; + config.onMatchesChange?.(this.matches); } } update(update: ViewUpdate) { if (!config.enabled) { this.decorations = Decoration.none; - this.matches = []; + if (this.matches.length > 0) { + this.matches = []; + config.onMatchesChange?.(this.matches); + } return; } @@ -147,6 +152,7 @@ function createStyleCheckPlugin(config: StyleCheckConfig) { const result = scanViewport(this.view, this.matcher); this.decorations = result.decorations; this.matches = result.matches; + config.onMatchesChange?.(this.matches); } } diff --git a/src/ports.ts b/src/ports.ts index c1e94a9..d80335d 100644 --- a/src/ports.ts +++ b/src/ports.ts @@ -1,3 +1,4 @@ +import { PatternCategory } from "$editor/pattern-matcher"; import { logger } from "$logger"; import type { PdfRenderResult } from "$pdf/types"; import type { @@ -50,6 +51,16 @@ export type UiLayoutSettings = { editor_font_family: EditorFontFamily; }; +export type StyleCheckPattern = { text: string; category: PatternCategory; replacement?: string }; + +export type StyleCheckCategorySettings = { filler: boolean; redundancy: boolean; cliche: boolean }; + +export type PersistedStyleCheckSettings = { + enabled: boolean; + categories: StyleCheckCategorySettings; + custom_patterns: StyleCheckPattern[]; +}; + type RenderMarkdownParams = [...LocationPathTextParams, profile: MarkdownProfile | undefined, ...LocParams]; type UiLayoutSetParams = Parameters< (settings: UiLayoutSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void @@ -497,3 +508,15 @@ export function uiLayoutGet(...[onOk, onErr]: LocParams): Cmd export function uiLayoutSet(...[settings, onOk, onErr]: UiLayoutSetParams): Cmd { return invokeCmd("ui_layout_set", { settings }, onOk, onErr); } + +type StyleCheckSetParams = Parameters< + (settings: PersistedStyleCheckSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + +export function styleCheckGet(...[onOk, onErr]: LocParams): Cmd { + return invokeCmd("style_check_get", {}, onOk, onErr); +} + +export function styleCheckSet(...[settings, onOk, onErr]: StyleCheckSetParams): Cmd { + return invokeCmd("style_check_set", { settings }, onOk, onErr); +} diff --git a/src/state/appStore.ts b/src/state/appStore.ts index 3f35aaf..5d068c7 100644 --- a/src/state/appStore.ts +++ b/src/state/appStore.ts @@ -6,6 +6,7 @@ import type { FocusDimmingMode, FocusModeSettings, LocationDescriptor, + StyleCheckPattern, StyleCheckSettings, Tab, } from "$types"; @@ -71,6 +72,8 @@ export type LayoutActions = { setStyleCheckSettings: (settings: StyleCheckSettings) => void; toggleStyleCheck: () => void; setStyleCheckCategory: (category: keyof StyleCheckSettings["categories"], enabled: boolean) => void; + addCustomPattern: (pattern: StyleCheckPattern) => void; + removeCustomPattern: (index: number) => void; }; export type WorkspaceState = { @@ -123,7 +126,7 @@ const getInitialLayoutState = (): LayoutState => ({ theme: "dark", focusModeSettings: { typewriterScrollingEnabled: true, dimmingMode: "sentence" }, posHighlightingEnabled: false, - styleCheckSettings: { enabled: false, categories: { filler: true, redundancy: true, cliche: true } }, + styleCheckSettings: { enabled: false, categories: { filler: true, redundancy: true, cliche: true }, customPatterns: [] }, }); const getInitialWorkspaceState = (): WorkspaceState => ({ @@ -208,6 +211,20 @@ const createLayoutSlice: StateCreator + set((state) => ({ + styleCheckSettings: { + ...state.styleCheckSettings, + customPatterns: [...state.styleCheckSettings.customPatterns, pattern], + }, + })), + removeCustomPattern: (index) => + set((state) => ({ + styleCheckSettings: { + ...state.styleCheckSettings, + customPatterns: state.styleCheckSettings.customPatterns.filter((_, i) => i !== index), + }, + })), }); const createWorkspaceSlice: StateCreator = (set) => ({ @@ -399,6 +416,8 @@ export const useLayoutActions = () => setStyleCheckSettings: state.setStyleCheckSettings, toggleStyleCheck: state.toggleStyleCheck, setStyleCheckCategory: state.setStyleCheckCategory, + addCustomPattern: state.addCustomPattern, + removeCustomPattern: state.removeCustomPattern, })), ); diff --git a/src/types.ts b/src/types.ts index 645473f..46266f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { PatternCategory } from "$editor/pattern-matcher"; + export type SaveStatus = "Idle" | "Dirty" | "Saving" | "Saved" | "Error"; export type LineEnding = "LF" | "CRLF" | "CR"; @@ -85,4 +87,10 @@ export type PosHighlightingEnabled = boolean; export type StyleCheckCategorySettings = { filler: boolean; redundancy: boolean; cliche: boolean }; -export type StyleCheckSettings = { enabled: boolean; categories: StyleCheckCategorySettings }; +export type StyleCheckPattern = { text: string; category: PatternCategory; replacement?: string }; + +export type StyleCheckSettings = { + enabled: boolean; + categories: StyleCheckCategorySettings; + customPatterns: StyleCheckPattern[]; +};