From 852d98749db2232cc697b5745ad38a591e133abf Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 6 Oct 2026 13:42:25 +0530 Subject: [PATCH 1/2] refactor: share duplicated helpers and cache token ratios Collapse copy-pasted path, settings, sidecar, clipboard, and window-style helpers. Parse benchmark-ratios.json once. Frontend reuses token-savings aggregates. Cargo.lock crate version now matches 0.2.11. --- src-tauri/Cargo.lock | 2 +- src-tauri/src/bin/parsekit-cli.rs | 93 +----------- src-tauri/src/clipboard_convert.rs | 196 ++++-------------------- src-tauri/src/clipboard_paths.rs | 2 +- src-tauri/src/global_hotkey.rs | 117 ++------------- src-tauri/src/lib.rs | 230 ++++++++++++++++++----------- src-tauri/src/macos_open_files.rs | 5 +- src-tauri/src/macos_popover.rs | 32 ++-- src-tauri/src/popover_trace.rs | 28 +--- src-tauri/src/sidecar_helpers.rs | 88 +++++++++-- src-tauri/src/token_count.rs | 27 ++-- src-tauri/src/token_stats.rs | 3 +- src/lib/progressHud.ts | 8 +- src/lib/tokenStats.ts | 18 ++- 14 files changed, 313 insertions(+), 536 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 066eec5..93ec917 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3197,7 +3197,7 @@ dependencies = [ [[package]] name = "parsekit" -version = "0.2.8" +version = "0.2.11" dependencies = [ "chrono", "liteparse", diff --git a/src-tauri/src/bin/parsekit-cli.rs b/src-tauri/src/bin/parsekit-cli.rs index cfc614d..6fa4076 100644 --- a/src-tauri/src/bin/parsekit-cli.rs +++ b/src-tauri/src/bin/parsekit-cli.rs @@ -1,8 +1,9 @@ //! ParseKit CLI: script-friendly wrapper around the parsekit-sidecar binary. //! Same JSON-lines protocol and parse logic as the GUI — no fork of the engine. -use parsekit_lib::sidecar_helpers::validate_output_format; +use parsekit_lib::sidecar_helpers::{resolve_sidecar, validate_output_format}; use parsekit_lib::token_stats::{self, RecordInput}; +use parsekit_lib::{path_has_supported_extension, read_settings_json}; use serde::Deserialize; use serde_json::{json, Value}; use std::env; @@ -12,12 +13,6 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use walkdir::WalkDir; -const SUPPORTED_EXTENSIONS: &[&str] = &[ - "pdf", "doc", "docx", "docm", "odt", "rtf", "ppt", "pptx", "pptm", "odp", "xls", "xlsx", - "xlsm", "ods", "csv", "tsv", "png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp", - "svg", -]; - const HELP: &str = "\ ParseKit — convert documents to Markdown/JSON locally @@ -192,7 +187,7 @@ fn execute_convert(args: ConvertArgs) -> Result<(), String> { input.display() )); } else { - if !is_supported_file(&input) { + if !path_has_supported_extension(&input) { return Err(format!( "Unsupported file type: {}", input.display() @@ -446,21 +441,11 @@ fn apply_rename_target( Ok(target) } -fn is_supported_file(path: &Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| { - let lower = ext.to_ascii_lowercase(); - SUPPORTED_EXTENSIONS.contains(&lower.as_str()) - }) - .unwrap_or(false) -} - fn scan_supported_files(dir: &Path) -> Result, String> { let mut files = Vec::new(); for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); - if path.is_file() && is_supported_file(path) { + if path.is_file() && path_has_supported_extension(path) { files.push(path.to_path_buf()); } } @@ -468,74 +453,10 @@ fn scan_supported_files(dir: &Path) -> Result, String> { Ok(files) } -fn settings_path() -> Result { - let home = env::var("HOME").map_err(|e| e.to_string())?; - Ok(PathBuf::from(home) - .join("Library/Application Support/com.harshabala.parsekit/settings.json")) -} - fn load_sidecar_settings() -> SidecarSettings { - let path = match settings_path() { - Ok(path) => path, - Err(_) => return default_sidecar_settings(), - }; - let raw = match fs::read_to_string(path) { - Ok(raw) => raw, - Err(_) => return default_sidecar_settings(), - }; - serde_json::from_str(&raw).unwrap_or_else(|_| default_sidecar_settings()) -} - -fn host_triple() -> String { - match env::consts::OS { - "macos" => format!("{}-apple-darwin", env::consts::ARCH), - "linux" => format!("{}-unknown-linux-gnu", env::consts::ARCH), - "windows" => format!("{}-pc-windows-msvc", env::consts::ARCH), - other => format!("{}-{other}", env::consts::ARCH), - } -} - -fn resolve_sidecar() -> Result { - if let Ok(path) = env::var("PARSEKIT_SIDECAR") { - let candidate = PathBuf::from(&path); - if candidate.is_file() { - return Ok(candidate); - } - return Err(format!("PARSEKIT_SIDECAR not found: {path}")); - } - - if let Ok(exe) = env::current_exe() { - if let Some(dir) = exe.parent() { - let sibling = dir.join("parsekit-sidecar"); - if sibling.is_file() { - return Ok(sibling); - } - } - } - - if let Ok(exe) = env::current_exe() { - let mut dir = exe.parent().map(Path::to_path_buf); - for _ in 0..8 { - let Some(current) = dir else { break }; - let triple = host_triple(); - let bundled = current - .join("src-tauri/binaries") - .join(format!("parsekit-sidecar-{triple}")); - if bundled.is_file() { - return Ok(bundled); - } - let plain = current.join("src-tauri/binaries/parsekit-sidecar"); - if plain.is_file() { - return Ok(plain); - } - dir = current.parent().map(Path::to_path_buf); - } - } - - Err( - "parsekit-sidecar not found. Build with: npm run build:sidecar (or set PARSEKIT_SIDECAR)" - .to_string(), - ) + read_settings_json() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_else(default_sidecar_settings) } fn execute_notify(args: &[String]) -> Result<(), String> { diff --git a/src-tauri/src/clipboard_convert.rs b/src-tauri/src/clipboard_convert.rs index 8002964..85aab64 100644 --- a/src-tauri/src/clipboard_convert.rs +++ b/src-tauri/src/clipboard_convert.rs @@ -13,7 +13,7 @@ use serde_json::{json, Value}; use tauri::{AppHandle, Manager, Runtime}; use crate::clipboard_paths::get_clipboard_file_paths; -use crate::sidecar_helpers::validate_output_format; +use crate::sidecar_helpers::{resolve_sidecar, validate_output_format}; pub const AUTO_CONVERT_SETTINGS_KEY: &str = "autoConvertOnCopy"; @@ -49,158 +49,36 @@ fn default_ocr_language() -> String { "eng".to_string() } -fn settings_path() -> Result { - let home = std::env::var("HOME").map_err(|e| e.to_string())?; - Ok(PathBuf::from(home) - .join("Library/Application Support/com.harshabala.parsekit/settings.json")) -} - -pub fn read_auto_convert_from_settings() -> bool { - let path = match settings_path() { - Ok(path) => path, - Err(_) => return false, - }; - let raw = match std::fs::read_to_string(path) { - Ok(raw) => raw, - Err(_) => return false, - }; - let value: Value = match serde_json::from_str(&raw) { - Ok(value) => value, - Err(_) => return false, - }; - value - .get(AUTO_CONVERT_SETTINGS_KEY) - .and_then(Value::as_bool) - .unwrap_or(false) -} - -fn load_app_settings() -> AppSidecarSettings { - let path = match settings_path() { - Ok(path) => path, - Err(_) => return AppSidecarSettings { - format: default_format(), - ocr_enabled: true, - ocr_language: default_ocr_language(), - workers: 4, - }, - }; - let raw = match std::fs::read_to_string(path) { - Ok(raw) => raw, - Err(_) => return AppSidecarSettings { +impl Default for AppSidecarSettings { + fn default() -> Self { + Self { format: default_format(), ocr_enabled: true, ocr_language: default_ocr_language(), workers: 4, - }, - }; - serde_json::from_str(&raw).unwrap_or(AppSidecarSettings { - format: default_format(), - ocr_enabled: true, - ocr_language: default_ocr_language(), - workers: 4, - }) -} - -fn normalize_user_path(path: String) -> String { - let trimmed = path.trim(); - if trimmed.starts_with("file://") { - if let Ok(url) = url::Url::parse(trimmed) { - if let Ok(p) = url.to_file_path() { - return p.to_string_lossy().into_owned(); - } } } - trimmed.to_string() } -fn is_supported_extension(path: &str) -> bool { - Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .map(|ext| crate::SUPPORTED_EXTENSIONS.contains(&ext.to_lowercase().as_str())) +pub fn read_auto_convert_from_settings() -> bool { + crate::read_settings_json() + .and_then(|value| { + value + .get(AUTO_CONVERT_SETTINGS_KEY) + .and_then(Value::as_bool) + }) .unwrap_or(false) } -fn expand_path_to_supported_files(path: &str) -> Vec { - let normalized = normalize_user_path(path.to_string()); - if normalized.is_empty() { - return Vec::new(); - } - let p = Path::new(&normalized); - if p.is_file() { - return if is_supported_extension(&normalized) { - vec![normalized] - } else { - Vec::new() - }; - } - if p.is_dir() { - return crate::scan_directory_sync(normalized).unwrap_or_default(); - } - Vec::new() +fn load_app_settings() -> AppSidecarSettings { + crate::read_settings_json() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default() } /// Supported files referenced by the current clipboard contents. pub fn resolve_clipboard_supported_files() -> Vec { - let mut files = Vec::new(); - for path in get_clipboard_file_paths() { - files.extend(expand_path_to_supported_files(&path)); - } - files.sort(); - files.dedup(); - files -} - -fn host_triple() -> String { - match std::env::consts::OS { - "macos" => format!("{}-apple-darwin", std::env::consts::ARCH), - "linux" => format!("{}-unknown-linux-gnu", std::env::consts::ARCH), - "windows" => format!("{}-pc-windows-msvc", std::env::consts::ARCH), - other => format!("{}-{other}", std::env::consts::ARCH), - } -} - -fn resolve_sidecar() -> Result { - if let Ok(path) = std::env::var("PARSEKIT_SIDECAR") { - let candidate = PathBuf::from(&path); - if candidate.is_file() { - return Ok(candidate); - } - return Err(format!("PARSEKIT_SIDECAR not found: {path}")); - } - - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let sibling = dir.join("parsekit-sidecar"); - if sibling.is_file() { - return Ok(sibling); - } - } - } - - if let Ok(exe) = std::env::current_exe() { - let mut dir = exe.parent().map(Path::to_path_buf); - for _ in 0..8 { - let Some(current) = dir else { break }; - let triple = host_triple(); - let bundled = current - .join("src-tauri/binaries") - .join(format!("parsekit-sidecar-{triple}")); - if bundled.is_file() { - return Ok(bundled); - } - let plain = current.join("src-tauri/binaries/parsekit-sidecar"); - if plain.is_file() { - return Ok(plain); - } - dir = current.parent().map(Path::to_path_buf); - } - } - - Err( - "parsekit-sidecar not found. Build with: npm run build:sidecar (or set PARSEKIT_SIDECAR)" - .to_string(), - ) + crate::collect_supported_files(get_clipboard_file_paths()) } fn run_sidecar_for_files( @@ -317,34 +195,6 @@ fn run_sidecar_for_files( Ok(output_paths) } -fn copy_text_to_system_clipboard(text: &str) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - let mut child = Command::new("/usr/bin/pbcopy") - .stdin(Stdio::piped()) - .spawn() - .map_err(|e| format!("Could not run pbcopy: {e}"))?; - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(text.as_bytes()) - .map_err(|e| format!("Could not write to pbcopy: {e}"))?; - } - let status = child - .wait() - .map_err(|e| format!("pbcopy wait failed: {e}"))?; - if status.success() { - Ok(()) - } else { - Err("pbcopy failed to copy to clipboard".into()) - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = text; - Err("Clipboard copy is only supported on macOS".into()) - } -} - fn temp_output_dir() -> Result { let stamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -376,7 +226,7 @@ pub fn convert_clipboard_files_to_clipboard() -> Result { combined.push_str(&content); } - copy_text_to_system_clipboard(&combined)?; + crate::copy_text_to_system_clipboard(&combined)?; let _ = std::fs::remove_dir_all(&temp_dir); Ok(files.len()) } @@ -464,19 +314,23 @@ impl Clone for ClipboardWatchState { #[cfg(test)] mod tests { - use super::*; + use std::path::Path; #[test] fn normalize_file_url_path() { assert_eq!( - normalize_user_path("file:///tmp/report.pdf".to_string()), + crate::normalize_user_path("file:///tmp/report.pdf"), "/tmp/report.pdf" ); } #[test] fn unsupported_extension_filtered() { - assert!(!is_supported_extension("/tmp/readme.txt")); - assert!(is_supported_extension("/tmp/report.PDF")); + assert!(!crate::path_has_supported_extension(Path::new( + "/tmp/readme.txt" + ))); + assert!(crate::path_has_supported_extension(Path::new( + "/tmp/report.PDF" + ))); } } \ No newline at end of file diff --git a/src-tauri/src/clipboard_paths.rs b/src-tauri/src/clipboard_paths.rs index 3d01b40..d250130 100644 --- a/src-tauri/src/clipboard_paths.rs +++ b/src-tauri/src/clipboard_paths.rs @@ -1,7 +1,7 @@ //! Read file paths from the macOS pasteboard (Finder copy → file URL). #[cfg(target_os = "macos")] -fn run_osascript(script: &str) -> Option { +pub(crate) fn run_osascript(script: &str) -> Option { let output = std::process::Command::new("osascript") .arg("-e") .arg(script) diff --git a/src-tauri/src/global_hotkey.rs b/src-tauri/src/global_hotkey.rs index caf55d4..ad0fcb4 100644 --- a/src-tauri/src/global_hotkey.rs +++ b/src-tauri/src/global_hotkey.rs @@ -1,6 +1,5 @@ //! Global hotkey: parse Finder selection or clipboard file paths in the background. -use std::path::Path; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, Runtime}; @@ -14,92 +13,19 @@ pub struct GlobalHotkeyState { current_shortcut: Mutex, } -fn settings_path() -> Result { - let home = std::env::var("HOME").map_err(|e| e.to_string())?; - Ok(std::path::PathBuf::from(home) - .join("Library/Application Support/com.harshabala.parsekit/settings.json")) -} - pub fn read_global_shortcut_from_settings() -> String { - let path = match settings_path() { - Ok(path) => path, - Err(_) => return DEFAULT_GLOBAL_SHORTCUT.to_string(), - }; - let raw = match std::fs::read_to_string(path) { - Ok(raw) => raw, - Err(_) => return DEFAULT_GLOBAL_SHORTCUT.to_string(), - }; - let value: serde_json::Value = match serde_json::from_str(&raw) { - Ok(value) => value, - Err(_) => return DEFAULT_GLOBAL_SHORTCUT.to_string(), - }; - value - .get(SETTINGS_KEY) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) + crate::read_settings_json() + .and_then(|value| { + value + .get(SETTINGS_KEY) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }) .unwrap_or_else(|| DEFAULT_GLOBAL_SHORTCUT.to_string()) } -fn normalize_user_path(path: String) -> String { - let trimmed = path.trim(); - if trimmed.starts_with("file://") { - if let Ok(url) = url::Url::parse(trimmed) { - if let Ok(p) = url.to_file_path() { - return p.to_string_lossy().into_owned(); - } - } - } - trimmed.to_string() -} - -fn is_supported_extension(path: &str) -> bool { - Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .map(|ext| crate::SUPPORTED_EXTENSIONS.contains(&ext.to_lowercase().as_str())) - .unwrap_or(false) -} - -fn expand_path_to_supported_files(path: &str) -> Vec { - let normalized = normalize_user_path(path.to_string()); - if normalized.is_empty() { - return Vec::new(); - } - let p = Path::new(&normalized); - if p.is_file() { - return if is_supported_extension(&normalized) { - vec![normalized] - } else { - Vec::new() - }; - } - if p.is_dir() { - return crate::scan_directory_sync(normalized) - .unwrap_or_default(); - } - Vec::new() -} - -#[cfg(target_os = "macos")] -fn run_osascript(script: &str) -> Option { - let output = std::process::Command::new("osascript") - .arg("-e") - .arg(script) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if text.is_empty() { - None - } else { - Some(text) - } -} - #[cfg(target_os = "macos")] fn get_finder_selection_paths() -> Vec { let script = r#" @@ -113,7 +39,7 @@ tell application "Finder" return text 1 thru -2 of out end tell "#; - run_osascript(script) + crate::clipboard_paths::run_osascript(script) .map(|text| { text.lines() .map(str::trim) @@ -129,31 +55,10 @@ fn get_finder_selection_paths() -> Vec { Vec::new() } -pub fn resolve_hotkey_input_paths() -> Vec { - let mut raw_paths = get_finder_selection_paths(); - if raw_paths.is_empty() { - raw_paths = crate::clipboard_paths::get_clipboard_file_paths(); - } - - let mut files = Vec::new(); - for path in raw_paths { - files.extend(expand_path_to_supported_files(&path)); - } - - files.sort(); - files.dedup(); - files -} - fn handle_hotkey_pressed(app: &AppHandle) { let finder_paths = get_finder_selection_paths(); if !finder_paths.is_empty() { - let mut files = Vec::new(); - for path in finder_paths { - files.extend(expand_path_to_supported_files(&path)); - } - files.sort(); - files.dedup(); + let files = crate::collect_supported_files(finder_paths); if files.is_empty() { let _ = crate::display_notification( "ParseKit", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3209ca4..2ae3b2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -236,32 +236,10 @@ fn position_popover_under_tray(window: &WebviewWindow, rect: &Rec return false; }; let scale = window.scale_factor().unwrap_or(1.0); - - let icon_x = match rect.position { - Position::Physical(p) => p.x as f64, - Position::Logical(p) => p.x * scale, - }; - let icon_y = match rect.position { - Position::Physical(p) => p.y as f64, - Position::Logical(p) => p.y * scale, - }; - let icon_w = match rect.size { - Size::Physical(s) => s.width as f64, - Size::Logical(s) => s.width * scale, - }; - let icon_h = match rect.size { - Size::Physical(s) => s.height as f64, - Size::Logical(s) => s.height * scale, - }; + let (icon_x, icon_y, icon_w, icon_h) = physical_xywh(rect, scale); let win_w = win_size.width as f64; let win_h = win_size.height as f64; - - let (monitor_width, monitor_height) = window - .current_monitor() - .ok() - .flatten() - .map(|m| (m.size().width as f64, m.size().height as f64)) - .unwrap_or((f64::MAX, f64::MAX)); + let (monitor_width, monitor_height) = monitor_size_f64(window, (f64::MAX, f64::MAX)); let x = (icon_x + icon_w / 2.0 - win_w / 2.0) .max(0.0) @@ -274,7 +252,6 @@ fn position_popover_under_tray(window: &WebviewWindow, rect: &Rec let _ = window.set_position(PhysicalPosition::new(x, y)); popover_trace(&format!("Positioning: OK x={x} y={y} win={win_w}x{win_h}")); - let _ = win_h; // keep height available for future vertical clamping true } @@ -394,8 +371,8 @@ fn restore_accessory_app_policy(app: &AppHandle) { let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory); } -fn normalize_user_path(path: String) -> String { - let trimmed = path.trim(); +pub(crate) fn normalize_user_path(path: impl AsRef) -> String { + let trimmed = path.as_ref().trim(); if trimmed.starts_with("file://") { if let Ok(url) = url::Url::parse(trimmed) { if let Ok(p) = url.to_file_path() { @@ -406,9 +383,24 @@ fn normalize_user_path(path: String) -> String { trimmed.to_string() } +pub fn app_support_dir() -> Result { + let home = std::env::var("HOME").map_err(|e| e.to_string())?; + Ok(std::path::PathBuf::from(home) + .join("Library/Application Support/com.harshabala.parsekit")) +} + +pub fn settings_json_path() -> Result { + Ok(app_support_dir()?.join("settings.json")) +} + +pub fn read_settings_json() -> Option { + let raw = std::fs::read_to_string(settings_json_path().ok()?).ok()?; + serde_json::from_str(&raw).ok() +} + /// Resolve and validate a user-supplied path before spawning `open` or similar OS commands. fn validate_user_path(path: &str) -> Result { - let normalized = normalize_user_path(path.to_string()); + let normalized = normalize_user_path(path); if normalized.is_empty() { return Err("Path is empty".into()); } @@ -425,6 +417,35 @@ fn file_path_to_string(path: tauri_plugin_dialog::FilePath) -> Option { .map(normalize_user_path) } +fn physical_xywh(rect: &Rect, scale: f64) -> (f64, f64, f64, f64) { + let x = match rect.position { + Position::Physical(p) => p.x as f64, + Position::Logical(p) => p.x * scale, + }; + let y = match rect.position { + Position::Physical(p) => p.y as f64, + Position::Logical(p) => p.y * scale, + }; + let w = match rect.size { + Size::Physical(s) => s.width as f64, + Size::Logical(s) => s.width * scale, + }; + let h = match rect.size { + Size::Physical(s) => s.height as f64, + Size::Logical(s) => s.height * scale, + }; + (x, y, w, h) +} + +fn monitor_size_f64(window: &WebviewWindow, fallback: (f64, f64)) -> (f64, f64) { + window + .current_monitor() + .ok() + .flatten() + .map(|m| (m.size().width as f64, m.size().height as f64)) + .unwrap_or(fallback) +} + /// Open native pickers without a parent window. Borderless accessory popovers cannot host /// sheet modals; attaching the main window as parent causes a system beep and no Finder UI. async fn run_file_picker_async( @@ -507,13 +528,13 @@ async fn pick_input_files( .await } -#[tauri::command] -async fn pick_input_folder( +async fn pick_named_folder( app: AppHandle, popover: State<'_, PopoverState>, + title: &'static str, ) -> Result, String> { - run_file_picker_async(app, popover.inner(), |app| async move { - await_pick_folder(&app, "Select folder") + run_file_picker_async(app, popover.inner(), move |app| async move { + await_pick_folder(&app, title) .await .ok() .flatten() @@ -523,20 +544,20 @@ async fn pick_input_folder( .map(|paths| paths.and_then(|mut v| v.pop())) } +#[tauri::command] +async fn pick_input_folder( + app: AppHandle, + popover: State<'_, PopoverState>, +) -> Result, String> { + pick_named_folder(app, popover, "Select folder").await +} + #[tauri::command] async fn pick_output_folder( app: AppHandle, popover: State<'_, PopoverState>, ) -> Result, String> { - run_file_picker_async(app, popover.inner(), |app| async move { - await_pick_folder(&app, "Choose output folder") - .await - .ok() - .flatten() - .map(|path| vec![path]) - }) - .await - .map(|paths| paths.and_then(|mut v| v.pop())) + pick_named_folder(app, popover, "Choose output folder").await } #[cfg(target_os = "macos")] @@ -668,6 +689,28 @@ fn imagemagick_available() -> bool { ) } +#[cfg(test)] +mod path_helper_tests { + use super::{normalize_user_path, path_has_supported_extension}; + use std::path::Path; + + #[test] + fn normalize_file_url_and_trim() { + assert_eq!( + normalize_user_path("file:///tmp/report.pdf"), + "/tmp/report.pdf" + ); + assert_eq!(normalize_user_path(" /tmp/a.pdf "), "/tmp/a.pdf"); + } + + #[test] + fn supported_extension_is_case_insensitive() { + assert!(path_has_supported_extension(Path::new("Report.PDF"))); + assert!(!path_has_supported_extension(Path::new("notes.txt"))); + assert!(!path_has_supported_extension(Path::new("noext"))); + } +} + #[cfg(test)] mod security_path_tests { use super::sanitize_report_file_name; @@ -993,8 +1036,7 @@ pub(crate) fn display_notification(title: &str, body: &str) -> Result<(), String } /// Copy plain text to the system clipboard (macOS `pbcopy` — reliable for menu-bar apps). -#[tauri::command] -fn copy_text_to_clipboard(text: String) -> Result<(), String> { +pub(crate) fn copy_text_to_system_clipboard(text: &str) -> Result<(), String> { #[cfg(target_os = "macos")] { use std::io::Write; @@ -1024,6 +1066,11 @@ fn copy_text_to_clipboard(text: String) -> Result<(), String> { } } +#[tauri::command] +fn copy_text_to_clipboard(text: String) -> Result<(), String> { + copy_text_to_system_clipboard(&text) +} + #[tauri::command] fn get_system_info() -> Result { Ok(serde_json::json!({ @@ -1053,11 +1100,54 @@ fn trigger_haptic() -> Result<(), String> { // Canonical list of supported file extensions — single source of truth used for both // the preview file count (scan_directory) and the actual parse file set passed to the sidecar. // Aligned with LiteParse v2 multi-format support (LibreOffice / ImageMagick where noted). -pub(crate) const SUPPORTED_EXTENSIONS: &[&str] = &[ +pub const SUPPORTED_EXTENSIONS: &[&str] = &[ "pdf", "doc", "docx", "docm", "odt", "rtf", "ppt", "pptx", "pptm", "odp", "xls", "xlsx", "xlsm", "ods", "csv", "tsv", "png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp", "svg", ]; +pub fn path_has_supported_extension(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| { + SUPPORTED_EXTENSIONS + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(ext)) + }) +} + +pub(crate) fn expand_path_to_supported_files(path: &str) -> Vec { + let normalized = normalize_user_path(path); + if normalized.is_empty() { + return Vec::new(); + } + let p = Path::new(&normalized); + if p.is_file() { + return if path_has_supported_extension(p) { + vec![normalized] + } else { + Vec::new() + }; + } + if p.is_dir() { + return scan_directory_sync(normalized).unwrap_or_default(); + } + Vec::new() +} + +pub(crate) fn collect_supported_files(raw_paths: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + let mut files = Vec::new(); + for path in raw_paths { + files.extend(expand_path_to_supported_files(path.as_ref())); + } + files.sort(); + files.dedup(); + files +} + const SCAN_MAX_DEPTH: usize = 32; const SCAN_MAX_FILES: usize = 10_000; @@ -1082,12 +1172,9 @@ pub(crate) fn scan_directory_sync(path: String) -> Result, String> { "Too many supported files (max {SCAN_MAX_FILES}). Choose a smaller folder." )); } - if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) { - let ext_lower = ext.to_lowercase(); - if SUPPORTED_EXTENSIONS.contains(&ext_lower.as_str()) { - if let Some(s) = entry_path.to_str() { - files.push(s.to_string()); - } + if path_has_supported_extension(entry_path) { + if let Some(s) = entry_path.to_str() { + files.push(s.to_string()); } } } @@ -1126,16 +1213,14 @@ async fn scan_directory(path: String) -> Result, String> { #[cfg(target_os = "macos")] fn maybe_show_menu_bar_hint() { - let Ok(home) = std::env::var("HOME") else { + let Ok(dir) = app_support_dir() else { return; }; - let marker = format!("{home}/Library/Application Support/com.harshabala.parsekit/.menu_bar_hint_shown"); - if Path::new(&marker).exists() { + let marker = dir.join(".menu_bar_hint_shown"); + if marker.exists() { return; } - if let Some(parent) = Path::new(&marker).parent() { - let _ = std::fs::create_dir_all(parent); - } + let _ = std::fs::create_dir_all(&dir); let _ = std::fs::write(&marker, ""); let _ = show_completion_notification( "ParseKit".to_string(), @@ -1249,32 +1334,10 @@ fn position_progress_hud_under_tray(window: &WebviewWindow, rect: return false; }; let scale = window.scale_factor().unwrap_or(1.0); - - let icon_x = match rect.position { - Position::Physical(p) => p.x as f64, - Position::Logical(p) => p.x * scale, - }; - let icon_y = match rect.position { - Position::Physical(p) => p.y as f64, - Position::Logical(p) => p.y * scale, - }; - let icon_w = match rect.size { - Size::Physical(s) => s.width as f64, - Size::Logical(s) => s.width * scale, - }; - let icon_h = match rect.size { - Size::Physical(s) => s.height as f64, - Size::Logical(s) => s.height * scale, - }; + let (icon_x, icon_y, icon_w, icon_h) = physical_xywh(rect, scale); let win_w = win_size.width as f64; let win_h = win_size.height as f64; - - let (monitor_width, monitor_height) = window - .current_monitor() - .ok() - .flatten() - .map(|m| (m.size().width as f64, m.size().height as f64)) - .unwrap_or((f64::MAX, f64::MAX)); + let (monitor_width, monitor_height) = monitor_size_f64(window, (f64::MAX, f64::MAX)); // Anchor below menu bar icon, right-aligned to tray. let x = (icon_x + icon_w - win_w) @@ -1294,12 +1357,7 @@ fn position_progress_hud_top_right(window: &WebviewWindow) -> boo return false; }; let scale = window.scale_factor().unwrap_or(1.0); - let (monitor_width, _) = window - .current_monitor() - .ok() - .flatten() - .map(|m| (m.size().width as f64, m.size().height as f64)) - .unwrap_or((1920.0 * scale, 1080.0 * scale)); + let (monitor_width, _) = monitor_size_f64(window, (1920.0 * scale, 1080.0 * scale)); let margin = 16.0 * scale; let menu_bar = 36.0 * scale; diff --git a/src-tauri/src/macos_open_files.rs b/src-tauri/src/macos_open_files.rs index 267bdf5..62e55ba 100644 --- a/src-tauri/src/macos_open_files.rs +++ b/src-tauri/src/macos_open_files.rs @@ -15,10 +15,7 @@ struct OpenQueueFile { } fn queue_path() -> Result { - let home = std::env::var("HOME").map_err(|e| e.to_string())?; - Ok(PathBuf::from(home) - .join("Library/Application Support/com.harshabala.parsekit") - .join("open-queue.json")) + Ok(crate::app_support_dir()?.join("open-queue.json")) } fn ensure_support_dir() -> Result { diff --git a/src-tauri/src/macos_popover.rs b/src-tauri/src/macos_popover.rs index eec8429..a3637e8 100644 --- a/src-tauri/src/macos_popover.rs +++ b/src-tauri/src/macos_popover.rs @@ -65,17 +65,14 @@ pub(crate) fn activate_app_for_popover( popover_trace("Activation: complete"); } -/// Configure the webview window as a floating popover (above menu bar, all spaces). #[cfg(target_os = "macos")] -pub fn configure_popover_window(window: &WebviewWindow) { +fn apply_floating_clear_style(window: &WebviewWindow) -> bool { let Ok(ns_ptr) = window.ns_window() else { - popover_trace("Activation: configure FAILED (ns_window unavailable)"); - return; + return false; }; unsafe { let ns_window: &NSWindow = &*ns_ptr.cast(); ns_window.setLevel(NSFloatingWindowLevel); - popover_trace("Activation: NSFloatingWindowLevel + clear background"); let behavior = NSWindowCollectionBehavior::CanJoinAllSpaces | NSWindowCollectionBehavior::FullScreenAuxiliary; ns_window.setCollectionBehavior(behavior); @@ -83,6 +80,17 @@ pub fn configure_popover_window(window: &WebviewWindow) { ns_window.setOpaque(false); ns_window.setBackgroundColor(Some(&NSColor::clearColor())); } + true +} + +/// Configure the webview window as a floating popover (above menu bar, all spaces). +#[cfg(target_os = "macos")] +pub fn configure_popover_window(window: &WebviewWindow) { + if !apply_floating_clear_style(window) { + popover_trace("Activation: configure FAILED (ns_window unavailable)"); + return; + } + popover_trace("Activation: NSFloatingWindowLevel + clear background"); } #[cfg(not(target_os = "macos"))] @@ -137,19 +145,7 @@ pub fn present_hud_window(window: &WebviewWindow) { #[cfg(target_os = "macos")] pub fn configure_hud_window(window: &WebviewWindow) { - let Ok(ns_ptr) = window.ns_window() else { - return; - }; - unsafe { - let ns_window: &NSWindow = &*ns_ptr.cast(); - ns_window.setLevel(NSFloatingWindowLevel); - let behavior = NSWindowCollectionBehavior::CanJoinAllSpaces - | NSWindowCollectionBehavior::FullScreenAuxiliary; - ns_window.setCollectionBehavior(behavior); - ns_window.setHasShadow(true); - ns_window.setOpaque(false); - ns_window.setBackgroundColor(Some(&NSColor::clearColor())); - } + let _ = apply_floating_clear_style(window); } #[cfg(not(target_os = "macos"))] diff --git a/src-tauri/src/popover_trace.rs b/src-tauri/src/popover_trace.rs index f19cb32..4800535 100644 --- a/src-tauri/src/popover_trace.rs +++ b/src-tauri/src/popover_trace.rs @@ -32,36 +32,10 @@ pub fn trace(step: &str) { #[cfg(not(debug_assertions))] pub fn trace(_step: &str) {} -#[cfg(debug_assertions)] -pub fn trace_fmt(args: std::fmt::Arguments<'_>) { - write_trace(&format!("[PopoverManager] {args}")); -} - -#[cfg(not(debug_assertions))] -pub fn trace_fmt(_args: std::fmt::Arguments<'_>) {} - -#[cfg(debug_assertions)] -pub fn tray_guard_trace(step: &str) { - write_trace(&format!("[TrayGuard] {step}")); -} - -#[cfg(not(debug_assertions))] -pub fn tray_guard_trace(_step: &str) {} - #[cfg(debug_assertions)] pub fn startup_trace(step: &str) { write_trace(&format!("[ParseKit] {step}")); } #[cfg(not(debug_assertions))] -pub fn startup_trace(_step: &str) {} - -#[macro_export] -macro_rules! popover_trace { - ($msg:expr) => { - $crate::popover_trace::trace($msg); - }; - ($fmt:expr, $($arg:tt)*) => { - $crate::popover_trace::trace_fmt(format_args!($fmt, $($arg)*)); - }; -} \ No newline at end of file +pub fn startup_trace(_step: &str) {} \ No newline at end of file diff --git a/src-tauri/src/sidecar_helpers.rs b/src-tauri/src/sidecar_helpers.rs index 08d6bcb..44a8c6c 100644 --- a/src-tauri/src/sidecar_helpers.rs +++ b/src-tauri/src/sidecar_helpers.rs @@ -11,7 +11,9 @@ use std::process::Command; pub const SPREADSHEET_EXTENSIONS: &[&str] = &["xls", "xlsx", "xlsm", "ods", "csv", "tsv"]; pub fn is_spreadsheet_ext(ext: &str) -> bool { - SPREADSHEET_EXTENSIONS.contains(&ext.to_lowercase().as_str()) + SPREADSHEET_EXTENSIONS + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(ext)) } pub fn validate_output_format(format: &str) -> Result<(), String> { @@ -51,9 +53,7 @@ pub fn output_paths(file_path: &Path, out_dir: &Path, format: &str) -> (PathBuf, .unwrap_or("document") .to_string(); let is_spreadsheet = is_spreadsheet_ext(ext); - let out_ext = if is_spreadsheet { - "json" - } else if format == "json" { + let out_ext = if is_spreadsheet || format == "json" { "json" } else if format == "txt" { "txt" @@ -167,6 +167,59 @@ pub fn token_savings_event(file_name: &str, savings: &TokenSavings) -> Value { }) } +pub fn host_triple() -> String { + match std::env::consts::OS { + "macos" => format!("{}-apple-darwin", std::env::consts::ARCH), + "linux" => format!("{}-unknown-linux-gnu", std::env::consts::ARCH), + "windows" => format!("{}-pc-windows-msvc", std::env::consts::ARCH), + other => format!("{}-{other}", std::env::consts::ARCH), + } +} + +/// Locate the `parsekit-sidecar` binary (env override, sibling of the exe, then repo binaries). +pub fn resolve_sidecar() -> Result { + if let Ok(path) = std::env::var("PARSEKIT_SIDECAR") { + let candidate = PathBuf::from(&path); + if candidate.is_file() { + return Ok(candidate); + } + return Err(format!("PARSEKIT_SIDECAR not found: {path}")); + } + + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let sibling = dir.join("parsekit-sidecar"); + if sibling.is_file() { + return Ok(sibling); + } + } + } + + if let Ok(exe) = std::env::current_exe() { + let mut dir = exe.parent().map(Path::to_path_buf); + let triple = host_triple(); + for _ in 0..8 { + let Some(current) = dir else { break }; + let bundled = current + .join("src-tauri/binaries") + .join(format!("parsekit-sidecar-{triple}")); + if bundled.is_file() { + return Ok(bundled); + } + let plain = current.join("src-tauri/binaries/parsekit-sidecar"); + if plain.is_file() { + return Ok(plain); + } + dir = current.parent().map(Path::to_path_buf); + } + } + + Err( + "parsekit-sidecar not found. Build with: npm run build:sidecar (or set PARSEKIT_SIDECAR)" + .to_string(), + ) +} + pub fn format_output(result: &ParseResult, base_name: &str, format: &str, is_spreadsheet: bool) -> String { if is_spreadsheet || format == "json" { let json_result = to_json_result(result); @@ -175,15 +228,15 @@ pub fn format_output(result: &ParseResult, base_name: &str, format: &str, is_spr }); } if format == "txt" { - if !result.pages.is_empty() { - return result - .pages - .iter() - .map(|p| p.text.as_str()) - .collect::>() - .join("\n\n---\n\n"); + if result.pages.is_empty() { + return result.text.clone(); } - return result.text.clone(); + return result + .pages + .iter() + .map(|p| p.text.as_str()) + .collect::>() + .join("\n\n---\n\n"); } // Markdown let pages: Vec = result @@ -234,4 +287,15 @@ mod tests { let cfg_zero = build_liteparse_config(true, "eng".to_string(), 0); assert_eq!(cfg_zero.num_workers, 1, "worker count is clamped to >= 1"); } + + #[test] + fn host_triple_matches_current_os() { + let triple = host_triple(); + #[cfg(target_os = "macos")] + assert!(triple.ends_with("-apple-darwin"), "{triple}"); + #[cfg(target_os = "linux")] + assert!(triple.contains("linux"), "{triple}"); + #[cfg(target_os = "windows")] + assert!(triple.contains("windows"), "{triple}"); + } } \ No newline at end of file diff --git a/src-tauri/src/token_count.rs b/src-tauri/src/token_count.rs index 92bdb5c..299bf9a 100644 --- a/src-tauri/src/token_count.rs +++ b/src-tauri/src/token_count.rs @@ -4,6 +4,7 @@ use liteparse::ParseResult; use serde::Deserialize; use std::collections::HashMap; use std::path::Path; +use std::sync::OnceLock; use tiktoken_rs::cl100k_base_singleton; const BENCHMARK_RATIOS_JSON: &str = include_str!("../../scripts/benchmark-ratios.json"); @@ -69,18 +70,24 @@ pub fn is_scanned_unlock(baseline_text: &str, result: &ParseResult, file_type: & file_type == "pdf" && baseline_text.trim().is_empty() && ocr_was_used(result) } +fn benchmark_ratios() -> &'static HashMap { + static RATIOS: OnceLock> = OnceLock::new(); + RATIOS.get_or_init(|| { + let parsed: BenchmarkRatiosFile = + serde_json::from_str(BENCHMARK_RATIOS_JSON).unwrap_or(BenchmarkRatiosFile { + by_file_type: HashMap::new(), + }); + parsed + .by_file_type + .into_iter() + .map(|(key, entry)| (key, entry.avg_reduction_ratio.clamp(0.0, 1.0))) + .collect() + }) +} + fn avg_reduction_ratio(file_type: &str) -> f64 { - let parsed: BenchmarkRatiosFile = - serde_json::from_str(BENCHMARK_RATIOS_JSON).unwrap_or(BenchmarkRatiosFile { - by_file_type: HashMap::new(), - }); let key = file_type.trim().trim_start_matches('.').to_ascii_lowercase(); - parsed - .by_file_type - .get(&key) - .map(|entry| entry.avg_reduction_ratio) - .unwrap_or(0.0) - .clamp(0.0, 1.0) + benchmark_ratios().get(&key).copied().unwrap_or(0.0) } /// Estimate baseline tokens from ParseKit output and benchmark reduction ratio. diff --git a/src-tauri/src/token_stats.rs b/src-tauri/src/token_stats.rs index 55c7b5c..ff87267 100644 --- a/src-tauri/src/token_stats.rs +++ b/src-tauri/src/token_stats.rs @@ -43,8 +43,7 @@ pub struct RecordInput { } fn support_dir() -> Result { - let home = std::env::var("HOME").map_err(|e| e.to_string())?; - Ok(PathBuf::from(home).join("Library/Application Support/com.harshabala.parsekit")) + crate::app_support_dir() } fn stats_path() -> Result { diff --git a/src/lib/progressHud.ts b/src/lib/progressHud.ts index 70143c6..16ce9dd 100644 --- a/src/lib/progressHud.ts +++ b/src/lib/progressHud.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { emitTo } from "@tauri-apps/api/event"; -import type { BatchTokenSavings } from "./progress"; +import { createBatchTokenSavings, type BatchTokenSavings } from "./progress"; import type { FileProgress } from "./types"; export const PROGRESS_HUD_WINDOW_LABEL = "progress-hud"; @@ -20,11 +20,7 @@ export function createEmptyHudState(): ProgressHudState { files: [], total: 0, isParsing: false, - batchTokenSavings: { - tokensSaved: 0, - pagesUnlocked: 0, - documentsUnlocked: 0, - }, + batchTokenSavings: createBatchTokenSavings(), }; } diff --git a/src/lib/tokenStats.ts b/src/lib/tokenStats.ts index 9fc48c7..ff6e2dd 100644 --- a/src/lib/tokenStats.ts +++ b/src/lib/tokenStats.ts @@ -62,26 +62,32 @@ export function filterEventsInRange( }); } -export function sumTokensInRange( +function sumFieldInRange( events: TokenEvent[], start: Date, end: Date, + field: "tokens_saved" | "pages_unlocked", ): number { return filterEventsInRange(events, start, end).reduce( - (sum, event) => sum + event.tokens_saved, + (sum, event) => sum + event[field], 0, ); } +export function sumTokensInRange( + events: TokenEvent[], + start: Date, + end: Date, +): number { + return sumFieldInRange(events, start, end, "tokens_saved"); +} + export function sumPagesUnlockedInRange( events: TokenEvent[], start: Date, end: Date, ): number { - return filterEventsInRange(events, start, end).reduce( - (sum, event) => sum + event.pages_unlocked, - 0, - ); + return sumFieldInRange(events, start, end, "pages_unlocked"); } export function startOfMonth(date: Date): Date { From 4ad6bc0b372579f4d10936b2d3c9cfd5a860a166 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 6 Oct 2026 14:51:37 +0530 Subject: [PATCH 2/2] test: Pin sidecar search order and ratio cache unit interval --- src-tauri/src/sidecar_helpers.rs | 12 ++++++++++++ src-tauri/src/token_count.rs | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/src-tauri/src/sidecar_helpers.rs b/src-tauri/src/sidecar_helpers.rs index 44a8c6c..fe7d3d1 100644 --- a/src-tauri/src/sidecar_helpers.rs +++ b/src-tauri/src/sidecar_helpers.rs @@ -298,4 +298,16 @@ mod tests { #[cfg(target_os = "windows")] assert!(triple.contains("windows"), "{triple}"); } + + #[test] + fn resolve_sidecar_prefers_env_then_errors_if_missing() { + let prev = std::env::var("PARSEKIT_SIDECAR").ok(); + std::env::set_var("PARSEKIT_SIDECAR", "/no/such/parsekit-sidecar-characterization"); + let err = resolve_sidecar().unwrap_err(); + assert!(err.contains("PARSEKIT_SIDECAR not found"), "{err}"); + match prev { + Some(v) => std::env::set_var("PARSEKIT_SIDECAR", v), + None => std::env::remove_var("PARSEKIT_SIDECAR"), + } + } } \ No newline at end of file diff --git a/src-tauri/src/token_count.rs b/src-tauri/src/token_count.rs index 299bf9a..f55fdde 100644 --- a/src-tauri/src/token_count.rs +++ b/src-tauri/src/token_count.rs @@ -270,4 +270,14 @@ mod tests { assert_eq!(file_type_from_path(Path::new("Report.PDF")), "pdf"); assert_eq!(file_type_from_path(Path::new("noext")), "unknown"); } + + #[test] + fn once_lock_ratio_defaults_unknown_to_zero_and_stays_in_unit_interval() { + assert_eq!(avg_reduction_ratio("definitely-missing-type"), 0.0); + assert_eq!(avg_reduction_ratio(".PDF"), avg_reduction_ratio("pdf")); + for key in ["pdf", "docx", "xlsx", "pptx", "txt", "md"] { + let r = avg_reduction_ratio(key); + assert!((0.0..=1.0).contains(&r), "{key}={r}"); + } + } } \ No newline at end of file