1use std::collections::HashSet;
2use std::path::Path;
3use std::{convert::TryFrom, fs, io, path::PathBuf};
4
5use chrono::{DateTime, Datelike, Days, Duration, Local, Months, NaiveDate, TimeZone};
6use globset::{Glob, GlobSetBuilder};
7use ignore::{Walk, WalkBuilder};
8use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
9use nucleo_matcher::{Config as NucleoConfig, Matcher};
10use orgize::ast::{Headline, PropertyDrawer, Timestamp};
11use orgize::export::{Container, Event, from_fn, from_fn_with_ctx};
12use orgize::{Org, ParseConfig};
13use rowan::ast::AstNode;
14use serde::{Deserialize, Serialize};
15use shellexpand::tilde;
16
17use crate::OrgModeError;
18use crate::config::{OrgConfig, load_org_config};
19use crate::utils::tags_match;
20
21macro_rules! convert_timestamp {
26 ($ts:expr, $prefix:ident) => {{
27 paste::paste! {
28 let year = $ts.[<year_ $prefix>]()?;
29 let month = $ts.[<month_ $prefix>]()?;
30 let day = $ts.[<day_ $prefix>]()?;
31 let hour = $ts.[<hour_ $prefix>]();
32 let minute = $ts.[<minute_ $prefix>]();
33
34 Some(chrono::NaiveDateTime::new(
35 chrono::NaiveDate::from_ymd_opt(
36 year.parse().ok()?,
37 month.parse().ok()?,
38 day.parse().ok()?,
39 )?,
40 chrono::NaiveTime::from_hms_opt(
41 hour.map(|v| v.parse().unwrap_or_default())
42 .unwrap_or_default(),
43 minute
44 .map(|v| v.parse().unwrap_or_default())
45 .unwrap_or_default(),
46 0,
47 )?,
48 ))
49 }
50 }};
51}
52
53#[cfg(test)]
54#[path = "org_mode_tests.rs"]
55mod org_mode_tests;
56
57#[derive(Debug)]
58pub struct OrgMode {
59 config: OrgConfig,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct TreeNode {
64 pub label: String,
65 pub level: usize,
66 #[serde(skip_serializing_if = "Vec::is_empty", default)]
67 pub children: Vec<TreeNode>,
68 #[serde(skip_serializing_if = "Vec::is_empty", default)]
69 pub tags: Vec<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SearchResult {
74 pub file_path: String,
75 pub snippet: String,
76 pub score: u32,
77 #[serde(skip_serializing_if = "Vec::is_empty", default)]
78 pub tags: Vec<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub enum TodoState {
83 Todo,
84 Done,
85 Other(String),
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub enum Priority {
90 A,
91 B,
92 C,
93 None,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Position {
98 pub start: u32,
99 pub end: u32,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct AgendaItem {
104 pub file_path: String,
105 pub heading: String,
106 pub level: usize,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub todo_state: Option<String>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub priority: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub scheduled: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub deadline: Option<String>,
117 #[serde(skip_serializing_if = "Vec::is_empty", default)]
118 pub tags: Vec<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub position: Option<Position>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct AgendaView {
125 pub items: Vec<AgendaItem>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub start_date: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub end_date: Option<String>,
130}
131
132#[derive(Default)]
133pub enum AgendaViewType {
134 Today,
135 Day(DateTime<Local>),
136 #[default]
137 CurrentWeek,
138 Week(u8),
139 CurrentMonth,
140 Month(u32),
141 Custom {
142 from: DateTime<Local>,
143 to: DateTime<Local>,
144 },
145}
146
147impl TreeNode {
148 pub fn new(label: String) -> Self {
149 Self {
150 label,
151 level: 0,
152 children: Vec::new(),
153 tags: Vec::new(),
154 }
155 }
156
157 pub fn new_with_level(label: String, level: usize) -> Self {
158 Self {
159 label,
160 level,
161 children: Vec::new(),
162 tags: Vec::new(),
163 }
164 }
165
166 pub fn to_indented_string(&self, indent: usize) -> String {
167 let mut result = String::new();
168 let prefix = " ".repeat(indent);
169 result.push_str(&format!(
170 "{}{} {}\n",
171 prefix,
172 "*".repeat(self.level),
173 self.label
174 ));
175
176 for child in &self.children {
177 result.push_str(&child.to_indented_string(indent + 1));
178 }
179
180 result
181 }
182}
183
184impl OrgMode {
185 pub fn new(config: OrgConfig) -> Result<Self, OrgModeError> {
186 let config = config.validate()?;
187
188 Ok(OrgMode { config })
189 }
190
191 pub fn with_defaults() -> Result<Self, OrgModeError> {
192 Self::new(load_org_config(None, None)?)
193 }
194
195 pub fn config(&self) -> &OrgConfig {
196 &self.config
197 }
198}
199
200impl OrgMode {
201 pub fn list_files(
202 &self,
203 tags: Option<&[String]>,
204 limit: Option<usize>,
205 ) -> Result<Vec<String>, OrgModeError> {
206 Walk::new(&self.config.org_directory)
207 .filter_map(|entry| match entry {
208 Ok(dir_entry) => {
209 let path = dir_entry.path();
210
211 if path.is_file()
212 && let Some(extension) = path.extension()
213 && extension == "org"
214 && let Ok(relative_path) = path.strip_prefix(&self.config.org_directory)
215 && let Some(path_str) = relative_path.to_str()
216 {
217 Some(Ok(path_str.to_string()))
218 } else {
219 None
220 }
221 }
222 Err(e) => Some(Err(OrgModeError::WalkError(e))),
223 })
224 .collect::<Result<Vec<String>, OrgModeError>>()
225 .map(|files| {
226 files
227 .into_iter()
228 .filter(|path| {
229 if let Some(tags) = tags {
230 let file_tags = self.tags_in_file(path).unwrap_or_default();
231 tags.iter().any(|tag| file_tags.contains(tag))
232 } else {
233 true
234 }
235 })
236 .take(limit.unwrap_or(usize::MAX))
237 .collect::<Vec<String>>()
238 })
239 }
240
241 pub fn search(
242 &self,
243 query: &str,
244 limit: Option<usize>,
245 snippet_max_size: Option<usize>,
246 ) -> Result<Vec<SearchResult>, OrgModeError> {
247 if query.trim().is_empty() {
248 return Ok(vec![]);
249 }
250
251 let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
252 let pattern = Pattern::new(
253 query,
254 CaseMatching::Ignore,
255 Normalization::Smart,
256 AtomKind::Fuzzy,
257 );
258
259 let files = self.list_files(None, None)?;
260 let mut all_results = Vec::new();
261
262 for file in files {
263 let content = match self.read_file(&file) {
264 Ok(content) => content,
265 Err(_) => continue, };
267
268 let matches = pattern.match_list(
269 content.lines().map(|s| s.to_owned()).collect::<Vec<_>>(),
270 &mut matcher,
271 );
272
273 for (snippet, score) in matches {
274 let snippet = Self::snippet(&snippet, snippet_max_size.unwrap_or(100));
275 all_results.push(SearchResult {
276 file_path: file.clone(),
277 snippet,
278 score,
279 tags: self.tags_in_file(&file).unwrap_or_default(),
280 });
281 }
282 }
283
284 all_results.sort_by(|a, b| b.score.cmp(&a.score));
285 all_results.truncate(limit.unwrap_or(all_results.len()));
286
287 Ok(all_results)
288 }
289
290 pub fn search_with_tags(
291 &self,
292 query: &str,
293 tags: Option<&[String]>,
294 limit: Option<usize>,
295 snippet_max_size: Option<usize>,
296 ) -> Result<Vec<SearchResult>, OrgModeError> {
297 self.search(query, None, snippet_max_size)
298 .map(|results| match tags {
299 Some(filter_tags) => results
300 .into_iter()
301 .filter(|r| filter_tags.iter().any(|tag| r.tags.contains(tag)))
302 .collect(),
303 None => results,
304 })
305 .map(|mut all_results| {
306 all_results.truncate(limit.unwrap_or(all_results.len()));
307 all_results
308 })
309 }
310
311 pub fn list_files_by_tags(&self, tags: &[String]) -> Result<Vec<String>, OrgModeError> {
312 self.list_files(Some(tags), None)
313 }
314
315 pub fn read_file(&self, path: &str) -> Result<String, OrgModeError> {
316 let org_dir = PathBuf::from(&self.config.org_directory);
317 let full_path = org_dir.join(path);
318
319 if !full_path.exists() {
320 return Err(OrgModeError::IoError(io::Error::new(
321 io::ErrorKind::NotFound,
322 format!("File not found: {}", path),
323 )));
324 }
325
326 if !full_path.is_file() {
327 return Err(OrgModeError::IoError(io::Error::new(
328 io::ErrorKind::InvalidInput,
329 format!("Path is not a file: {}", path),
330 )));
331 }
332
333 fs::read_to_string(full_path).map_err(OrgModeError::IoError)
334 }
335
336 pub fn get_outline(&self, path: &str) -> Result<TreeNode, OrgModeError> {
337 let content = self.read_file(path)?;
338
339 let mut root = TreeNode::new("Document".into());
340 let mut stack: Vec<TreeNode> = Vec::new();
341
342 let mut handler = from_fn(|event| {
343 if let Event::Enter(Container::Headline(h)) = event {
344 let level = h.level();
345 let label = h.title_raw();
346 let tags = h.tags().map(|s| s.to_string()).collect();
347 let node = TreeNode {
348 label,
349 level,
350 tags,
351 children: Vec::new(),
352 };
353
354 while let Some(n) = stack.last() {
355 if n.level < level {
356 break;
357 }
358 let finished_node = stack.pop().unwrap();
359 if let Some(parent) = stack.last_mut() {
360 parent.children.push(finished_node);
361 } else {
362 root.children.push(finished_node);
363 }
364 }
365
366 stack.push(node);
367 }
368 });
369
370 Org::parse(&content).traverse(&mut handler);
371
372 while let Some(node) = stack.pop() {
373 if let Some(parent) = stack.last_mut() {
374 parent.children.push(node);
375 } else {
376 root.children.push(node);
377 }
378 }
379
380 Ok(root)
381 }
382
383 pub fn get_heading(&self, path: &str, heading: &str) -> Result<String, OrgModeError> {
384 let content = self.read_file(path)?;
385
386 let heading_path: Vec<&str> = heading.split('/').collect();
387 let mut current_level = 0;
388 let mut found = None;
389
390 let mut handler = from_fn_with_ctx(|event, ctx| {
391 if let Event::Enter(Container::Headline(h)) = event {
392 let title = h.title_raw();
393 let level = h.level();
394
395 if let Some(part) = heading_path.get(current_level) {
396 if title == *part {
397 if level == heading_path.len() {
398 found = Some(h);
399 ctx.stop();
400 }
401 current_level += 1;
402 }
403 } else {
404 ctx.stop()
405 }
406 }
407 });
408
409 Org::parse(&content).traverse(&mut handler);
410
411 found
412 .ok_or_else(|| OrgModeError::InvalidHeadingPath(heading.into()))
413 .map(|h| h.raw())
414 }
415
416 pub fn get_element_by_id(&self, id: &str) -> Result<String, OrgModeError> {
417 let files = self.list_files(None, None)?;
418
419 let found = files.iter().find_map(|path| {
420 self.read_file(path)
421 .map(|content| self.search_id(content, id))
422 .unwrap_or_default()
423 });
424
425 found.ok_or_else(|| OrgModeError::InvalidElementId(id.into()))
426 }
427
428 fn search_id(&self, content: String, id: &str) -> Option<String> {
429 let mut found = None;
430 let has_id_property = |properties: Option<PropertyDrawer>| {
431 properties
432 .and_then(|props| {
433 props
434 .to_hash_map()
435 .into_iter()
436 .find(|(k, v)| k.to_lowercase() == "id" && v == id)
437 })
438 .is_some()
439 };
440 let mut handler = from_fn_with_ctx(|event, ctx| {
441 if let Event::Enter(Container::Headline(ref h)) = event
442 && has_id_property(h.properties())
443 {
444 found = Some(h.raw());
445 ctx.stop();
446 } else if let Event::Enter(Container::Document(ref d)) = event
447 && has_id_property(d.properties())
448 {
449 found = Some(d.raw());
450 ctx.stop();
451 }
452 });
453
454 Org::parse(&content).traverse(&mut handler);
455
456 found
457 }
458
459 fn snippet(s: &str, max: usize) -> String {
460 if s.chars().count() > max {
461 s.chars().take(max).collect::<String>() + "..."
462 } else {
463 s.to_string()
464 }
465 }
466
467 fn tags_in_file(&self, path: &str) -> Result<Vec<String>, OrgModeError> {
468 let content = self.read_file(path)?;
469 let mut tags = Vec::new();
470
471 let mut handler = from_fn(|event| {
472 if let Event::Enter(Container::Headline(h)) = event {
473 tags.extend(h.tags().map(|s| s.to_string()));
474 }
475 });
476
477 Org::parse(&content).traverse(&mut handler);
478
479 Ok(tags)
480 }
481
482 fn files_in_path(&self, path: &str) -> Result<impl Iterator<Item = PathBuf>, OrgModeError> {
483 let org_root = PathBuf::from(&self.config.org_directory);
484
485 let path = tilde(&path).into_owned();
486 let path = if PathBuf::from(&path).is_absolute() {
487 path.to_string()
488 } else {
489 org_root.join(&path).to_str().unwrap_or(&path).to_string()
490 };
491
492 let root = path
493 .split_once('*')
494 .map(|(prefix, _)| prefix)
495 .unwrap_or(&path);
496
497 let globset = GlobSetBuilder::new().add(Glob::new(&path)?).build()?;
498
499 let iter = WalkBuilder::new(root)
500 .build()
501 .flatten()
502 .filter(move |e| e.path().is_file() && globset.is_match(e.path()))
503 .map(|e| e.path().to_path_buf());
504
505 Ok(iter)
506 }
507
508 fn agenda_tasks(&self) -> impl Iterator<Item = (Headline, String)> {
509 self.config
510 .org_agenda_files
511 .iter()
512 .filter_map(|loc| self.files_in_path(loc).ok())
513 .flatten()
514 .collect::<HashSet<_>>()
515 .into_iter()
516 .flat_map(|file| {
517 let config = ParseConfig {
518 todo_keywords: (
519 self.config.unfinished_keywords(),
520 self.config.finished_keywords(),
521 ),
522 ..Default::default()
523 };
524 let org = config.parse(fs::read_to_string(&file).unwrap_or_default());
526
527 let org_root = Path::new(&self.config.org_directory);
528
529 let mut tasks = Vec::new();
530 let mut handler = from_fn(|event| {
531 if let Event::Enter(container) = event
532 && let Container::Headline(headline) = container
533 && (headline.is_todo() || headline.is_done())
534 {
535 let file_path = file
536 .strip_prefix(org_root)
537 .unwrap_or(&file)
538 .to_string_lossy()
539 .to_string();
540 tasks.push((headline, file_path));
541 }
542 });
543 org.traverse(&mut handler);
544 tasks
545 })
546 }
547
548 pub fn list_tasks(
559 &self,
560 todo_states: Option<&[String]>,
561 tags: Option<&[String]>,
562 priority: Option<Priority>,
563 limit: Option<usize>,
564 ) -> Result<Vec<AgendaItem>, OrgModeError> {
565 let tasks = self
566 .agenda_tasks()
567 .filter(|(headline, _)| {
568 headline.is_todo()
569 && tags
570 .map(|tags| {
571 tags_match(
572 &headline.tags().map(|s| s.to_string()).collect::<Vec<_>>(),
573 tags,
574 )
575 })
576 .unwrap_or(true)
577 && priority
578 .as_ref()
579 .map(|p| {
580 if let Some(prio) = headline.priority() {
581 let prio = prio.to_string();
582 matches!(
583 (p, prio.as_str()),
584 (Priority::A, "A") | (Priority::B, "B") | (Priority::C, "C")
585 )
586 } else {
587 *p == Priority::None
588 }
589 })
590 .unwrap_or(true)
591 && todo_states
592 .map(|states| {
593 headline
594 .todo_keyword()
595 .map(|kw| states.contains(&kw.to_string()))
596 .unwrap_or(false)
597 })
598 .unwrap_or(true)
599 })
600 .map(|(headline, file_path)| Self::headline_to_agenda_item(&headline, file_path))
601 .take(limit.unwrap_or(usize::MAX))
602 .collect::<Vec<_>>();
603
604 Ok(tasks)
605 }
606
607 pub fn get_agenda_view(
617 &self,
618 agenda_view_type: AgendaViewType,
619 todo_states: Option<&[String]>,
620 tags: Option<&[String]>,
621 ) -> Result<AgendaView, OrgModeError> {
622 let items = self
623 .agenda_tasks()
624 .filter(|(headline, _)| {
625 tags.map(|tags| {
626 tags_match(
627 &headline.tags().map(|s| s.to_string()).collect::<Vec<_>>(),
628 tags,
629 )
630 })
631 .unwrap_or(true)
632 && todo_states
633 .map(|states| {
634 headline
635 .todo_keyword()
636 .map(|kw| states.contains(&kw.to_string()))
637 .unwrap_or(false)
638 })
639 .unwrap_or(true)
640 && self.is_in_agenda_range(headline, &agenda_view_type)
641 })
642 .map(|(headline, file_path)| Self::headline_to_agenda_item(&headline, file_path))
643 .collect::<Vec<_>>();
644
645 Ok(AgendaView {
646 items,
647 start_date: Some(agenda_view_type.start_date().format("%Y-%m-%d").to_string()),
648 end_date: Some(agenda_view_type.end_date().format("%Y-%m-%d").to_string()),
649 })
650 }
651
652 fn is_in_agenda_range(&self, headline: &Headline, agenda_view_type: &AgendaViewType) -> bool {
654 let start_date = agenda_view_type.start_date();
655 let end_date = agenda_view_type.end_date();
656
657 let timestamps = headline
658 .syntax()
659 .children()
660 .filter(|c| !Headline::can_cast(c.kind()))
661 .flat_map(|node| node.descendants().filter_map(Timestamp::cast))
662 .filter(|ts| ts.is_active())
663 .filter(|ts| {
664 headline.scheduled().map(|s| &s != ts).unwrap_or(true)
665 && headline.deadline().map(|s| &s != ts).unwrap_or(true)
666 })
667 .collect::<Vec<_>>();
668
669 let is_within_range = |ts_opt: Option<Timestamp>| {
670 if let Some(ts) = ts_opt
672 && let Some(date) = OrgMode::start_to_chrono(&ts)
673 && let Some(date) = Local.from_local_datetime(&date).single()
674 {
675 if let Some(repeater_value) = ts.repeater_value()
676 && let Some(repeater_unit) = ts.repeater_unit()
677 {
678 let value = repeater_value as u64;
679 let mut current_date =
680 OrgMode::add_repeater_duration(date, value, &repeater_unit);
681
682 while current_date < start_date {
683 current_date =
684 OrgMode::add_repeater_duration(current_date, value, &repeater_unit);
685 }
686
687 current_date >= start_date && current_date <= end_date
688 } else {
689 date >= start_date && date <= end_date
690 }
691 } else {
692 false
693 }
694 };
695
696 is_within_range(headline.scheduled())
697 || is_within_range(headline.deadline())
698 || timestamps.into_iter().any(|ts| is_within_range(Some(ts)))
699 }
700
701 pub fn start_to_chrono(ts: &Timestamp) -> Option<chrono::NaiveDateTime> {
702 convert_timestamp!(ts, start)
703 }
704
705 pub fn end_to_chrono(ts: &Timestamp) -> Option<chrono::NaiveDateTime> {
706 convert_timestamp!(ts, end)
707 }
708}
709
710impl OrgMode {
712 fn to_start_of_day(date: DateTime<Local>) -> DateTime<Local> {
714 date.date_naive()
715 .and_hms_opt(0, 0, 0)
716 .and_then(|dt| Local.from_local_datetime(&dt).single())
717 .unwrap_or(date)
718 }
719
720 fn to_end_of_day(date: DateTime<Local>) -> DateTime<Local> {
722 date.date_naive()
723 .and_hms_opt(23, 59, 59)
724 .and_then(|dt| Local.from_local_datetime(&dt).single())
725 .unwrap_or(date)
726 }
727
728 fn naive_date_to_local(
730 date: NaiveDate,
731 hour: u32,
732 min: u32,
733 sec: u32,
734 ) -> Result<DateTime<Local>, OrgModeError> {
735 date.and_hms_opt(hour, min, sec)
736 .and_then(|dt| Local.from_local_datetime(&dt).single())
737 .ok_or_else(|| {
738 OrgModeError::InvalidAgendaViewType(format!(
739 "Could not convert date '{}' to local timezone",
740 date
741 ))
742 })
743 }
744
745 fn last_day_of_month(date: DateTime<Local>) -> DateTime<Local> {
747 let month = date.month();
748 let year = date.year();
749
750 let (next_month, next_year) = if month == 12 {
752 (1, year + 1)
753 } else {
754 (month + 1, year)
755 };
756
757 let next_month_first = Self::to_start_of_day(
759 date.with_year(next_year)
760 .unwrap()
761 .with_month(next_month)
762 .unwrap()
763 .with_day(1)
764 .unwrap(),
765 );
766
767 next_month_first - Duration::days(1)
769 }
770
771 fn add_repeater_duration(
778 date: DateTime<Local>,
779 value: u64,
780 unit: &orgize::ast::TimeUnit,
781 ) -> DateTime<Local> {
782 match unit {
783 orgize::ast::TimeUnit::Hour => Some(date + Duration::hours(value as i64)),
784 orgize::ast::TimeUnit::Day => date.checked_add_days(Days::new(value)),
785 orgize::ast::TimeUnit::Week => date.checked_add_days(Days::new(value * 7)),
786 orgize::ast::TimeUnit::Month => date.checked_add_months(Months::new(value as u32)),
787 orgize::ast::TimeUnit::Year => date.checked_add_months(Months::new(value as u32 * 12)),
788 }
789 .unwrap_or(date)
790 }
791
792 fn headline_to_agenda_item(headline: &Headline, file_path: String) -> AgendaItem {
798 AgendaItem {
799 file_path,
800 heading: headline.title_raw(),
801 level: headline.level(),
802 todo_state: headline.todo_keyword().map(|t| t.to_string()),
803 priority: headline.priority().map(|p| p.to_string()),
804 deadline: headline.deadline().map(|d| d.raw()),
805 scheduled: headline.scheduled().map(|d| d.raw()),
806 tags: headline.tags().map(|s| s.to_string()).collect(),
807 position: Some(Position {
808 start: headline.start().into(),
809 end: headline.end().into(),
810 }),
811 }
812 }
813
814 fn parse_date_string(date_str: &str, context: &str) -> Result<NaiveDate, OrgModeError> {
820 NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
821 OrgModeError::InvalidAgendaViewType(format!(
822 "Invalid {context} '{date_str}', expected YYYY-MM-DD"
823 ))
824 })
825 }
826}
827
828impl AgendaViewType {
830 pub fn start_date(&self) -> DateTime<Local> {
831 let date = match self {
832 AgendaViewType::Today => Local::now(),
833 AgendaViewType::Day(d) => *d,
834 AgendaViewType::CurrentWeek => {
835 let now = Local::now();
836 let weekday = now.weekday().num_days_from_monday();
837 now - Duration::days(weekday as i64)
838 }
839 AgendaViewType::Week(week_num) => {
840 let now = Local::now();
841 let year_start =
842 OrgMode::to_start_of_day(now.with_month(1).unwrap().with_day(1).unwrap());
843 year_start + Duration::weeks(*week_num as i64)
844 }
845 AgendaViewType::CurrentMonth => {
846 let now = Local::now();
847 now.with_day(1).unwrap()
848 }
849 AgendaViewType::Month(month) => {
850 let now = Local::now();
851 now.with_month(*month).unwrap_or(now).with_day(1).unwrap()
852 }
853 AgendaViewType::Custom { from, .. } => *from,
854 };
855 OrgMode::to_start_of_day(date)
856 }
857
858 pub fn end_date(&self) -> DateTime<Local> {
859 let date = match self {
860 AgendaViewType::Today => Local::now(),
861 AgendaViewType::Day(d) => *d,
862 AgendaViewType::CurrentWeek => {
863 let now = Local::now();
864 let weekday = now.weekday().num_days_from_monday();
865 let start = now - Duration::days(weekday as i64);
866 start + Duration::days(6)
867 }
868 AgendaViewType::Week(week_num) => {
869 let now = Local::now();
870 let year_start =
871 OrgMode::to_start_of_day(now.with_month(1).unwrap().with_day(1).unwrap());
872 let target_week_start = year_start + Duration::weeks(*week_num as i64);
873 target_week_start + Duration::days(6)
874 }
875 AgendaViewType::CurrentMonth => {
876 let now = Local::now();
877 OrgMode::last_day_of_month(now)
878 }
879 AgendaViewType::Month(month) => {
880 let now = Local::now();
881 let date_in_month = now.with_month(*month).unwrap_or(now);
882 OrgMode::last_day_of_month(date_in_month)
883 }
884 AgendaViewType::Custom { to, .. } => *to,
885 };
886 OrgMode::to_end_of_day(date)
887 }
888}
889
890impl TryFrom<&str> for AgendaViewType {
900 type Error = OrgModeError;
901
902 fn try_from(value: &str) -> Result<Self, Self::Error> {
903 if value.is_empty() {
904 return Ok(AgendaViewType::default());
905 }
906
907 match value {
908 "today" => Ok(AgendaViewType::Today),
909 "week" => Ok(AgendaViewType::CurrentWeek),
910 "month" => Ok(AgendaViewType::CurrentMonth),
911 _ => {
912 let parts: Vec<&str> = value.split('/').collect();
914
915 match parts.as_slice() {
916 ["day", date_str] => {
917 let parsed_date = OrgMode::parse_date_string(date_str, "date format")?;
919 let datetime = OrgMode::naive_date_to_local(parsed_date, 0, 0, 0)?;
920 Ok(AgendaViewType::Day(datetime))
921 }
922 ["week", week_str] => {
923 let week_num = week_str.parse::<u8>().map_err(|_| {
925 OrgModeError::InvalidAgendaViewType(format!(
926 "Invalid week number '{}', expected 0-53",
927 week_str
928 ))
929 })?;
930 if week_num > 53 {
931 return Err(OrgModeError::InvalidAgendaViewType(format!(
932 "Week number {} out of range, expected 0-53",
933 week_num
934 )));
935 }
936 Ok(AgendaViewType::Week(week_num))
937 }
938 ["month", month_str] => {
939 let month_num = month_str.parse::<u32>().map_err(|_| {
941 OrgModeError::InvalidAgendaViewType(format!(
942 "Invalid month number '{}', expected 1-12",
943 month_str
944 ))
945 })?;
946 if !(1..=12).contains(&month_num) {
947 return Err(OrgModeError::InvalidAgendaViewType(format!(
948 "Month number {} out of range, expected 1-12",
949 month_num
950 )));
951 }
952 Ok(AgendaViewType::Month(month_num))
953 }
954 ["query", "from", from_str, "to", to_str] => {
955 let from_date = OrgMode::parse_date_string(from_str, "from date")?;
957 let to_date = OrgMode::parse_date_string(to_str, "to date")?;
958
959 let from_datetime = OrgMode::naive_date_to_local(from_date, 0, 0, 0)?;
960 let to_datetime = OrgMode::naive_date_to_local(to_date, 23, 59, 59)?;
961
962 if from_datetime > to_datetime {
963 return Err(OrgModeError::InvalidAgendaViewType(
964 "From date must be before to date".into(),
965 ));
966 }
967 Ok(AgendaViewType::Custom {
968 from: from_datetime,
969 to: to_datetime,
970 })
971 }
972 _ => Err(OrgModeError::InvalidAgendaViewType(format!(
973 "Unknown agenda view type format: '{}'",
974 value
975 ))),
976 }
977 }
978 }
979 }
980}