Add custom categories feature

- Add support for user-defined custom categories in config.toml
- Update Config struct with categories field and default_categories() function
- Thread categories through GeminiClient and prompt builder
- Update AI prompts to use dynamic categories instead of hardcoded ones
- Add comprehensive documentation with examples for different use cases
- Update tests to support new categories field
- Maintain backward compatibility with default categories
- Update version from 1.0.3 to 1.0.4

Closes feature request for custom categories.
This commit is contained in:
2025-12-31 13:01:15 +05:30
parent 08a272c4de
commit 1f0547a210
10 changed files with 167 additions and 21 deletions

View File

@@ -91,10 +91,9 @@ pub fn handle_gemini_error(error: crate::gemini::GeminiError) {
pub async fn handle_organization(
args: Args,
api_key: String,
download_path: PathBuf,
config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
let client: GeminiClient = GeminiClient::new(api_key);
let client: GeminiClient = GeminiClient::new(config.api_key, config.categories.clone());
let data_dir = Config::get_data_dir()?;
let cache_path = data_dir.join(".noentropy_cache.json");
@@ -106,6 +105,7 @@ pub async fn handle_organization(
let mut undo_log = UndoLog::load_or_create(&undo_log_path);
undo_log.cleanup_old_entries(30 * 24 * 60 * 60);
let download_path = config.download_folder;
let batch = FileBatch::from_path(download_path.clone(), args.recursive);
if batch.filenames.is_empty() {

View File

@@ -21,14 +21,15 @@ pub struct GeminiClient {
model: String,
#[allow(dead_code)]
timeout: Duration,
categories: Vec<String>,
}
impl GeminiClient {
pub fn new(api_key: String) -> Self {
Self::with_model(api_key, DEFAULT_MODEL.to_string())
pub fn new(api_key: String, categories: Vec<String>) -> Self {
Self::with_model(api_key, DEFAULT_MODEL.to_string(), categories)
}
pub fn with_model(api_key: String, model: String) -> Self {
pub fn with_model(api_key: String, model: String, categories: Vec<String>) -> Self {
let timeout = Duration::from_secs(DEFAULT_TIMEOUT_SECS);
let client = Self::build_client(timeout);
let base_url = Self::build_base_url(&model);
@@ -39,6 +40,7 @@ impl GeminiClient {
base_url,
model,
timeout,
categories,
}
}
@@ -77,7 +79,8 @@ impl GeminiClient {
return Ok(cached_response);
}
let prompt = PromptBuilder::new(filenames.clone()).build_categorization_prompt();
let prompt =
PromptBuilder::new(filenames.clone()).build_categorization_prompt(&self.categories);
let request_body = self.build_categorization_request(&prompt);
let res = self.send_request_with_retry(&url, &request_body).await?;

View File

@@ -29,12 +29,13 @@ impl PromptBuilder {
}
}
pub fn build_categorization_prompt(&self) -> String {
pub fn build_categorization_prompt(&self, categories: &[String]) -> String {
let categories_str = categories.join("', '");
format!(
"I have these files in my Downloads folder: [{}]. \
Categorize them into these folders: 'Images', 'Documents', 'Installers', 'Music', 'Archives', 'Code', 'Misc'. \
Categorize them into these folders: '{}'. \
Return ONLY a JSON object with this structure: {{ 'files': [ {{ 'filename': 'name', 'category': 'folder' }} ] }}",
self.file_list
self.file_list, categories_str
)
}

View File

@@ -3,7 +3,7 @@ use noentropy::cli::{
Args,
orchestrator::{handle_organization, handle_undo},
};
use noentropy::settings::{get_or_prompt_api_key, get_or_prompt_download_folder};
use noentropy::settings::{get_or_prompt_config, get_or_prompt_download_folder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -15,10 +15,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
let api_key = get_or_prompt_api_key()?;
let download_path = get_or_prompt_download_folder()?;
let config = get_or_prompt_config()?;
handle_organization(args, api_key, download_path).await?;
handle_organization(args, config).await?;
Ok(())
}

View File

@@ -5,10 +5,24 @@ use std::path::PathBuf;
use super::prompt::Prompter;
pub fn default_categories() -> Vec<String> {
vec![
"Images".to_string(),
"Documents".to_string(),
"Installers".to_string(),
"Music".to_string(),
"Archives".to_string(),
"Code".to_string(),
"Misc".to_string(),
]
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub api_key: String,
pub download_folder: PathBuf,
#[serde(default = "default_categories")]
pub categories: Vec<String>,
}
impl Config {
@@ -128,11 +142,41 @@ pub fn get_or_prompt_download_folder() -> Result<PathBuf, Box<dyn std::error::Er
Ok(folder_path)
}
pub fn get_or_prompt_config() -> Result<Config, Box<dyn std::error::Error>> {
let mut config = Config::load().unwrap_or_default();
let mut needs_save = false;
// Check API key
if config.api_key.is_empty() {
println!();
println!("{}", "🔑 NoEntropy Configuration".bold().cyan());
println!("{}", "─────────────────────────────".cyan());
config.api_key = Prompter::prompt_api_key()?;
needs_save = true;
}
// Check download folder
if config.download_folder.as_os_str().is_empty() || !config.download_folder.exists() {
println!();
println!("{}", "📁 Download folder not configured.".yellow());
config.download_folder = Prompter::prompt_download_folder()?;
needs_save = true;
}
if needs_save {
config.save()?;
println!();
}
Ok(config)
}
impl Default for Config {
fn default() -> Self {
Self {
api_key: String::new(),
download_folder: PathBuf::new(),
categories: default_categories(),
}
}
}

View File

@@ -1,7 +1,9 @@
pub mod config;
pub mod prompt;
pub use config::{Config, get_or_prompt_api_key, get_or_prompt_download_folder};
pub use config::{
Config, get_or_prompt_api_key, get_or_prompt_config, get_or_prompt_download_folder,
};
pub use prompt::Prompter;
#[cfg(test)]

View File

@@ -1,3 +1,4 @@
use crate::settings::config::default_categories;
use crate::settings::*;
use std::path::Path;
use std::path::PathBuf;
@@ -7,6 +8,7 @@ fn test_config_serialization() {
let config = Config {
api_key: "test_key_12345".to_string(),
download_folder: PathBuf::from("/test/path"),
categories: default_categories(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
@@ -15,6 +17,7 @@ fn test_config_serialization() {
let deserialized: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(config.api_key, deserialized.api_key);
assert_eq!(config.download_folder, deserialized.download_folder);
assert_eq!(config.categories, deserialized.categories);
}
#[test]
@@ -83,6 +86,7 @@ fn test_config_empty_api_key_error() {
let config = Config {
api_key: String::new(),
download_folder: PathBuf::from("/test/path"),
categories: default_categories(),
};
assert!(config.api_key.is_empty());