1mod byday;
21mod csv;
22mod dayone;
23mod doing;
24pub mod helpers;
25pub mod html;
26pub mod import;
27mod json;
28mod markdown;
29mod taskpaper;
30mod timeline;
31
32use doing_config::Config;
33use doing_taskpaper::Entry;
34use doing_template::renderer::RenderOptions;
35use regex::Regex;
36
37pub trait ExportPlugin {
42 fn name(&self) -> &str;
44
45 fn render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String;
47
48 fn settings(&self) -> ExportPluginSettings;
50}
51
52#[derive(Clone, Debug)]
54pub struct ExportPluginSettings {
55 pub trigger: String,
56}
57
58pub struct ExportRegistry {
64 plugins: Vec<RegisteredPlugin>,
65}
66
67impl ExportRegistry {
68 pub fn new() -> Self {
70 Self {
71 plugins: Vec::new(),
72 }
73 }
74
75 pub fn available_formats(&self) -> Vec<&str> {
77 let mut names: Vec<&str> = self.plugins.iter().map(|p| p.name.as_str()).collect();
78 names.sort();
79 names
80 }
81
82 pub fn register(&mut self, plugin: Box<dyn ExportPlugin>) {
91 let name = plugin.name().to_string();
92 let settings = plugin.settings();
93 let pattern = normalize_trigger(&settings.trigger);
94 let trigger = Regex::new(&format!("(?i)^(?:{pattern})$"))
95 .unwrap_or_else(|_| panic!("invalid trigger pattern for plugin \"{name}\": {pattern}"));
96 self.plugins.push(RegisteredPlugin {
97 name,
98 plugin,
99 trigger,
100 });
101 }
102
103 pub fn resolve(&self, format: &str) -> Option<&dyn ExportPlugin> {
108 self
109 .plugins
110 .iter()
111 .find(|p| p.trigger.is_match(format))
112 .map(|p| p.plugin.as_ref())
113 }
114}
115
116impl Default for ExportRegistry {
117 fn default() -> Self {
118 Self::new()
119 }
120}
121
122struct RegisteredPlugin {
123 name: String,
124 plugin: Box<dyn ExportPlugin>,
125 trigger: Regex,
126}
127
128pub fn default_registry() -> ExportRegistry {
130 let mut registry = ExportRegistry::new();
131 registry.register(Box::new(byday::BydayExport));
132 registry.register(Box::new(csv::CsvExport));
133 registry.register(Box::new(dayone::DayoneExport));
134 registry.register(Box::new(dayone::DayoneDaysExport));
135 registry.register(Box::new(dayone::DayoneEntriesExport));
136 registry.register(Box::new(doing::DoingExport));
137 registry.register(Box::new(html::HtmlExport));
138 registry.register(Box::new(json::JsonExport));
139 registry.register(Box::new(markdown::MarkdownExport));
140 registry.register(Box::new(taskpaper::TaskPaperExport));
141 registry.register(Box::new(timeline::TimelineExport));
142 registry
143}
144
145fn normalize_trigger(trigger: &str) -> String {
147 trigger.trim().to_string()
148}
149
150#[cfg(test)]
151mod test {
152 use super::*;
153
154 struct MockPlugin {
155 name: String,
156 trigger: String,
157 }
158
159 impl MockPlugin {
160 fn new(name: &str, trigger: &str) -> Self {
161 Self {
162 name: name.into(),
163 trigger: trigger.into(),
164 }
165 }
166 }
167
168 impl ExportPlugin for MockPlugin {
169 fn name(&self) -> &str {
170 &self.name
171 }
172
173 fn render(&self, _entries: &[Entry], _options: &RenderOptions, _config: &Config) -> String {
174 format!("[{}]", self.name)
175 }
176
177 fn settings(&self) -> ExportPluginSettings {
178 ExportPluginSettings {
179 trigger: self.trigger.clone(),
180 }
181 }
182 }
183
184 mod default_registry {
185 use pretty_assertions::assert_eq;
186
187 use super::*;
188
189 #[test]
190 fn it_registers_all_built_in_plugins() {
191 let registry = default_registry();
192
193 assert_eq!(
194 registry.available_formats(),
195 vec![
196 "byday",
197 "csv",
198 "dayone",
199 "dayone-days",
200 "dayone-entries",
201 "doing",
202 "html",
203 "json",
204 "markdown",
205 "taskpaper",
206 "timeline"
207 ]
208 );
209 }
210 }
211
212 mod export_registry_available_formats {
213 use pretty_assertions::assert_eq;
214
215 use super::*;
216
217 #[test]
218 fn it_returns_empty_for_new_registry() {
219 let registry = ExportRegistry::new();
220
221 assert!(registry.available_formats().is_empty());
222 }
223
224 #[test]
225 fn it_returns_sorted_format_names() {
226 let mut registry = ExportRegistry::new();
227 registry.register(Box::new(MockPlugin::new("markdown", "markdown|md")));
228 registry.register(Box::new(MockPlugin::new("csv", "csv")));
229 registry.register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")));
230
231 let formats = registry.available_formats();
232
233 assert_eq!(formats, vec!["csv", "markdown", "taskpaper"]);
234 }
235 }
236
237 mod export_registry_register {
238 use pretty_assertions::assert_eq;
239
240 use super::*;
241
242 #[test]
243 fn it_adds_plugin_to_registry() {
244 let mut registry = ExportRegistry::new();
245
246 registry.register(Box::new(MockPlugin::new("csv", "csv")));
247
248 assert_eq!(registry.available_formats(), vec!["csv"]);
249 }
250
251 #[test]
252 #[should_panic(expected = "invalid trigger pattern")]
253 fn it_panics_on_invalid_trigger_pattern() {
254 let mut registry = ExportRegistry::new();
255
256 registry.register(Box::new(MockPlugin::new("bad", "(?invalid")));
257 }
258 }
259
260 mod export_registry_resolve {
261 use pretty_assertions::assert_eq;
262
263 use super::*;
264
265 #[test]
266 fn it_matches_exact_format_name() {
267 let mut registry = ExportRegistry::new();
268 registry.register(Box::new(MockPlugin::new("csv", "csv")));
269
270 let plugin = registry.resolve("csv").unwrap();
271
272 assert_eq!(plugin.name(), "csv");
273 }
274
275 #[test]
276 fn it_matches_alternate_trigger_pattern() {
277 let mut registry = ExportRegistry::new();
278 registry.register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")));
279
280 assert!(registry.resolve("taskpaper").is_some());
281 assert!(registry.resolve("task").is_some());
282 assert!(registry.resolve("tp").is_some());
283 }
284
285 #[test]
286 fn it_matches_case_insensitively() {
287 let mut registry = ExportRegistry::new();
288 registry.register(Box::new(MockPlugin::new("csv", "csv")));
289
290 assert!(registry.resolve("CSV").is_some());
291 assert!(registry.resolve("Csv").is_some());
292 }
293
294 #[test]
295 fn it_returns_none_for_unknown_format() {
296 let mut registry = ExportRegistry::new();
297 registry.register(Box::new(MockPlugin::new("csv", "csv")));
298
299 assert!(registry.resolve("json").is_none());
300 }
301
302 #[test]
303 fn it_does_not_match_partial_strings() {
304 let mut registry = ExportRegistry::new();
305 registry.register(Box::new(MockPlugin::new("csv", "csv")));
306
307 assert!(registry.resolve("csvx").is_none());
308 assert!(registry.resolve("xcsv").is_none());
309 }
310 }
311}