1#![allow(dead_code)]
2
3use crate::document::DocumentStore;
4use std::collections::HashMap;
5
6pub trait Plugin: Send + Sync {
8 fn generate_content(&self, store: &DocumentStore) -> Result<String, String>;
10}
11
12pub struct PluginRegistry {
14 plugins: HashMap<String, Box<dyn Plugin>>,
15}
16
17impl PluginRegistry {
18 pub fn new() -> Self {
20 PluginRegistry {
21 plugins: HashMap::new(),
22 }
23 }
24
25 pub fn register(&mut self, name: impl Into<String>, plugin: Box<dyn Plugin>) {
27 self.plugins.insert(name.into(), plugin);
28 }
29
30 pub fn has_plugin(&self, name: &str) -> bool {
32 self.plugins.contains_key(name)
33 }
34
35 pub fn generate(&self, name: &str, store: &DocumentStore) -> Result<String, String> {
37 self.plugins
38 .get(name)
39 .ok_or_else(|| format!("Plugin '{}' not found", name))
40 .and_then(|plugin| plugin.generate_content(store))
41 }
42}
43
44impl Default for PluginRegistry {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50pub struct IndexPlugin;
52
53impl Plugin for IndexPlugin {
54 fn generate_content(&self, store: &DocumentStore) -> Result<String, String> {
55 let mut all_docs = store.list_all_documents()?;
56 all_docs.sort();
57
58 let mut content = String::from("# Index\n\n");
59 content.push_str(&format!(
60 "*Dynamically generated index of all {} notes*\n\n",
61 all_docs.len()
62 ));
63
64 if all_docs.is_empty() {
65 content.push_str("No notes found.\n");
66 return Ok(content);
67 }
68
69 let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
71
72 for doc in &all_docs {
73 if let Some(slash_pos) = doc.find('/') {
74 let category = &doc[..slash_pos];
75 grouped
76 .entry(category.to_string())
77 .or_default()
78 .push(doc.clone());
79 } else {
80 grouped
81 .entry("Root".to_string())
82 .or_default()
83 .push(doc.clone());
84 }
85 }
86
87 let mut categories: Vec<_> = grouped.keys().cloned().collect();
89 categories.sort();
90
91 if let Some(pos) = categories.iter().position(|c| c == "Root") {
93 let root = categories.remove(pos);
94 categories.insert(0, root);
95 }
96
97 for category in &categories {
99 if let Some(docs) = grouped.get(category) {
100 if category == "Root" && categories.len() > 1 {
101 content.push_str("## Root Notes\n\n");
102 } else if category != "Root" {
103 content.push_str(&format!("## {}\n\n", category));
104 }
105
106 for doc in docs {
107 content.push_str(&format!("- [[{}]]\n", doc));
108 }
109 content.push('\n');
110 }
111 }
112
113 content.push_str("---\n\n");
114 content.push_str("*This note is generated by the `index` plugin*\n");
115
116 Ok(content)
117 }
118}
119
120pub struct TodoPlugin;
122
123impl Plugin for TodoPlugin {
124 fn generate_content(&self, store: &DocumentStore) -> Result<String, String> {
125 let all_docs = store.list_all_documents()?;
126
127 let mut content = String::from("# Todos\n\n");
128 content.push_str("*All todos found across your wiki*\n\n");
129
130 let mut notes_with_todos = Vec::new();
131
132 for doc_name in &all_docs {
134 match store.load(doc_name) {
135 Ok(doc) => {
136 let todos = extract_todos(&doc.content);
137 if !todos.is_empty() {
138 notes_with_todos.push((doc_name.clone(), todos));
139 }
140 }
141 Err(_) => continue, }
143 }
144
145 if notes_with_todos.is_empty() {
146 content.push_str("No todos found in any notes.\n");
147 return Ok(content);
148 }
149
150 notes_with_todos.sort_by(|a, b| a.0.cmp(&b.0));
152
153 let note_count = notes_with_todos.len();
154
155 for (note_name, todos) in notes_with_todos {
157 content.push_str(&format!("## [[{}]]\n\n", note_name));
158 for todo in todos {
159 content.push_str(&format!("{}\n", todo));
160 }
161 content.push('\n');
162 }
163
164 content.push_str("---\n\n");
165 content.push_str(&format!("*Found {} notes with todos*\n\n", note_count));
166 content.push_str("*This note is generated by the `todo` plugin*\n");
167
168 Ok(content)
169 }
170}
171
172fn extract_todos(content: &str) -> Vec<String> {
174 let mut todos = Vec::new();
175
176 for line in content.lines() {
177 let trimmed = line.trim();
178 if trimmed.starts_with("- [ ]")
180 || trimmed.starts_with("* [ ]")
181 || trimmed.starts_with("- [x]")
182 || trimmed.starts_with("- [X]")
183 || trimmed.starts_with("* [x]")
184 || trimmed.starts_with("* [X]")
185 {
186 todos.push(line.to_string());
187 }
188 }
189
190 todos
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::path::PathBuf;
197
198 #[test]
199 fn test_plugin_registry() {
200 let mut registry = PluginRegistry::new();
201
202 assert!(!registry.has_plugin("index"));
203
204 registry.register("index", Box::new(IndexPlugin));
205
206 assert!(registry.has_plugin("index"));
207 assert!(!registry.has_plugin("nonexistent"));
208 }
209
210 #[test]
211 fn test_index_plugin_empty() {
212 use std::env;
213 use std::fs;
214
215 let temp_dir = env::temp_dir().join("piki-test-plugin-empty");
216 let _ = fs::remove_dir_all(&temp_dir);
217 fs::create_dir_all(&temp_dir).unwrap();
218
219 let store = DocumentStore::new(temp_dir.clone());
220 let plugin = IndexPlugin;
221
222 let result = plugin.generate_content(&store);
224 assert!(result.is_ok());
225
226 let content = result.unwrap();
227 assert!(content.contains("# Index"));
228 assert!(content.contains("No notes found"));
229
230 fs::remove_dir_all(&temp_dir).ok();
232 }
233
234 #[test]
235 fn test_index_plugin_with_notes() {
236 let store = DocumentStore::new(PathBuf::from("example-wiki"));
237 let plugin = IndexPlugin;
238
239 let content = plugin.generate_content(&store).unwrap();
240
241 assert!(content.contains("# Index"));
243 assert!(content.contains("[["));
245 }
246
247 #[test]
248 fn test_extract_todos() {
249 let content = r#"
250# My Note
251
252- [ ] Unchecked todo
253- [x] Checked todo
254- [X] Checked todo uppercase
255* [ ] Unchecked with asterisk
256* [x] Checked with asterisk
257- Regular bullet point
258 - [ ] Indented todo
259
260Some text here.
261
262- [ ] Another todo
263"#;
264
265 let todos = extract_todos(content);
266
267 assert_eq!(todos.len(), 7);
268 assert!(todos[0].contains("[ ] Unchecked todo"));
269 assert!(todos[1].contains("[x] Checked todo"));
270 assert!(todos[2].contains("[X] Checked todo uppercase"));
271 assert!(todos[3].contains("[ ] Unchecked with asterisk"));
272 assert!(todos[4].contains("[x] Checked with asterisk"));
273 assert!(todos[5].contains("[ ] Indented todo"));
274 assert!(todos[6].contains("[ ] Another todo"));
275 }
276
277 #[test]
278 fn test_todo_plugin_empty() {
279 use std::env;
280 use std::fs;
281
282 let temp_dir = env::temp_dir().join("piki-test-todo-empty");
283 let _ = fs::remove_dir_all(&temp_dir);
284 fs::create_dir_all(&temp_dir).unwrap();
285
286 let store = DocumentStore::new(temp_dir.clone());
287 let plugin = TodoPlugin;
288
289 let result = plugin.generate_content(&store);
290 assert!(result.is_ok());
291
292 let content = result.unwrap();
293 assert!(content.contains("# Todos"));
294 assert!(content.contains("No todos found"));
295
296 fs::remove_dir_all(&temp_dir).ok();
297 }
298
299 #[test]
300 fn test_todo_plugin_with_todos() {
301 use crate::Document;
302 use std::env;
303 use std::fs;
304
305 let temp_dir = env::temp_dir().join("piki-test-todo-with-content");
306 let _ = fs::remove_dir_all(&temp_dir);
307 fs::create_dir_all(&temp_dir).unwrap();
308
309 let store = DocumentStore::new(temp_dir.clone());
310
311 let doc1 = Document {
313 name: "shopping".to_string(),
314 path: temp_dir.join("shopping.md"),
315 content: "# Shopping\n- [ ] Buy milk\n- [x] Get eggs\n".to_string(),
316 modified_time: None,
317 };
318 store.save(&doc1).unwrap();
319
320 let doc2 = Document {
321 name: "project".to_string(),
322 path: temp_dir.join("project.md"),
323 content: "# Project\n- [ ] Task 1\n- [ ] Task 2\n".to_string(),
324 modified_time: None,
325 };
326 store.save(&doc2).unwrap();
327
328 let plugin = TodoPlugin;
329 let content = plugin.generate_content(&store).unwrap();
330
331 assert!(content.contains("# Todos"));
333 assert!(content.contains("[[project]]"));
334 assert!(content.contains("[[shopping]]"));
335 assert!(content.contains("- [ ] Buy milk"));
336 assert!(content.contains("- [x] Get eggs"));
337 assert!(content.contains("- [ ] Task 1"));
338 assert!(content.contains("Found 2 notes with todos"));
339
340 fs::remove_dir_all(&temp_dir).ok();
341 }
342}