diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..0b244bc 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,8 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default" + "opener:default", + "dialog:default", + "fs:default" ] } diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index b736ee8..a6354ac 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -52,10 +52,17 @@ pub async fn download_model( ).ok(); } + let flags_arc = Arc::clone(&state.download_cancel_flags); + tauri::async_runtime::spawn(async move { let res = perform_model_download(app_clone.clone(), models_dir.clone(), model_clone.clone(), cancel_flag).await; - let db = state_db.lock().await; + + { + let mut flags = flags_arc.lock().await; + flags.remove(&model_clone.id); + } + let db = state_db.lock().await; match res { Ok(_) => { let file_path = models_dir.join(&model_clone.file_name).to_string_lossy().to_string(); @@ -64,16 +71,24 @@ pub async fn download_model( "UPDATE models SET status = 'downloaded', file_path = ?1, downloaded_at = ?2 WHERE id = ?3", rusqlite::params![file_path, now, model_clone.id], ).ok(); + + app_clone.emit("model-download-complete", serde_json::json!({ + "model_id": model_clone.id + })).ok(); } Err(err) => { db.execute( "UPDATE models SET status = 'available' WHERE id = ?1", rusqlite::params![model_clone.id], ).ok(); - app_clone.emit("model-download-error", serde_json::json!({ - "model_id": model_clone.id, - "error": err - })).ok(); + + let is_cancellation = err.to_lowercase().contains("cancel"); + if !is_cancellation { + app_clone.emit("model-download-error", serde_json::json!({ + "model_id": model_clone.id, + "error": err + })).ok(); + } } } }); diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 2243f58..e93f290 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -59,6 +59,47 @@ pub async fn get_app_data_dir( Ok(format_app_data_dir(&state.app_data_dir)) } +/// Opens a file or directory using the host operating system's default viewer/application. +#[tauri::command] +pub async fn open_file_path( + path: String, +) -> Result<(), String> { + let is_url = path.starts_with("http://") || path.starts_with("https://"); + if !is_url { + let p = Path::new(&path); + if !p.exists() { + return Err(format!("File does not exist on disk: {}", path)); + } + } + + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| format!("Failed to open file: {}", e))?; + Ok(()) + } + + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", "", &path]) + .spawn() + .map_err(|e| format!("Failed to open file: {}", e))?; + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + std::process::Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| format!("Failed to open file: {}", e))?; + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18e3aaf..cdb0fc3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -99,6 +99,7 @@ pub fn run() { commands::settings::get_settings, commands::settings::set_setting, commands::settings::get_app_data_dir, + commands::settings::open_file_path, commands::models::get_models, commands::models::download_model, commands::models::cancel_model_download, diff --git a/src-tauri/src/llm/client.rs b/src-tauri/src/llm/client.rs index 61bf629..a4b502f 100644 --- a/src-tauri/src/llm/client.rs +++ b/src-tauri/src/llm/client.rs @@ -270,7 +270,6 @@ impl LlamaClient { let location = None; let mut skills = Vec::new(); let mut experience_years = None; - let mut education = Vec::new(); let work_experience = Vec::new(); // 1. Email Regex @@ -328,23 +327,8 @@ impl LlamaClient { } } - // 6. Education heuristic - let degrees = ["Bachelor", "B.Tech", "B.E.", "B.S.", "BS", "Master", "M.Tech", "M.S.", "MS", "Ph.D", "PhD", "Associate Degree"]; - for line in text.lines() { - for deg in degrees { - if line.to_lowercase().contains(°.to_lowercase()) { - education.push(Education { - degree: deg.to_string(), - institution: line.trim().to_string(), - year: None, - }); - break; - } - } - if education.len() >= 2 { - break; - } - } + // 6. Education extraction + let education = extract_education_from_text(text); ExtractedCandidate { name, @@ -361,6 +345,164 @@ impl LlamaClient { } } +/// Strictly extracts educational credentials from resume text. +/// +/// Ensures credentials only originate from authentic education sections or explicit +/// degree patterns with word boundaries, preventing false positives from technical keywords +/// (e.g., "CMS", "AWS", "systems"). +pub fn extract_education_from_text(text: &str) -> Vec { + let mut education_entries = Vec::new(); + let lines: Vec<&str> = text.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect(); + + let mut in_edu_section = false; + let mut edu_lines = Vec::new(); + + let section_headers = [ + "EXPERIENCE", "WORK EXPERIENCE", "PROFESSIONAL EXPERIENCE", "EMPLOYMENT HISTORY", + "CAREER HISTORY", "SKILLS", "TECHNICAL SKILLS", "PROJECTS", "KEY PROJECTS", + "PERSONAL PROJECTS", "CERTIFICATIONS", "ACHIEVEMENTS", "AWARDS", "PUBLICATIONS", + "LANGUAGES", "INTERESTS", "VOLUNTEER", "VOLUNTEERING", "SUMMARY", "PROFESSIONAL SUMMARY", + ]; + + for line in &lines { + let clean_upper = line.trim_matches(|c: char| c == ':' || c.is_whitespace()).to_uppercase(); + if clean_upper == "EDUCATION" + || clean_upper.starts_with("EDUCATION") + || clean_upper == "ACADEMIC BACKGROUND" + || clean_upper.starts_with("ACADEMIC") + || clean_upper == "ACADEMICS" + || clean_upper == "QUALIFICATIONS" + { + in_edu_section = true; + continue; + } + + if in_edu_section { + if section_headers.iter().any(|&hdr| clean_upper == hdr || clean_upper.starts_with(hdr)) { + break; + } + edu_lines.push(*line); + } + } + + let has_edu_section = !edu_lines.is_empty(); + let search_lines = if has_edu_section { + &edu_lines[..] + } else { + &lines[..] + }; + + let degree_patterns: &[(&str, &str)] = &[ + (r"(?i)\b(Bachelor(?:'s)?(?:\s+of\s+[A-Za-z\s&]+)?|B\.?E\.?|B\.?Tech|B\.?S\.?(?:c)?|BCA|BBA)\b", "Bachelor"), + (r"(?i)\b(Master(?:'s)?(?:\s+of\s+[A-Za-z\s&]+)?|M\.?E\.?|M\.?Tech|M\.?S\.?(?:c)?|MCA|MBA)\b", "Master"), + (r"(?i)\b(Ph\.?D\.?|Doctorate(?:\s+of\s+[A-Za-z\s&]+)?)\b", "Ph.D"), + (r"(?i)\b(Associate(?:'s)?(?:\s+Degree|\s+of\s+[A-Za-z\s&]+)?)\b", "Associate Degree"), + (r"(?i)\b(Diploma(?:\s+in\s+[A-Za-z\s&]+)?)\b", "Diploma"), + ]; + + let compiled_patterns: Vec<(Regex, &'static str)> = degree_patterns + .iter() + .filter_map(|(pat, cat)| Regex::new(pat).ok().map(|re| (re, *cat))) + .collect(); + + let year_re = Regex::new(r"\b(19\d{2}|20\d{2})\b").ok(); + let inst_keywords = ["University", "Institute", "College", "School", "Academy", "Polytechnic", "Campus"]; + + let mut i = 0; + while i < search_lines.len() { + let line = search_lines[i]; + let mut found_degree: Option = None; + let mut degree_category = ""; + + for (re, cat) in &compiled_patterns { + if let Some(mat) = re.find(line) { + found_degree = Some(mat.as_str().trim().to_string()); + degree_category = *cat; + break; + } + } + + if let Some(deg) = found_degree { + // When outside an explicit education section, require strong institution keywords or explicit degree phrasing + if !has_edu_section { + let has_inst = inst_keywords.iter().any(|k| line.contains(k)); + let is_explicit_degree = deg.to_lowercase().contains("bachelor") + || deg.to_lowercase().contains("master") + || deg.to_lowercase().contains("doctorate") + || deg.to_lowercase().contains("degree"); + if !has_inst && !is_explicit_degree { + i += 1; + continue; + } + } + + let mut year = None; + if let Some(ref y_re) = year_re { + if let Some(y_mat) = y_re.find(line) { + year = Some(y_mat.as_str().to_string()); + } + } + + let remainder = line.replace(°, "").trim().to_string(); + let mut clean_rem = remainder.trim_matches(|c: char| c == ',' || c == '-' || c == '|' || c == '–' || c == '—' || c.is_whitespace()).to_string(); + if let Some(ref y_val) = year { + clean_rem = clean_rem.replace(y_val, ""); + clean_rem = clean_rem.trim_matches(|c: char| c == ',' || c == '-' || c == '|' || c == '–' || c == '—' || c.is_whitespace()).to_string(); + } + + let mut institution = String::new(); + if !clean_rem.is_empty() && (inst_keywords.iter().any(|k| clean_rem.contains(k)) || clean_rem.len() > 3) { + institution = clean_rem.clone(); + } else if i + 1 < search_lines.len() { + let next_line = search_lines[i + 1]; + let next_clean_upper = next_line.trim_matches(|c: char| c == ':' || c.is_whitespace()).to_uppercase(); + let is_next_header = section_headers.iter().any(|&hdr| next_clean_upper == hdr); + let is_next_degree = compiled_patterns.iter().any(|(re, _)| re.is_match(next_line)); + + if !is_next_header && !is_next_degree { + let mut inst_str = next_line.trim().to_string(); + if year.is_none() { + if let Some(ref y_re) = year_re { + if let Some(y_mat) = y_re.find(next_line) { + year = Some(y_mat.as_str().to_string()); + } + } + } + if let Some(ref y_val) = year { + inst_str = inst_str.replace(y_val, ""); + inst_str = inst_str.trim_matches(|c: char| c == ',' || c == '-' || c == '|' || c == '–' || c == '—' || c.is_whitespace()).to_string(); + } + institution = inst_str; + i += 1; + } + } + + if institution.is_empty() { + institution = if !clean_rem.is_empty() { clean_rem } else { "Educational Institution".to_string() }; + } + + let formatted_degree = if deg.len() <= 4 && !deg.contains(' ') { + format!("{} ({})", degree_category, deg) + } else { + deg + }; + + education_entries.push(Education { + degree: formatted_degree, + institution, + year, + }); + } + + i += 1; + if education_entries.len() >= 3 { + break; + } + } + + education_entries +} + #[cfg(test)] mod tests { use super::*; @@ -428,4 +570,52 @@ Bachelor of Science in Computer Science, Stanford University assert!(!analysis.summary.is_empty()); assert!(!analysis.strengths.is_empty()); } + + #[test] + fn test_extract_education_no_false_cms_positives() { + let resume_text = r#" +KISHORE KUMAR +Software Engineer + +EXPERIENCE +Software Engineer — Apparel Group — 6thStreet.com +● Delivered 20+ features for 6thStreet's React Native app. +● Managed CMS integration, enabling dynamic and seamless content updates without redeploys. +● Integrated AWS Secrets Manager for secure credential management. + +EDUCATION +Bachelor of Engineering +Sri Shakthi Institute of Engineering and Technology + "#; + + let edu = extract_education_from_text(resume_text); + assert_eq!(edu.len(), 1); + assert_eq!(edu[0].degree, "Bachelor of Engineering"); + assert_eq!(edu[0].institution, "Sri Shakthi Institute of Engineering and Technology"); + + // Verify CMS / AWS was not extracted as a degree + for e in &edu { + assert!(!e.degree.contains("CMS")); + assert!(!e.institution.contains("CMS")); + assert_ne!(e.degree, "MS"); + } + } + + #[test] + fn test_extract_education_multiple_real_degrees() { + let resume_text = r#" +EDUCATION +Master of Science in Computer Science, Stanford University, 2021 +Bachelor of Technology, MIT, 2019 + "#; + + let edu = extract_education_from_text(resume_text); + assert_eq!(edu.len(), 2); + assert!(edu[0].degree.contains("Master")); + assert_eq!(edu[0].institution, "Stanford University"); + assert_eq!(edu[0].year, Some("2021".to_string())); + assert!(edu[1].degree.contains("Bachelor")); + assert_eq!(edu[1].institution, "MIT"); + assert_eq!(edu[1].year, Some("2019".to_string())); + } } diff --git a/src-tauri/src/llm/model_manager.rs b/src-tauri/src/llm/model_manager.rs index 96a8ce2..36c12de 100644 --- a/src-tauri/src/llm/model_manager.rs +++ b/src-tauri/src/llm/model_manager.rs @@ -233,8 +233,6 @@ pub async fn perform_model_download( )); } - app.emit("model-download-complete", serde_json::json!({ "model_id": model.id })).ok(); - Ok(()) } diff --git a/src-tauri/src/processing/parser/pdf.rs b/src-tauri/src/processing/parser/pdf.rs index 6658d48..d39ac9b 100644 --- a/src-tauri/src/processing/parser/pdf.rs +++ b/src-tauri/src/processing/parser/pdf.rs @@ -1,26 +1,208 @@ use std::path::Path; -/// Extracts text from all pages of a PDF. -/// -/// Text extracted from each page is separated by a newline. Pages whose text -/// cannot be extracted are skipped. +/// Normalizes raw extracted PDF text, reconstructing fragmented words and artificial line breaks +/// into clean paragraphs, distinct section headers, and formatted bullet points. +pub fn normalize_extracted_text(raw: &str) -> String { + let tokens: Vec<&str> = raw.split_whitespace().collect(); + if tokens.is_empty() { + return String::new(); + } + + const MULTI_WORD_SECTIONS: &[&str] = &[ + "PROFESSIONAL SUMMARY", + "EXECUTIVE SUMMARY", + "TECHNICAL SKILLS", + "SKILLS & ABILITIES", + "CORE COMPETENCIES", + "PROFESSIONAL EXPERIENCE", + "WORK EXPERIENCE", + "EMPLOYMENT HISTORY", + "CAREER HISTORY", + "KEY PROJECTS", + "PERSONAL PROJECTS", + "ACADEMIC BACKGROUND", + "CERTIFICATIONS & LICENSES", + ]; + + const SINGLE_WORD_SECTIONS: &[&str] = &[ + "SUMMARY", + "PROFILE", + "SKILLS", + "EXPERIENCE", + "PROJECTS", + "EDUCATION", + "CERTIFICATIONS", + "ACHIEVEMENTS", + "AWARDS", + "PUBLICATIONS", + "LANGUAGES", + "INTERESTS", + "VOLUNTEERING", + ]; + + const ROLE_PREFIXES: &[&str] = &[ + "Software", "Senior", "Lead", "Product", "Frontend", "Backend", + "Full-Stack", "Full", "Staff", "Principal", "Junior", "Head" + ]; + + const ROLE_SUFFIXES: &[&str] = &[ + "Engineer", "Developer", "Architect", "Designer", "Manager", "Development", "Director" + ]; + + const DEGREE_OPENERS: &[&str] = &[ + "Bachelor", "Master", "B.E.", "B.Tech", "B.S.", "M.S.", "M.Tech", "Ph.D", "PhD", "MBA", "MCA" + ]; + + let mut lines: Vec = Vec::new(); + let mut current_line: Vec<&str> = Vec::new(); + + let mut i = 0; + while i < tokens.len() { + let token = tokens[i]; + let next_token = if i + 1 < tokens.len() { tokens[i + 1] } else { "" }; + let next2_token = if i + 2 < tokens.len() { tokens[i + 2] } else { "" }; + + let candidate3 = format!("{} {} {}", token, next_token, next2_token).to_uppercase(); + let candidate2 = format!("{} {}", token, next_token).to_uppercase(); + let candidate1 = token.to_uppercase(); + + let upper1 = is_all_uppercase_token(token); + let upper2 = upper1 && is_all_uppercase_token(next_token); + let upper3 = upper2 && is_all_uppercase_token(next2_token); + + let mut matched_header: Option = None; + let mut header_tokens_count = 0; + + if upper3 && MULTI_WORD_SECTIONS.contains(&candidate3.as_str()) { + matched_header = Some(candidate3); + header_tokens_count = 3; + } else if upper2 && MULTI_WORD_SECTIONS.contains(&candidate2.as_str()) { + matched_header = Some(candidate2); + header_tokens_count = 2; + } else if upper1 + && SINGLE_WORD_SECTIONS.contains(&candidate1.as_str()) + && next_token != "&" + && next_token != "and" + && !next_token.ends_with(':') + && !token.ends_with(':') + { + matched_header = Some(candidate1); + header_tokens_count = 1; + } + + if let Some(header) = matched_header { + if !current_line.is_empty() { + lines.push(current_line.join(" ")); + current_line.clear(); + } + lines.push(String::new()); + lines.push(header); + lines.push(String::new()); + i += header_tokens_count; + continue; + } + + if is_bullet_symbol(token) { + if !current_line.is_empty() { + lines.push(current_line.join(" ")); + current_line.clear(); + } + current_line.push("●"); + i += 1; + continue; + } + + let is_role_start = ROLE_PREFIXES.contains(&token) && ROLE_SUFFIXES.contains(&next_token); + let is_edu_start = DEGREE_OPENERS.contains(&token) + || (token == "Bachelor" && next_token == "of") + || (token == "Master" && next_token == "of"); + + if (is_role_start || is_edu_start) && !current_line.is_empty() && current_line.contains(&"●") { + lines.push(current_line.join(" ")); + current_line.clear(); + lines.push(String::new()); + } + + current_line.push(token); + + let is_date_end = (token == "Present" + || token == "Current" + || is_year(token)) + && next_token != "–" + && next_token != "-" + && next_token != "to" + && next_token != "Present" + && !is_year(next_token); + + if is_date_end + && current_line.iter().any(|&w| w == "—" || w == "-" || w == "–") + && !current_line.contains(&"●") + && current_line.len() >= 4 + { + lines.push(current_line.join(" ")); + current_line.clear(); + } + + i += 1; + } + + if !current_line.is_empty() { + lines.push(current_line.join(" ")); + } + + let mut cleaned = Vec::new(); + let mut prev_blank = false; + for line in lines { + let trimmed = line.trim(); + if trimmed.is_empty() { + if !prev_blank && !cleaned.is_empty() { + cleaned.push(String::new()); + prev_blank = true; + } + } else { + cleaned.push(trimmed.to_string()); + prev_blank = false; + } + } + + cleaned.join("\n") +} + +fn is_all_uppercase_token(t: &str) -> bool { + let chars: Vec = t.chars().filter(|c| c.is_alphabetic()).collect(); + !chars.is_empty() && chars.iter().all(|c| c.is_uppercase()) +} + +fn is_bullet_symbol(token: &str) -> bool { + token == "•" + || token == "●" + || token == "▪" + || token == "▫" + || token == "*" + || token == "\u{2022}" + || token == "\u{2023}" + || token == "\u{25E6}" + || token == "\u{2043}" + || token == "\u{2219}" + || (token.len() >= 2 + && token.len() <= 3 + && token.chars().take(token.len().saturating_sub(1)).all(|c| c.is_ascii_digit()) + && (token.ends_with('.') || token.ends_with(')'))) +} + +fn is_year(s: &str) -> bool { + s.len() == 4 && (s.starts_with("19") || s.starts_with("20")) && s.chars().all(|c| c.is_ascii_digit()) +} + +/// Extracts text from all pages of a PDF and returns normalized, cleanly wrapped text. /// /// # Returns /// -/// The combined extracted text. +/// The combined extracted text with preserved headers and lists. /// /// # Errors /// -/// Returns an error if the PDF cannot be loaded or contains no extractable -/// text. -/// -/// # Examples -/// -/// ``` -/// use hirelens_lib::processing::parser::pdf::extract_pdf_text; -/// let result = extract_pdf_text("missing.pdf"); -/// assert!(result.is_err()); -/// ``` +/// Returns an error if the PDF cannot be loaded or contains no extractable text. pub fn extract_pdf_text>(path: P) -> Result { let doc = lopdf::Document::load(path).map_err(|e| format!("Failed to load PDF: {}", e))?; let mut extracted_text = String::new(); @@ -37,7 +219,12 @@ pub fn extract_pdf_text>(path: P) -> Result { return Err("PDF appears to be scanned or contains no extractable text layer.".to_string()); } - Ok(extracted_text) + let normalized = normalize_extracted_text(&extracted_text); + if normalized.trim().is_empty() { + return Err("PDF appears to be scanned or contains no extractable text layer.".to_string()); + } + + Ok(normalized) } #[cfg(test)] @@ -49,5 +236,19 @@ mod tests { let res = extract_pdf_text("/nonexistent/file/path.pdf"); assert!(res.is_err()); } + + #[test] + fn test_normalize_extracted_text_single_words() { + let raw = "Experienced\n \nFull-Stack\n \nDeveloper\n \nwith\n \nexpertise\n \nin\n \nReact\n \nand\n \nRust."; + let normalized = normalize_extracted_text(raw); + assert_eq!(normalized, "Experienced Full-Stack Developer with expertise in React and Rust."); + } + + #[test] + fn test_normalize_extracted_text_bullets_and_headers() { + let raw = "PROFESSIONAL EXPERIENCE\n \n●\n \nDesigned\n \nand\n \nbuilt\n \nAPIs.\n \n●\n \nManaged\n \nsystems."; + let normalized = normalize_extracted_text(raw); + assert!(normalized.contains("PROFESSIONAL EXPERIENCE\n\n● Designed and built APIs.\n● Managed systems.")); + } } diff --git a/src/App.tsx b/src/App.tsx index 29d2582..7ed3c25 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,23 +14,42 @@ import { useSettingsStore } from './stores/useSettingsStore'; import { api } from './lib/tauri'; import { CandidateAnalysisCompleteEvent } from './types/processing'; +interface ModelDownloadProgressPayload { + model_id: string; + downloaded_bytes: number; + total_bytes: number; + speed_bps: number; +} + +interface ModelDownloadCompletePayload { + model_id: string; +} + +interface ModelDownloadErrorPayload { + model_id: string; + error: string; +} + export function App() { const [onboardingCompleted, setOnboardingCompleted] = useState(null); - const { handleAnalysisComplete } = useCandidateStore(); - const { setDownloadProgress, fetchModels } = useSettingsStore(); + const handleAnalysisComplete = useCandidateStore((s) => s.handleAnalysisComplete); + const setDownloadProgress = useSettingsStore((s) => s.setDownloadProgress); + const setDownloadError = useSettingsStore((s) => s.setDownloadError); + const fetchModels = useSettingsStore((s) => s.fetchModels); useEffect(() => { // Check onboarding status from settings api.settings.getAll().then((settings) => { setOnboardingCompleted(settings.onboarding_completed === 'true'); }).catch(() => { - setOnboardingCompleted(true); + setOnboardingCompleted(false); }); // Tauri Event Listeners let unlistenAnalysis: (() => void) | undefined; let unlistenProgress: (() => void) | undefined; let unlistenComplete: (() => void) | undefined; + let unlistenError: (() => void) | undefined; listen('candidate-analysis-complete', (event) => { handleAnalysisComplete(event.payload); @@ -38,7 +57,7 @@ export function App() { unlistenAnalysis = unlisten; }); - listen('model-download-progress', (event) => { + listen('model-download-progress', (event) => { const { model_id, downloaded_bytes, total_bytes, speed_bps } = event.payload; setDownloadProgress({ modelId: model_id, @@ -50,19 +69,30 @@ export function App() { unlistenProgress = unlisten; }); - listen('model-download-complete', () => { + listen('model-download-complete', () => { setDownloadProgress(null); + setDownloadError(null); fetchModels(); }).then((unlisten) => { unlistenComplete = unlisten; }); + listen('model-download-error', (event) => { + const { model_id, error } = event.payload; + setDownloadProgress(null); + setDownloadError({ modelId: model_id, message: error || 'Model download failed' }); + fetchModels(); + }).then((unlisten) => { + unlistenError = unlisten; + }); + return () => { if (unlistenAnalysis) unlistenAnalysis(); if (unlistenProgress) unlistenProgress(); if (unlistenComplete) unlistenComplete(); + if (unlistenError) unlistenError(); }; - }, [handleAnalysisComplete, setDownloadProgress, fetchModels]); + }, [handleAnalysisComplete, setDownloadProgress, setDownloadError, fetchModels]); if (onboardingCompleted === null) { return ( diff --git a/src/components/candidates/CandidateDetail.tsx b/src/components/candidates/CandidateDetail.tsx index ccccd1c..cec4f76 100644 --- a/src/components/candidates/CandidateDetail.tsx +++ b/src/components/candidates/CandidateDetail.tsx @@ -1,6 +1,5 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { openPath } from '@tauri-apps/plugin-opener'; import { ArrowLeft, Mail, @@ -14,6 +13,11 @@ import { X, RotateCcw, FileWarning, + Copy, + AlertCircle, + Eye, + Code, + Loader2, } from 'lucide-react'; import { CandidateDetail as CandidateDetailType } from '../../types/candidate'; import { ScoreBreakdown } from './ScoreBreakdown'; @@ -22,7 +26,8 @@ import { SkillMatchBadge } from './SkillMatchBadge'; import { Badge } from '../ui/Badge'; import { Button } from '../ui/Button'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card'; -import { getScoreColor } from '../../lib/utils'; +import { getScoreColor, formatResumeText } from '../../lib/utils'; +import { api } from '../../lib/tauri'; interface CandidateDetailProps { candidate: CandidateDetailType; @@ -40,7 +45,16 @@ interface CandidateDetailProps { export function CandidateDetail({ candidate, jobId, onUpdateStatus }: CandidateDetailProps) { const navigate = useNavigate(); const [activeTab, setActiveTab] = useState<'analysis' | 'resume'>('analysis'); + const [resumeViewMode, setResumeViewMode] = useState<'formatted' | 'raw'>('formatted'); const [notes, setNotes] = useState(candidate.shortlistNotes || ''); + const [isOpeningFile, setIsOpeningFile] = useState(false); + const [openFileError, setOpenFileError] = useState(null); + const [isCopiedPath, setIsCopiedPath] = useState(false); + const [isCopiedText, setIsCopiedText] = useState(false); + + const formattedResumeText = useMemo(() => { + return candidate.rawText ? formatResumeText(candidate.rawText) : ''; + }, [candidate.rawText]); const analysis = candidate.analysis; const scoreColors = analysis ? getScoreColor(analysis.scores.overallScore) : null; @@ -58,10 +72,46 @@ export function CandidateDetail({ candidate, jobId, onUpdateStatus }: CandidateD }; const handleOpenOriginalFile = async () => { + if (!candidate.filePath) { + setOpenFileError('No local file path recorded for this candidate.'); + setTimeout(() => setOpenFileError(null), 5000); + return; + } + setIsOpeningFile(true); + setOpenFileError(null); + try { + await api.system.openPath(candidate.filePath); + } catch (err: any) { + const msg = typeof err === 'string' ? err : err?.message || 'Failed to open original file'; + setOpenFileError(msg); + setTimeout(() => setOpenFileError(null), 6000); + } finally { + setIsOpeningFile(false); + } + }; + + const handleCopyFilePath = async () => { + if (!candidate.filePath) return; + try { + await navigator.clipboard.writeText(candidate.filePath); + setIsCopiedPath(true); + setTimeout(() => setIsCopiedPath(false), 2000); + } catch { + setOpenFileError('Failed to copy file path to clipboard'); + setTimeout(() => setOpenFileError(null), 5000); + } + }; + + const handleCopyResumeText = async () => { + const textToCopy = resumeViewMode === 'formatted' ? formattedResumeText : candidate.rawText || ''; + if (!textToCopy) return; try { - await openPath(candidate.filePath); + await navigator.clipboard.writeText(textToCopy); + setIsCopiedText(true); + setTimeout(() => setIsCopiedText(false), 2000); } catch { - // Ignore + setOpenFileError('Failed to copy resume text to clipboard'); + setTimeout(() => setOpenFileError(null), 5000); } }; @@ -356,26 +406,126 @@ export function CandidateDetail({ candidate, jobId, onUpdateStatus }: CandidateD ) : ( /* Resume Text Tab */ - - - - {candidate.fileName} - - - - -
-              {candidate.rawText || 'No text extracted for this resume.'}
-            
-
-
+
+ {openFileError && ( +
+ +
+ Unable to open original file: + {openFileError} +
+
+ )} + + + +
+ + + {candidate.fileName} + + {candidate.filePath && ( +
+ {candidate.filePath} + +
+ )} +
+ +
+ {/* View Mode Toggle */} +
+ + +
+ + {/* Copy Text Button */} + + + {/* Open Original File Button */} + +
+
+ + + {resumeViewMode === 'formatted' ? ( +
+ {formattedResumeText ? ( + formattedResumeText + ) : ( +

No text extracted for this resume.

+ )} +
+ ) : ( +
+                  {candidate.rawText || 'No text extracted for this resume.'}
+                
+ )} +
+
+
)} ); diff --git a/src/components/onboarding/ModelDownloadStep.tsx b/src/components/onboarding/ModelDownloadStep.tsx index 245e710..15044ba 100644 --- a/src/components/onboarding/ModelDownloadStep.tsx +++ b/src/components/onboarding/ModelDownloadStep.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { CheckCircle2, Download, HardDrive, Sparkles, Loader2 } from 'lucide-react'; +import { CheckCircle2, Download, HardDrive, Sparkles, Loader2, AlertCircle, XCircle, ArrowRight } from 'lucide-react'; import { Button } from '../ui/Button'; import { Card, CardContent } from '../ui/Card'; import { Badge } from '../ui/Badge'; @@ -18,16 +18,17 @@ export function ModelDownloadStep({ onComplete }: ModelDownloadStepProps) { models, systemInfo, downloadProgress, + downloadError, fetchModels, fetchSystemInfo, downloadModel, + cancelModelDownload, setActiveModel, saveSetting, } = useSettingsStore(); const [selectedTier, setSelectedTier] = useState('balanced'); - const [isDownloading, setIsDownloading] = useState(false); - const [isDownloaded, setIsDownloaded] = useState(false); + const [isStartingDownload, setIsStartingDownload] = useState(false); useEffect(() => { fetchModels(); @@ -42,24 +43,59 @@ export function ModelDownloadStep({ onComplete }: ModelDownloadStepProps) { const selectedModel = models.find((m) => m.tier === selectedTier) || models[0]; + const isDownloading = + isStartingDownload || + (!!selectedModel && + (selectedModel.status === 'downloading' || + (!!downloadProgress?.modelId && downloadProgress.modelId === selectedModel.id))); + + const isDownloaded = selectedModel?.status === 'downloaded'; + + // Automatically activate when download completes + useEffect(() => { + if (selectedModel?.status === 'downloaded' && !selectedModel.isActive) { + setActiveModel(selectedModel.id); + } + }, [selectedModel?.status, selectedModel?.id, selectedModel?.isActive, setActiveModel]); + const handleStartDownload = async () => { if (!selectedModel) return; - setIsDownloading(true); + setIsStartingDownload(true); try { await downloadModel(selectedModel.id); - await setActiveModel(selectedModel.id); - setIsDownloaded(true); - setIsDownloading(false); - } catch { - setIsDownloading(false); + } finally { + setIsStartingDownload(false); } }; + const handleCancel = async () => { + if (!selectedModel) return; + setIsStartingDownload(false); + await cancelModelDownload(selectedModel.id); + }; + const handleFinish = async () => { + if (selectedModel) { + await setActiveModel(selectedModel.id); + } + await saveSetting('onboarding_completed', 'true'); + onComplete(); + }; + + const handleSkipForNow = async () => { await saveSetting('onboarding_completed', 'true'); onComplete(); }; + const currentDownloadForSelected = + selectedModel && downloadProgress?.modelId === selectedModel.id ? downloadProgress : null; + + const percent = currentDownloadForSelected && currentDownloadForSelected.total > 0 + ? Math.min(100, Math.round((currentDownloadForSelected.downloaded / currentDownloadForSelected.total) * 100)) + : null; + + const hasError = selectedModel && downloadError?.modelId === selectedModel.id ? downloadError.message : null; + return (
@@ -70,36 +106,57 @@ export function ModelDownloadStep({ onComplete }: ModelDownloadStepProps) { Choose your Local AI Model

- HireLens uses local weights to ensure candidate data privacy. Select the tier best suited for your computer. + HireLens runs open weights on your machine for complete candidate privacy. Select the tier best suited for your computer.

{/* Model Selection Cards */} -
+
{(['fast', 'balanced', 'quality'] as ModelTier[]).map((tier) => { const config = MODEL_TIER_CONFIG[tier]; + const model = models.find((m) => m.tier === tier); const isRecommended = systemInfo?.recommendedModelTier === tier; const isSelected = selectedTier === tier; + const isTierDownloaded = model?.status === 'downloaded'; + const isTierDownloading = + !!model && (model.status === 'downloading' || downloadProgress?.modelId === model.id); return ( !isDownloading && !isDownloaded && setSelectedTier(tier)} + role="radio" + aria-checked={isSelected} + tabIndex={isDownloading ? -1 : 0} + onClick={() => !isDownloading && setSelectedTier(tier)} + onKeyDown={(e) => { + if (!isDownloading && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + setSelectedTier(tier); + } + }} className={cn( - 'cursor-pointer transition-all relative border-2', + 'cursor-pointer transition-all relative border-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2', isSelected ? 'border-indigo-600 bg-indigo-50/20 shadow-sm' - : 'border-slate-200/80 hover:border-slate-300' + : 'border-slate-200/80 hover:border-slate-300', + isDownloading && !isSelected && 'opacity-60 cursor-not-allowed' )} >
{config.label} - {isRecommended && ( - - Recommended - - )} +
+ {isRecommended && ( + + Recommended + + )} + {isTierDownloaded && ( + + Downloaded + + )} +
@@ -110,33 +167,71 @@ export function ModelDownloadStep({ onComplete }: ModelDownloadStepProps) {

{config.notes}

+ + {isTierDownloading && ( +
+ + Downloading... +
+ )} ); })}
- {/* Progress & Action */} -
+ {/* Error Message Banner */} + {hasError && ( +
+ +
+ Download error: + {hasError} +
+
+ )} + + {/* Progress & Actions */} +
{isDownloading && ( -
-
+
+
- Downloading {selectedModel?.displayName}... + Downloading {selectedModel?.displayName || 'model'}... - - {downloadProgress - ? `${((downloadProgress.downloaded / (downloadProgress.total || 1)) * 100).toFixed(0)}%` - : 'Starting...'} + + {percent !== null ? `${percent}%` : 'Connecting...'}
+ -
- {downloadProgress ? formatBytes(downloadProgress.downloaded) : '0 MB'} - {downloadProgress ? `${formatBytes(downloadProgress.speedBps)}/s` : 'Connecting...'} + +
+ + {currentDownloadForSelected + ? `${formatBytes(currentDownloadForSelected.downloaded)} / ${formatBytes(currentDownloadForSelected.total)}` + : 'Preparing stream...'} + + + {currentDownloadForSelected && currentDownloadForSelected.speedBps > 0 + ? `${formatBytes(currentDownloadForSelected.speedBps)}/s` + : 'Connecting...'} + +
+ +
+
)} @@ -144,23 +239,32 @@ export function ModelDownloadStep({ onComplete }: ModelDownloadStepProps) { {isDownloaded ? (
- Model configured successfully + Model configured and ready
-
- ) : ( - - )} + ) : !isDownloading ? ( +
+ + + +
+ ) : null}
); diff --git a/src/components/processing/DropZone.tsx b/src/components/processing/DropZone.tsx index e89a5ac..4b7410f 100644 --- a/src/components/processing/DropZone.tsx +++ b/src/components/processing/DropZone.tsx @@ -1,5 +1,6 @@ -import React, { useState, useRef } from 'react'; -import { UploadCloud, FileText, Loader2, AlertCircle } from 'lucide-react'; +import React, { useState, useRef, useEffect } from 'react'; +import { UploadCloud, FileText, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { open } from '@tauri-apps/plugin-dialog'; import { api } from '../../lib/tauri'; import { DuplicateResumeInfo } from '../../types/processing'; import { Button } from '../ui/Button'; @@ -27,31 +28,60 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [uploadSuccessCount, setUploadSuccessCount] = useState(null); const [duplicateCandidates, setDuplicateCandidates] = useState([]); const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false); const fileInputRef = useRef(null); + const successTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + } + }; + }, []); + + const triggerSuccess = (count: number) => { + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + } + setUploadSuccessCount(count); + successTimeoutRef.current = setTimeout(() => { + setUploadSuccessCount(null); + successTimeoutRef.current = null; + }, 4000); + }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); - setIsDragging(true); + e.stopPropagation(); + if (!isUploading) { + setIsDragging(true); + } }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); + e.stopPropagation(); setIsDragging(false); }; const handleDrop = async (e: React.DragEvent) => { e.preventDefault(); + e.stopPropagation(); setIsDragging(false); + if (isUploading) return; + setErrorMessage(null); + setUploadSuccessCount(null); const files = Array.from(e.dataTransfer.files); await processFiles(files); }; const handleFileChange = async (e: React.ChangeEvent) => { - if (!e.target.files) return; + if (!e.target.files || isUploading) return; const files = Array.from(e.target.files); await processFiles(files); if (fileInputRef.current) { @@ -62,8 +92,10 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { const executeUpload = async (paths: string[]) => { if (paths.length === 0) return; setIsUploading(true); + setErrorMessage(null); try { await api.resumes.upload(jobId, paths); + triggerSuccess(paths.length); if (onUploaded) onUploaded(); } catch (err: any) { setErrorMessage(err?.toString() || 'Failed to upload resumes'); @@ -76,6 +108,7 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { if (filePaths.length === 0) return; setIsUploading(true); setErrorMessage(null); + setUploadSuccessCount(null); try { // Check if any files already exist for this job (same name and size) @@ -91,6 +124,7 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { // No duplicates detected, proceed directly with upload await api.resumes.upload(jobId, filePaths); + triggerSuccess(filePaths.length); if (onUploaded) onUploaded(); } catch (err: any) { setErrorMessage(err?.toString() || 'Failed to process resumes'); @@ -114,7 +148,6 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { } try { - // In Tauri webview, path is available on File object in desktop mode, or we can use dialog plugin const filePaths: string[] = []; for (const file of validFiles) { // @ts-expect-error Tauri attaches path property to dropped/selected Files @@ -127,20 +160,7 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { if (filePaths.length > 0) { await initiateUploadWithDuplicateCheck(filePaths); } else { - // Fallback for file picker when path is missing - try { - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ - multiple: true, - filters: [{ name: 'Resumes', extensions: ['pdf', 'docx', 'doc'] }], - }); - if (selected) { - const paths = Array.isArray(selected) ? selected : [selected]; - await initiateUploadWithDuplicateCheck(paths); - } - } catch { - setErrorMessage('Failed to resolve file paths. Please use the Browse button.'); - } + setErrorMessage('Could not determine local file path. Please use Browse Files to select resumes.'); } } catch (err: any) { setErrorMessage(err?.toString() || 'Failed to upload resumes'); @@ -148,21 +168,30 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { }; const handleBrowseClick = async () => { + if (isUploading) return; + setErrorMessage(null); + setUploadSuccessCount(null); + try { - const { open } = await import('@tauri-apps/plugin-dialog'); const selected = await open({ multiple: true, - filters: [{ name: 'Resumes', extensions: ['pdf', 'docx', 'doc'] }], + title: 'Select Resumes to Upload', + filters: [ + { + name: 'Resume Documents (*.pdf, *.docx, *.doc)', + extensions: ['pdf', 'docx', 'doc'], + }, + ], }); + if (selected) { const paths = Array.isArray(selected) ? selected : [selected]; - await initiateUploadWithDuplicateCheck(paths); - } - } catch { - // Fallback to HTML input - if (fileInputRef.current) { - fileInputRef.current.click(); + if (paths.length > 0) { + await initiateUploadWithDuplicateCheck(paths); + } } + } catch (err: any) { + setErrorMessage(err?.toString() || 'Failed to open native file picker'); } }; @@ -194,10 +223,11 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - className={`border-2 border-dashed rounded-xl p-6 text-center transition-all ${ + onClick={handleBrowseClick} + className={`border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer select-none ${ isDragging - ? 'border-indigo-500 bg-indigo-50/40 scale-[1.01]' - : 'border-slate-200/90 bg-slate-50/50 hover:bg-slate-50' + ? 'border-indigo-500 bg-indigo-50/50 scale-[1.01]' + : 'border-slate-200/90 bg-slate-50/50 hover:bg-slate-50 hover:border-indigo-300' }`} > {isUploading ? ( + ) : uploadSuccessCount !== null ? ( + ) : ( )} @@ -220,7 +252,11 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) {

- Drag & drop resumes here + {isUploading + ? 'Uploading resumes to queue...' + : uploadSuccessCount !== null + ? `Uploaded ${uploadSuccessCount} resume${uploadSuccessCount > 1 ? 's' : ''} successfully` + : 'Drag & drop resumes or click to browse'}

Supports batch PDF & DOCX resumes @@ -231,9 +267,12 @@ export function DropZone({ jobId, onUploaded }: DropZoneProps) { type="button" variant="outline" size="sm" - onClick={handleBrowseClick} + onClick={(e) => { + e.stopPropagation(); + handleBrowseClick(); + }} disabled={isUploading} - className="text-xs gap-1.5" + className="text-xs gap-1.5 cursor-pointer" > Browse Files diff --git a/src/components/settings/ModelSelector.tsx b/src/components/settings/ModelSelector.tsx index d7fff7d..34cb207 100644 --- a/src/components/settings/ModelSelector.tsx +++ b/src/components/settings/ModelSelector.tsx @@ -1,5 +1,4 @@ -import { useState } from 'react'; -import { HardDrive, CheckCircle2, Download, Loader2 } from 'lucide-react'; +import { HardDrive, CheckCircle2, Download, Loader2, XCircle, AlertCircle } from 'lucide-react'; import { useSettingsStore } from '../../stores/useSettingsStore'; import { MODEL_TIER_CONFIG } from '../../lib/constants'; import { Card, CardContent } from '../ui/Card'; @@ -14,25 +13,28 @@ export function ModelSelector() { models, systemInfo, downloadProgress, + downloadError, downloadModel, + cancelModelDownload, setActiveModel, } = useSettingsStore(); - const [downloadingTier, setDownloadingTier] = useState(null); + const handleDownload = async (modelId: string) => { + await downloadModel(modelId); + }; - const handleDownload = async (modelId: string, tier: string) => { - setDownloadingTier(tier); - try { - await downloadModel(modelId); - } finally { - setDownloadingTier(null); - } + const handleCancel = async (modelId: string) => { + await cancelModelDownload(modelId); }; const handleActivate = async (modelId: string) => { await setActiveModel(modelId); }; + const isAnyModelDownloading = models.some( + (m) => m.status === 'downloading' || (!!downloadProgress?.modelId && downloadProgress.modelId === m.id) + ); + return (

@@ -42,64 +44,115 @@ export function ModelSelector() { const isRecommended = systemInfo?.recommendedModelTier === tier; const isActive = model?.isActive; const isDownloaded = model?.status === 'downloaded'; - const isCurrentDownloading = downloadingTier === tier; + const isCurrentDownloading = + !!model && (model.status === 'downloading' || downloadProgress?.modelId === model.id); + const currentProgress = + model && downloadProgress?.modelId === model.id ? downloadProgress : null; + const modelError = + model && downloadError?.modelId === model.id ? downloadError.message : null; + + const percent = + currentProgress && currentProgress.total > 0 + ? Math.min( + 100, + Math.round( + (currentProgress.downloaded / currentProgress.total) * 100 + ) + ) + : null; return ( - -
- {config.label} -
- {isRecommended && ( - - Recommended - - )} - {isActive && ( - - Active - - )} + +
+
+ {config.label} +
+ {isRecommended && ( + + Recommended + + )} + {isActive && ( + + Active + + )} +
-
-
- - {config.size} -
+
+ + {config.size} +
-

- {config.notes} -

+

+ {config.notes} +

- {isCurrentDownloading && downloadProgress && ( -
- -
- {formatBytes(downloadProgress.downloaded)} - {formatBytes(downloadProgress.speedBps)}/s + {/* Live Download Progress Box */} + {isCurrentDownloading && ( +
+
+ + + Downloading... + + + {percent !== null ? `${percent}%` : 'Starting...'} + +
+ +
+ + {currentProgress + ? `${formatBytes(currentProgress.downloaded)} / ${formatBytes(currentProgress.total)}` + : 'Connecting...'} + + + {currentProgress && currentProgress.speedBps > 0 + ? `${formatBytes(currentProgress.speedBps)}/s` + : '—'} + +
-
- )} + )} + + {/* Error Notification */} + {modelError && ( +
+ + {modelError} +
+ )} +
{isActive ? (
Currently Active
+ ) : isCurrentDownloading ? ( + ) : isDownloaded ? ( )}
diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 537bcfe..1f3bc8f 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -38,6 +38,18 @@ export const api = { }, system: { getInfo: () => invoke('get_system_info'), + openPath: async (path: string): Promise => { + try { + await invoke('open_file_path', { path }); + } catch (backendErr: any) { + try { + const { openPath: pluginOpen } = await import('@tauri-apps/plugin-opener'); + await pluginOpen(path); + } catch { + throw backendErr; + } + } + }, }, settings: { getAll: () => invoke>('get_settings'), diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 89e89d4..49096e7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -54,3 +54,189 @@ export function getScoreColor(score: number) { bar: 'bg-rose-500', }; } + +/** + * Formats and normalizes resume text, reconstructing fragmented words and artificial line breaks + * into clean paragraphs, distinct section headers, and formatted bullet points. + * + * @param raw - The raw resume text string + * @returns Cleanly normalized, wrapped resume text + */ +export function formatResumeText(raw: string): string { + if (!raw) return ''; + + const tokens = raw + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + if (tokens.length === 0) return ''; + + const isBulletSymbol = (t: string) => + /^[•●▪▫\*\u2022\u2023\u25E6\u2043\u2219]$/.test(t) || + /^\d{1,2}[\.\)]$/.test(t) || + /^\(\d{1,2}\)$/.test(t); + + const KNOWN_SECTIONS_SET = new Set([ + 'PROFESSIONAL SUMMARY', + 'EXECUTIVE SUMMARY', + 'SUMMARY', + 'PROFILE', + 'TECHNICAL SKILLS', + 'SKILLS & ABILITIES', + 'SKILLS', + 'CORE COMPETENCIES', + 'PROFESSIONAL EXPERIENCE', + 'WORK EXPERIENCE', + 'EXPERIENCE', + 'EMPLOYMENT HISTORY', + 'CAREER HISTORY', + 'KEY PROJECTS', + 'PERSONAL PROJECTS', + 'PROJECTS', + 'EDUCATION', + 'ACADEMIC BACKGROUND', + 'QUALIFICATIONS', + 'CERTIFICATIONS & LICENSES', + 'CERTIFICATIONS', + 'ACHIEVEMENTS', + 'AWARDS', + 'PUBLICATIONS', + 'LANGUAGES', + 'INTERESTS', + 'VOLUNTEER EXPERIENCE', + 'VOLUNTEERING', + ]); + + const lines: string[] = []; + let currentLine: string[] = []; + + const flushLine = () => { + if (currentLine.length > 0) { + lines.push(currentLine.join(' ')); + currentLine = []; + } + }; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const nextToken = i + 1 < tokens.length ? tokens[i + 1] : ''; + const next2Token = i + 2 < tokens.length ? tokens[i + 2] : ''; + + const candidate3 = `${token} ${nextToken} ${next2Token}`.toUpperCase(); + const candidate2 = `${token} ${nextToken}`.toUpperCase(); + const candidate1 = token.toUpperCase(); + + let matchedHeader: string | null = null; + let headerTokensCount = 0; + + const isUpper = (t: string) => t === t.toUpperCase() && /[A-Z]/.test(t); + const upperRun1 = isUpper(token); + const upperRun2 = upperRun1 && isUpper(nextToken); + const upperRun3 = upperRun2 && isUpper(next2Token); + + if (upperRun3 && KNOWN_SECTIONS_SET.has(candidate3)) { + matchedHeader = candidate3; + headerTokensCount = 3; + } else if (upperRun2 && KNOWN_SECTIONS_SET.has(candidate2)) { + matchedHeader = candidate2; + headerTokensCount = 2; + } else if ( + upperRun1 && + KNOWN_SECTIONS_SET.has(candidate1) && + nextToken !== '&' && + nextToken !== 'and' && + !nextToken.endsWith(':') && + !token.endsWith(':') + ) { + matchedHeader = candidate1; + headerTokensCount = 1; + } + + if (matchedHeader) { + flushLine(); + lines.push(''); + lines.push(matchedHeader); + lines.push(''); + i += headerTokensCount - 1; + continue; + } + + // If token is a bullet symbol (●, •, etc.) + if (isBulletSymbol(token)) { + flushLine(); + currentLine.push('●'); + continue; + } + + // Role / Project title boundary checks: + const isRoleStart = + (token === 'Software' || + token === 'Senior' || + token === 'Lead' || + token === 'Product' || + token === 'Frontend' || + token === 'Backend' || + token === 'Full-Stack' || + token === 'Staff' || + token === 'Principal') && + (nextToken === 'Engineer' || + nextToken === 'Developer' || + nextToken === 'Architect' || + nextToken === 'Designer' || + nextToken === 'Manager' || + nextToken === 'Development'); + + const isProjectOrEduStart = + (token === 'Bachelor' && nextToken === 'of') || + (token === 'Master' && nextToken === 'of') || + (token === 'B.E.' || token === 'B.Tech' || token === 'B.S.' || token === 'M.S.'); + + if ((isRoleStart || isProjectOrEduStart) && currentLine.length > 0 && currentLine.includes('●')) { + flushLine(); + lines.push(''); + } + + currentLine.push(token); + + // If token is the end of a date range like "Present" or "2025" and next token is not part of date + const isDateEnd = + (token === 'Present' || token === 'Current' || /^(19|20)\d{2}$/.test(token)) && + nextToken !== '–' && + nextToken !== '-' && + nextToken !== 'to' && + nextToken !== 'Present' && + !/^(19|20)\d{2}$/.test(nextToken); + + if ( + isDateEnd && + currentLine.some((w) => ['—', '-', '–'].includes(w)) && + !currentLine.includes('●') && + currentLine.length >= 4 + ) { + flushLine(); + } + } + + flushLine(); + + // Normalize blank lines + const cleaned: string[] = []; + let prevBlank = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + if (!prevBlank && cleaned.length > 0) { + cleaned.push(''); + prevBlank = true; + } + } else { + cleaned.push(trimmed); + prevBlank = false; + } + } + + return cleaned.join('\n'); +} + + diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 7000279..36f77ce 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,6 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; import { HardDrive, ShieldCheck, Info, FolderOpen, Copy, Check, AlertCircle, RotateCcw } from 'lucide-react'; -import { openPath } from '@tauri-apps/plugin-opener'; import { ModelSelector } from '../components/settings/ModelSelector'; import { ConcurrencySettings } from '../components/settings/ConcurrencySettings'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card'; @@ -49,7 +48,7 @@ export function SettingsPage() { setIsOpening(true); setActionError(null); try { - await openPath(appDataDir); + await api.system.openPath(appDataDir); } catch (err: any) { const msg = typeof err === 'string' ? err : err?.message || 'Failed to open directory in file explorer'; setActionError(msg); @@ -59,6 +58,18 @@ export function SettingsPage() { } }; + const handleOpenUrl = async (url: string, e: React.MouseEvent) => { + e.preventDefault(); + setActionError(null); + try { + await api.system.openPath(url); + } catch (err: any) { + const msg = typeof err === 'string' ? err : err?.message || 'Failed to open link'; + setActionError(msg); + setTimeout(() => setActionError(null), 5000); + } + }; + const handleCopyPath = async () => { if (!appDataDir) return; setActionError(null); @@ -190,8 +201,27 @@ export function SettingsPage() {

{APP_NAME} v1.0.0 — {APP_TAGLINE}

-

- Built with Tauri 2, Rust backend engine, SQLite relational vector store, and React frontend. +

+ Copyright © {new Date().getFullYear()}. All rights reserved —{' '} + handleOpenUrl('https://rigial.com/', e)} + className="text-indigo-600 hover:text-indigo-800 hover:underline font-medium cursor-pointer" + > + Rigial.com + {' '} + — M R Kishore Kumar —{' '} + handleOpenUrl('https://www.linkedin.com/in/mrkishorekumar/', e)} + className="text-indigo-600 hover:text-indigo-800 hover:underline font-medium cursor-pointer" + > + LinkedIn +

diff --git a/src/stores/useSettingsStore.ts b/src/stores/useSettingsStore.ts index 3f063c8..82d0612 100644 --- a/src/stores/useSettingsStore.ts +++ b/src/stores/useSettingsStore.ts @@ -2,11 +2,24 @@ import { create } from 'zustand'; import { Model, SystemInfo } from '../types/settings'; import { api } from '../lib/tauri'; +export interface ModelDownloadProgress { + modelId: string; + downloaded: number; + total: number; + speedBps: number; +} + +export interface ModelDownloadError { + modelId: string; + message: string; +} + interface SettingsStore { settings: Record; models: Model[]; systemInfo: SystemInfo | null; - downloadProgress: { modelId: string; downloaded: number; total: number; speedBps: number } | null; + downloadProgress: ModelDownloadProgress | null; + downloadError: ModelDownloadError | null; isLoading: boolean; error: string | null; fetchSettings: () => Promise; @@ -16,7 +29,8 @@ interface SettingsStore { downloadModel: (modelId: string) => Promise; cancelModelDownload: (modelId: string) => Promise; setActiveModel: (modelId: string) => Promise; - setDownloadProgress: (progress: { modelId: string; downloaded: number; total: number; speedBps: number } | null) => void; + setDownloadProgress: (progress: ModelDownloadProgress | null) => void; + setDownloadError: (err: ModelDownloadError | null) => void; } export const useSettingsStore = create((set, get) => ({ @@ -24,6 +38,7 @@ export const useSettingsStore = create((set, get) => ({ models: [], systemInfo: null, downloadProgress: null, + downloadError: null, isLoading: false, error: null, @@ -65,17 +80,19 @@ export const useSettingsStore = create((set, get) => ({ downloadModel: async (modelId: string) => { try { + set({ downloadError: null }); await api.models.download(modelId); await get().fetchModels(); } catch (err: any) { - set({ error: err?.toString() }); + const errMsg = err?.toString() || 'Failed to start model download'; + set({ downloadError: { modelId, message: errMsg }, error: errMsg }); } }, cancelModelDownload: async (modelId: string) => { try { await api.models.cancelDownload(modelId); - set({ downloadProgress: null }); + set({ downloadProgress: null, downloadError: null }); await get().fetchModels(); } catch (err: any) { set({ error: err?.toString() }); @@ -94,4 +111,8 @@ export const useSettingsStore = create((set, get) => ({ setDownloadProgress: (progress) => { set({ downloadProgress: progress }); }, + + setDownloadError: (err) => { + set({ downloadError: err }); + }, }));