#![allow(dead_code)]
use crate::document::DocumentStore;
use std::collections::HashMap;
pub trait Plugin: Send + Sync {
fn generate_content(&self, store: &DocumentStore) -> Result<String, String>;
}
pub struct PluginRegistry {
plugins: HashMap<String, Box<dyn Plugin>>,
}
impl PluginRegistry {
pub fn new() -> Self {
PluginRegistry {
plugins: HashMap::new(),
}
}
pub fn register(&mut self, name: impl Into<String>, plugin: Box<dyn Plugin>) {
self.plugins.insert(name.into(), plugin);
}
pub fn has_plugin(&self, name: &str) -> bool {
self.plugins.contains_key(name)
}
pub fn generate(&self, name: &str, store: &DocumentStore) -> Result<String, String> {
self.plugins
.get(name)
.ok_or_else(|| format!("Plugin '{}' not found", name))
.and_then(|plugin| plugin.generate_content(store))
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
pub struct IndexPlugin;
impl Plugin for IndexPlugin {
fn generate_content(&self, store: &DocumentStore) -> Result<String, String> {
let mut all_docs = store.list_all_documents()?;
all_docs.sort();
let mut content = String::from("# Index\n\n");
content.push_str(&format!(
"*Dynamically generated index of all {} notes*\n\n",
all_docs.len()
));
if all_docs.is_empty() {
content.push_str("No notes found.\n");
return Ok(content);
}
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for doc in &all_docs {
if let Some(slash_pos) = doc.find('/') {
let category = &doc[..slash_pos];
grouped
.entry(category.to_string())
.or_default()
.push(doc.clone());
} else {
grouped
.entry("Root".to_string())
.or_default()
.push(doc.clone());
}
}
let mut categories: Vec<_> = grouped.keys().cloned().collect();
categories.sort();
if let Some(pos) = categories.iter().position(|c| c == "Root") {
let root = categories.remove(pos);
categories.insert(0, root);
}
for category in &categories {
if let Some(docs) = grouped.get(category) {
if category == "Root" && categories.len() > 1 {
content.push_str("## Root Notes\n\n");
} else if category != "Root" {
content.push_str(&format!("## {}\n\n", category));
}
for doc in docs {
content.push_str(&format!("- [[{}]]\n", doc));
}
content.push('\n');
}
}
content.push_str("---\n\n");
content.push_str("*This note is generated by the `index` plugin*\n");
Ok(content)
}
}
pub struct TodoPlugin;
impl Plugin for TodoPlugin {
fn generate_content(&self, store: &DocumentStore) -> Result<String, String> {
let all_docs = store.list_all_documents()?;
let mut content = String::from("# Todos\n\n");
content.push_str("*All todos found across your wiki*\n\n");
let mut notes_with_todos = Vec::new();
for doc_name in &all_docs {
match store.load(doc_name) {
Ok(doc) => {
let todos = extract_todos(&doc.content);
if !todos.is_empty() {
notes_with_todos.push((doc_name.clone(), todos));
}
}
Err(_) => continue, }
}
if notes_with_todos.is_empty() {
content.push_str("No todos found in any notes.\n");
return Ok(content);
}
notes_with_todos.sort_by(|a, b| a.0.cmp(&b.0));
let note_count = notes_with_todos.len();
for (note_name, todos) in notes_with_todos {
content.push_str(&format!("## [[{}]]\n\n", note_name));
for todo in todos {
content.push_str(&format!("{}\n", todo));
}
content.push('\n');
}
content.push_str("---\n\n");
content.push_str(&format!("*Found {} notes with todos*\n\n", note_count));
content.push_str("*This note is generated by the `todo` plugin*\n");
Ok(content)
}
}
fn extract_todos(content: &str) -> Vec<String> {
let mut todos = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("- [ ]")
|| trimmed.starts_with("* [ ]")
|| trimmed.starts_with("- [x]")
|| trimmed.starts_with("- [X]")
|| trimmed.starts_with("* [x]")
|| trimmed.starts_with("* [X]")
{
todos.push(line.to_string());
}
}
todos
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_plugin_registry() {
let mut registry = PluginRegistry::new();
assert!(!registry.has_plugin("index"));
registry.register("index", Box::new(IndexPlugin));
assert!(registry.has_plugin("index"));
assert!(!registry.has_plugin("nonexistent"));
}
#[test]
fn test_index_plugin_empty() {
use std::env;
use std::fs;
let temp_dir = env::temp_dir().join("piki-test-plugin-empty");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
let plugin = IndexPlugin;
let result = plugin.generate_content(&store);
assert!(result.is_ok());
let content = result.unwrap();
assert!(content.contains("# Index"));
assert!(content.contains("No notes found"));
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_index_plugin_with_notes() {
let store = DocumentStore::new(PathBuf::from("example-wiki"));
let plugin = IndexPlugin;
let content = plugin.generate_content(&store).unwrap();
assert!(content.contains("# Index"));
assert!(content.contains("[["));
}
#[test]
fn test_extract_todos() {
let content = r#"
# My Note
- [ ] Unchecked todo
- [x] Checked todo
- [X] Checked todo uppercase
* [ ] Unchecked with asterisk
* [x] Checked with asterisk
- Regular bullet point
- [ ] Indented todo
Some text here.
- [ ] Another todo
"#;
let todos = extract_todos(content);
assert_eq!(todos.len(), 7);
assert!(todos[0].contains("[ ] Unchecked todo"));
assert!(todos[1].contains("[x] Checked todo"));
assert!(todos[2].contains("[X] Checked todo uppercase"));
assert!(todos[3].contains("[ ] Unchecked with asterisk"));
assert!(todos[4].contains("[x] Checked with asterisk"));
assert!(todos[5].contains("[ ] Indented todo"));
assert!(todos[6].contains("[ ] Another todo"));
}
#[test]
fn test_todo_plugin_empty() {
use std::env;
use std::fs;
let temp_dir = env::temp_dir().join("piki-test-todo-empty");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
let plugin = TodoPlugin;
let result = plugin.generate_content(&store);
assert!(result.is_ok());
let content = result.unwrap();
assert!(content.contains("# Todos"));
assert!(content.contains("No todos found"));
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_todo_plugin_with_todos() {
use crate::Document;
use std::env;
use std::fs;
let temp_dir = env::temp_dir().join("piki-test-todo-with-content");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
let doc1 = Document {
name: "shopping".to_string(),
path: temp_dir.join("shopping.md"),
content: "# Shopping\n- [ ] Buy milk\n- [x] Get eggs\n".to_string(),
modified_time: None,
};
store.save(&doc1).unwrap();
let doc2 = Document {
name: "project".to_string(),
path: temp_dir.join("project.md"),
content: "# Project\n- [ ] Task 1\n- [ ] Task 2\n".to_string(),
modified_time: None,
};
store.save(&doc2).unwrap();
let plugin = TodoPlugin;
let content = plugin.generate_content(&store).unwrap();
assert!(content.contains("# Todos"));
assert!(content.contains("[[project]]"));
assert!(content.contains("[[shopping]]"));
assert!(content.contains("- [ ] Buy milk"));
assert!(content.contains("- [x] Get eggs"));
assert!(content.contains("- [ ] Task 1"));
assert!(content.contains("Found 2 notes with todos"));
fs::remove_dir_all(&temp_dir).ok();
}
}