2025-12-12 01:36:39 +05:00
|
|
|
use rand::prelude::*;
|
2023-04-27 00:28:36 +05:00
|
|
|
use seqdiff::ratio;
|
|
|
|
|
use serde_yaml;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::{fs, fs::File};
|
|
|
|
|
|
|
|
|
|
use core::time::Duration;
|
|
|
|
|
use std::path::PathBuf;
|
2025-12-11 23:43:50 +05:00
|
|
|
use std::process::{Child, Command};
|
2023-04-29 14:58:16 +05:00
|
|
|
// use tauri::Manager;
|
2023-04-27 00:28:36 +05:00
|
|
|
|
|
|
|
|
mod structs;
|
|
|
|
|
pub use structs::*;
|
|
|
|
|
|
2026-01-05 01:22:45 +05:00
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
2025-12-11 23:43:50 +05:00
|
|
|
use crate::{audio, config};
|
2023-04-27 00:28:36 +05:00
|
|
|
|
2023-06-11 19:17:50 +05:00
|
|
|
// @TODO. Allow commands both in yaml and json format.
|
2026-01-05 01:22:45 +05:00
|
|
|
pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
|
2023-04-27 00:28:36 +05:00
|
|
|
// collect commands
|
2026-01-05 01:22:45 +05:00
|
|
|
let mut commands: Vec<JCommandsList> = Vec::new();
|
|
|
|
|
|
|
|
|
|
let cmd_dirs = fs::read_dir(config::COMMANDS_PATH)
|
|
|
|
|
.map_err(|e| format!("Error reading commands directory: {}", e))?;
|
|
|
|
|
|
|
|
|
|
for entry in cmd_dirs {
|
|
|
|
|
let entry = match entry {
|
|
|
|
|
Ok(e) => e,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!("Failed to read command directory entry: {}", e);
|
|
|
|
|
continue;
|
2023-04-27 00:28:36 +05:00
|
|
|
}
|
2026-01-05 01:22:45 +05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let cmd_path = entry.path();
|
|
|
|
|
let toml_file = cmd_path.join("command.toml");
|
|
|
|
|
|
|
|
|
|
if !toml_file.exists() {
|
|
|
|
|
continue;
|
2023-04-27 00:28:36 +05:00
|
|
|
}
|
2026-01-05 01:22:45 +05:00
|
|
|
|
|
|
|
|
// read and parse TOML
|
|
|
|
|
let content = match fs::read_to_string(&toml_file) {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!("Failed to read {}: {}", toml_file.display(), e);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
2023-04-27 00:28:36 +05:00
|
|
|
|
2026-01-05 01:22:45 +05:00
|
|
|
let file: JCommandsList = match toml::from_str(&content) {
|
|
|
|
|
Ok(f) => f,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!("Failed to parse {}: {}", toml_file.display(), e);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
commands.push(JCommandsList {
|
|
|
|
|
path: cmd_path,
|
|
|
|
|
commands: file.commands,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if commands.is_empty() {
|
|
|
|
|
Err("No commands found".into())
|
2023-04-27 00:28:36 +05:00
|
|
|
} else {
|
2026-01-05 01:22:45 +05:00
|
|
|
info!("Loaded {} commands", commands.len());
|
|
|
|
|
Ok(commands)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Commands hash generation for cache invalidation (deterministi c)
|
|
|
|
|
pub fn commands_hash(commands: &Vec<JCommandsList>) -> String {
|
|
|
|
|
use sha2::{Sha256, Digest};
|
|
|
|
|
|
|
|
|
|
let mut hasher = Sha256::new();
|
|
|
|
|
|
|
|
|
|
// collect all command ids and phrases, sorted
|
|
|
|
|
let mut all_ids: Vec<_> = commands.iter()
|
|
|
|
|
.flat_map(|ac| ac.commands.iter().map(|c| (&c.id, &c.phrases)))
|
|
|
|
|
.collect();
|
|
|
|
|
all_ids.sort_by_key(|(id, _)| *id);
|
|
|
|
|
|
|
|
|
|
for (id, phrases) in all_ids {
|
|
|
|
|
hasher.update(id.as_bytes());
|
|
|
|
|
for phrase in phrases {
|
|
|
|
|
hasher.update(phrase.as_bytes());
|
|
|
|
|
}
|
2023-04-27 00:28:36 +05:00
|
|
|
}
|
2026-01-05 01:22:45 +05:00
|
|
|
|
|
|
|
|
format!("{:x}", hasher.finalize())
|
2023-04-27 00:28:36 +05:00
|
|
|
}
|
|
|
|
|
|
2026-01-05 01:22:45 +05:00
|
|
|
|
2023-04-27 00:28:36 +05:00
|
|
|
pub fn fetch_command<'a>(
|
|
|
|
|
phrase: &str,
|
2026-01-05 01:22:45 +05:00
|
|
|
commands: &'a Vec<JCommandsList>,
|
|
|
|
|
) -> Option<(&'a PathBuf, &'a JCommand)> {
|
2026-01-05 04:20:43 +05:00
|
|
|
let mut result: Option<(&PathBuf, &JCommand)> = None;
|
|
|
|
|
let mut best_score = config::CMD_RATIO_THRESHOLD;
|
|
|
|
|
|
|
|
|
|
// normalize input
|
|
|
|
|
let phrase = phrase.trim().to_lowercase();
|
|
|
|
|
if phrase.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let phrase_chars: Vec<char> = phrase.chars().collect();
|
|
|
|
|
let phrase_words: Vec<&str> = phrase.split_whitespace().collect();
|
|
|
|
|
|
|
|
|
|
for cmd_list in commands {
|
|
|
|
|
for cmd in &cmd_list.commands {
|
|
|
|
|
for cmd_phrase in &cmd.phrases {
|
|
|
|
|
let cmd_phrase = cmd_phrase.trim().to_lowercase();
|
|
|
|
|
let cmd_phrase_chars: Vec<char> = cmd_phrase.chars().collect();
|
|
|
|
|
|
|
|
|
|
// character-level similarity
|
|
|
|
|
let char_ratio = ratio(&phrase_chars, &cmd_phrase_chars);
|
|
|
|
|
|
|
|
|
|
// word-level similarity (handles word order)
|
|
|
|
|
let cmd_words: Vec<&str> = cmd_phrase.split_whitespace().collect();
|
|
|
|
|
let word_score = word_overlap_score(&phrase_words, &cmd_words);
|
|
|
|
|
|
|
|
|
|
// combined score (weighted average)
|
|
|
|
|
let score = (char_ratio * 0.6) + (word_score * 0.4);
|
|
|
|
|
|
|
|
|
|
// early exit on perfect match
|
|
|
|
|
if score >= 99.0 {
|
|
|
|
|
debug!("Perfect match: '{}' -> '{}'", phrase, cmd_phrase);
|
|
|
|
|
return Some((&cmd_list.path, cmd));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if score > best_score {
|
|
|
|
|
best_score = score;
|
|
|
|
|
result = Some((&cmd_list.path, cmd));
|
2023-04-27 00:28:36 +05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 04:20:43 +05:00
|
|
|
if let Some((cmd_path, cmd)) = result {
|
2025-12-11 23:43:50 +05:00
|
|
|
info!(
|
2026-01-05 04:20:43 +05:00
|
|
|
"Fuzzy match: '{}' -> cmd '{}' (score: {:.1}%)",
|
|
|
|
|
phrase, cmd.id, best_score
|
2025-12-11 23:43:50 +05:00
|
|
|
);
|
2026-01-05 04:20:43 +05:00
|
|
|
Some((cmd_path, cmd))
|
2023-04-27 00:28:36 +05:00
|
|
|
} else {
|
2026-01-05 04:20:43 +05:00
|
|
|
debug!("No match for '{}' (best: {:.1}%)", phrase, best_score);
|
2023-04-27 00:28:36 +05:00
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 04:20:43 +05:00
|
|
|
|
|
|
|
|
fn word_overlap_score(input_words: &[&str], cmd_words: &[&str]) -> f64 {
|
|
|
|
|
if input_words.is_empty() || cmd_words.is_empty() {
|
|
|
|
|
return 0.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut matched = 0.0;
|
|
|
|
|
|
|
|
|
|
for input_word in input_words {
|
|
|
|
|
// find best matching word in command
|
|
|
|
|
let best_word_match = cmd_words
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|cmd_word| {
|
|
|
|
|
let iw: Vec<char> = input_word.chars().collect();
|
|
|
|
|
let cw: Vec<char> = cmd_word.chars().collect();
|
|
|
|
|
ratio(&iw, &cw)
|
|
|
|
|
})
|
|
|
|
|
.fold(0.0_f64, |a, b| a.max(b));
|
|
|
|
|
|
|
|
|
|
// count as match if word similarity > 70%
|
|
|
|
|
if best_word_match > 70.0 {
|
|
|
|
|
matched += best_word_match / 100.0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// normalize by max word count
|
|
|
|
|
let max_words = input_words.len().max(cmd_words.len()) as f64;
|
|
|
|
|
(matched / max_words) * 100.0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2023-06-11 19:17:50 +05:00
|
|
|
// @TODO. Rewrite executors by executor type struct. (with match arms)
|
2023-04-27 00:28:36 +05:00
|
|
|
pub fn execute_exe(exe: &str, args: &Vec<String>) -> std::io::Result<Child> {
|
|
|
|
|
Command::new(exe).args(args).spawn()
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-01 04:24:06 +05:00
|
|
|
pub fn execute_cli(cmd: &str, args: &Vec<String>) -> std::io::Result<Child> {
|
2026-01-04 06:14:05 +05:00
|
|
|
debug!("Spawning cmd as: cmd /C {} {:?}", cmd, args);
|
2023-05-01 04:24:06 +05:00
|
|
|
|
|
|
|
|
if cfg!(target_os = "windows") {
|
2025-12-11 23:43:50 +05:00
|
|
|
Command::new("cmd").arg("/C").arg(cmd).args(args).spawn()
|
2023-05-01 04:24:06 +05:00
|
|
|
} else {
|
2025-12-11 23:43:50 +05:00
|
|
|
Command::new("sh").arg("-c").arg(cmd).args(args).spawn()
|
2023-05-01 04:24:06 +05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-27 00:28:36 +05:00
|
|
|
pub fn execute_command(
|
|
|
|
|
cmd_path: &PathBuf,
|
2026-01-05 01:22:45 +05:00
|
|
|
cmd_config: &JCommand,
|
2023-06-11 19:17:50 +05:00
|
|
|
// app_handle: &tauri::AppHandle,
|
2023-05-01 04:24:06 +05:00
|
|
|
) -> Result<bool, String> {
|
2023-06-11 19:17:50 +05:00
|
|
|
let sounds_directory = audio::get_sound_directory().unwrap();
|
|
|
|
|
|
2026-01-05 01:22:45 +05:00
|
|
|
match cmd_config.action.as_str() {
|
2023-04-27 00:28:36 +05:00
|
|
|
"voice" => {
|
|
|
|
|
// VOICE command type
|
2025-12-11 23:43:50 +05:00
|
|
|
let random_cmd_sound = format!(
|
|
|
|
|
"{}.wav",
|
|
|
|
|
cmd_config
|
|
|
|
|
.sounds
|
|
|
|
|
.choose(&mut rand::thread_rng())
|
|
|
|
|
.unwrap()
|
|
|
|
|
);
|
2023-06-11 19:17:50 +05:00
|
|
|
// events::play(random_cmd_sound, app_handle);
|
|
|
|
|
audio::play_sound(&sounds_directory.join(random_cmd_sound));
|
2023-04-27 00:28:36 +05:00
|
|
|
|
2023-05-01 04:24:06 +05:00
|
|
|
Ok(true)
|
2023-04-27 00:28:36 +05:00
|
|
|
}
|
|
|
|
|
"ahk" => {
|
|
|
|
|
// AutoHotkey command type
|
2026-01-05 01:22:45 +05:00
|
|
|
let exe_path_absolute = Path::new(&cmd_config.exe_path);
|
|
|
|
|
let exe_path_local = Path::new(&cmd_path).join(&cmd_config.exe_path);
|
2023-04-27 00:28:36 +05:00
|
|
|
|
|
|
|
|
if let Ok(_) = execute_exe(
|
|
|
|
|
if exe_path_absolute.exists() {
|
|
|
|
|
exe_path_absolute.to_str().unwrap()
|
|
|
|
|
} else {
|
|
|
|
|
exe_path_local.to_str().unwrap()
|
|
|
|
|
},
|
2026-01-05 01:22:45 +05:00
|
|
|
&cmd_config.exe_args,
|
2023-04-27 00:28:36 +05:00
|
|
|
) {
|
2025-12-11 23:43:50 +05:00
|
|
|
let random_cmd_sound = format!(
|
|
|
|
|
"{}.wav",
|
|
|
|
|
cmd_config
|
|
|
|
|
.sounds
|
|
|
|
|
.choose(&mut rand::thread_rng())
|
|
|
|
|
.unwrap()
|
|
|
|
|
);
|
2023-06-11 19:17:50 +05:00
|
|
|
// events::play(random_cmd_sound, app_handle);
|
|
|
|
|
audio::play_sound(&sounds_directory.join(random_cmd_sound));
|
2023-04-27 00:28:36 +05:00
|
|
|
|
2023-05-01 04:24:06 +05:00
|
|
|
Ok(true)
|
2023-04-27 00:28:36 +05:00
|
|
|
} else {
|
2023-04-30 22:32:48 +05:00
|
|
|
error!("AHK process spawn error (does exe path is valid?)");
|
2023-04-27 00:28:36 +05:00
|
|
|
Err("AHK process spawn error (does exe path is valid?)".into())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"cli" => {
|
|
|
|
|
// CLI command type
|
2026-01-05 01:22:45 +05:00
|
|
|
let cli_cmd = &cmd_config.cli_cmd;
|
2023-04-27 00:28:36 +05:00
|
|
|
|
2026-01-05 01:22:45 +05:00
|
|
|
match execute_cli(cli_cmd, &cmd_config.cli_args) {
|
2025-12-11 23:43:50 +05:00
|
|
|
Ok(_) => {
|
|
|
|
|
let random_cmd_sound = format!(
|
|
|
|
|
"{}.wav",
|
|
|
|
|
cmd_config
|
|
|
|
|
.sounds
|
|
|
|
|
.choose(&mut rand::thread_rng())
|
|
|
|
|
.unwrap()
|
|
|
|
|
);
|
2023-06-11 19:17:50 +05:00
|
|
|
// events::play(random_cmd_sound, app_handle);
|
2025-12-11 23:43:50 +05:00
|
|
|
audio::play_sound(&sounds_directory.join(random_cmd_sound));
|
2023-05-01 04:24:06 +05:00
|
|
|
|
|
|
|
|
Ok(true)
|
2025-12-11 23:43:50 +05:00
|
|
|
}
|
2023-05-01 04:24:06 +05:00
|
|
|
Err(msg) => {
|
|
|
|
|
error!("CLI command error ({})", msg);
|
|
|
|
|
Err(format!("Shell command error ({})", msg).into())
|
|
|
|
|
}
|
2023-04-27 00:28:36 +05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"terminate" => {
|
|
|
|
|
// TERMINATE command type
|
2025-12-11 23:43:50 +05:00
|
|
|
let random_cmd_sound = format!(
|
|
|
|
|
"{}.wav",
|
|
|
|
|
cmd_config
|
|
|
|
|
.sounds
|
|
|
|
|
.choose(&mut rand::thread_rng())
|
|
|
|
|
.unwrap()
|
|
|
|
|
);
|
2023-06-11 19:17:50 +05:00
|
|
|
// events::play(random_cmd_sound, app_handle);
|
|
|
|
|
audio::play_sound(&sounds_directory.join(random_cmd_sound));
|
2023-04-27 00:28:36 +05:00
|
|
|
|
|
|
|
|
std::thread::sleep(Duration::from_secs(2));
|
|
|
|
|
std::process::exit(0);
|
|
|
|
|
}
|
2023-05-01 04:24:06 +05:00
|
|
|
"stop_chaining" => {
|
|
|
|
|
// STOP_CHAINING command type
|
2025-12-11 23:43:50 +05:00
|
|
|
let random_cmd_sound = format!(
|
|
|
|
|
"{}.wav",
|
|
|
|
|
cmd_config
|
|
|
|
|
.sounds
|
|
|
|
|
.choose(&mut rand::thread_rng())
|
|
|
|
|
.unwrap()
|
|
|
|
|
);
|
2023-06-11 19:17:50 +05:00
|
|
|
// events::play(random_cmd_sound, app_handle);
|
|
|
|
|
audio::play_sound(&sounds_directory.join(random_cmd_sound));
|
2023-05-01 04:24:06 +05:00
|
|
|
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
2023-04-30 22:32:48 +05:00
|
|
|
_ => {
|
|
|
|
|
error!("Command type unknown");
|
|
|
|
|
Err("Command type unknown".into())
|
2025-12-11 23:43:50 +05:00
|
|
|
}
|
2023-04-27 00:28:36 +05:00
|
|
|
}
|
|
|
|
|
}
|
2023-06-11 19:17:50 +05:00
|
|
|
|
2026-01-05 01:22:45 +05:00
|
|
|
pub fn list(from: &Vec<JCommandsList>) -> Vec<String> {
|
2023-06-11 19:17:50 +05:00
|
|
|
let mut out: Vec<String> = vec![];
|
|
|
|
|
|
|
|
|
|
for x in from.iter() {
|
|
|
|
|
out.push(String::from(x.path.to_str().unwrap()));
|
|
|
|
|
// out.append()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out
|
2025-12-11 23:43:50 +05:00
|
|
|
}
|