1use crate::{
30 api::{gitlab::GitLab, jira::Jira},
31 db::tasks::Tasks,
32 db::templates::Templates,
33 libs::{
34 config::Config,
35 messages::Message,
36 stdin_drain::drain_available_stdin_lines,
37 task::{collapse_whitespace, is_ignored_name, normalize_task_name, Task, TaskFilter},
38 view::View,
39 },
40 msg_error, msg_info, msg_print, msg_success,
41};
42use anyhow::Result;
43use chrono::Local;
44use clap::Args;
45use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select};
46use indicatif::{ProgressBar, ProgressStyle};
47use std::collections::{HashMap, HashSet};
48use std::time::Duration;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56enum TaskSource {
57 Incomplete,
59 Gitlab,
61 Jira,
63}
64
65#[derive(Debug, Clone)]
67struct DiscoveryItem {
68 source: TaskSource,
69 task: Task,
70 short_sha: Option<String>,
72}
73
74impl TaskSource {
75 fn priority(self) -> u8 {
77 match self {
78 TaskSource::Incomplete => 0,
79 TaskSource::Jira => 1,
80 TaskSource::Gitlab => 2,
81 }
82 }
83}
84
85fn format_discovery_item(item: &DiscoveryItem) -> String {
86 match item.source {
87 TaskSource::Incomplete => {
88 format!(
89 "↻ {} — {}%",
90 item.task.name,
91 item.task.completeness.unwrap_or(0)
92 )
93 }
94 TaskSource::Jira => format!("◉ {}", item.task.name),
95 TaskSource::Gitlab => match &item.short_sha {
96 Some(sha) => format!("● {} ({})", item.task.name, sha),
97 None => format!("● {}", item.task.name),
98 },
99 }
100}
101
102fn dedup_discovery_items(items: Vec<DiscoveryItem>) -> Vec<DiscoveryItem> {
104 let mut best: HashMap<String, DiscoveryItem> = HashMap::new();
105
106 for item in items {
107 let key = normalize_task_name(&item.task.name);
108 match best.get(&key) {
109 Some(existing) if existing.source.priority() <= item.source.priority() => {}
110 _ => {
111 best.insert(key, item);
112 }
113 }
114 }
115
116 let mut result: Vec<DiscoveryItem> = best.into_values().collect();
117 result.sort_by(|a, b| {
118 a.source
119 .priority()
120 .cmp(&b.source.priority())
121 .then_with(|| a.task.name.cmp(&b.task.name))
122 });
123 result
124}
125
126#[derive(Debug, Args)]
132pub struct TaskArgs {
133 #[arg(short, long)]
139 name: Option<String>,
140
141 #[arg(long)]
147 comment: Option<String>,
148
149 #[arg(short, long)]
158 completeness: Option<i32>,
159
160 #[arg(short, long)]
166 show: bool,
167
168 #[arg(short, long)]
174 all: bool,
175
176 #[arg(short, long)]
182 id: Option<Vec<i32>>,
183
184 #[arg(short, long, help = "Find incomplete tasks")]
192 find: bool,
193
194 #[arg(short, long, help = "Delete tasks by IDs")]
200 delete: Option<Vec<i32>>,
201
202 #[arg(long, help = "Delete all tasks for today")]
208 delete_today: bool,
209
210 #[arg(short, long, help = "Edit task by ID")]
216 edit: Option<i32>,
217
218 #[arg(long, help = "Edit multiple tasks interactively")]
224 edit_interactive: bool,
225
226 #[arg(long, short = 't')]
232 template: Option<String>,
233
234 #[arg(long, short = 'l')]
240 from_template: bool,
241
242 #[arg(long)]
248 tags: Option<String>,
249
250 #[arg(long)]
255 tag: Option<String>,
256}
257
258pub async fn cmd(task_args: TaskArgs) -> Result<()> {
324 let date = Local::now();
325
326 if let Some(ids) = task_args.delete {
328 return handle_delete_by_ids(ids).await;
329 }
330
331 if task_args.delete_today {
332 return handle_delete_today().await;
333 }
334
335 if let Some(id) = task_args.edit {
337 return handle_edit_by_id(id).await;
338 }
339
340 if task_args.edit_interactive {
341 return handle_edit_interactive().await;
342 }
343
344 if let Some(template_name) = task_args.template {
346 return handle_create_from_template(template_name).await;
347 }
348
349 if task_args.from_template {
350 return handle_create_from_template_interactive().await;
351 }
352
353 if task_args.show {
355 let mut filter: TaskFilter = TaskFilter::Date(date.date_naive());
356
357 if task_args.all {
359 filter = TaskFilter::All;
360 } else if task_args.id.is_some() {
361 filter = TaskFilter::ByIds(task_args.id.unwrap());
362 } else if let Some(tag) = task_args.tag {
363 filter = TaskFilter::ByTag(tag);
364 }
365
366 let tasks = Tasks::new()?.fetch(filter)?;
367 if tasks.is_empty() {
368 msg_error!(Message::TaskNotFound);
369 return Ok(());
370 }
371 View::tasks(&tasks)?;
372
373 return Ok(());
374 } else if task_args.find {
375 return handle_task_discovery(date).await;
377 }
378
379 handle_task_creation(task_args).await
381}
382
383async fn handle_task_discovery(date: chrono::DateTime<Local>) -> Result<()> {
394 let date_naive = date.date_naive();
395 let mut config = Config::read()?;
396 let gitlab_config = config.gitlab.clone();
397 let jira_config = config.jira.clone();
398 let ignore_names = config.effective_ignore_names();
399
400 let spinner = ProgressBar::new_spinner();
401 spinner.set_style(
402 ProgressStyle::with_template("{spinner:.cyan} {msg}")
403 .unwrap_or_else(|_| ProgressStyle::default_spinner())
404 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
405 );
406 spinner.enable_steady_tick(Duration::from_millis(80));
407 spinner.set_message(Message::TasksDiscoverySearchingIncomplete.to_string());
408
409 let incomplete_tasks = Tasks::new()?.fetch(TaskFilter::Incomplete)?;
410 let today_tasks = Tasks::new()?.fetch(TaskFilter::Date(date_naive))?;
411 let today_names: HashSet<String> = today_tasks.iter().map(|t| normalize_task_name(&t.name)).collect();
412
413 spinner.set_message(Message::TasksDiscoveryFetchingExternal.to_string());
414
415 let (commits, jira_issues) = tokio::join!(
416 async {
417 match gitlab_config {
418 Some(cfg) => GitLab::new(&cfg).get_today_commits().await.unwrap_or_default(),
419 None => Vec::new(),
420 }
421 },
422 async {
423 match jira_config {
424 Some(cfg) => {
425 let mut jira = Jira::new(&cfg);
426 jira.get_completed_issues(&date_naive).await.unwrap_or_default()
427 }
428 None => Vec::new(),
429 }
430 },
431 );
432
433 spinner.finish_and_clear();
434
435 let mut candidates: Vec<DiscoveryItem> = Vec::new();
436
437 for task in incomplete_tasks {
438 if is_ignored_name(&task.name, &ignore_names) {
439 continue;
440 }
441 if today_names.contains(&normalize_task_name(&task.name)) {
442 continue;
443 }
444 candidates.push(DiscoveryItem {
445 source: TaskSource::Incomplete,
446 task,
447 short_sha: None,
448 });
449 }
450
451 for issue in jira_issues {
452 let name = format!("{} {}", issue.key, issue.fields.summary);
453 if is_ignored_name(&name, &ignore_names) {
454 continue;
455 }
456 if today_names.contains(&normalize_task_name(&name)) {
457 continue;
458 }
459 candidates.push(DiscoveryItem {
460 source: TaskSource::Jira,
461 task: Task::new(&name, "", Some(100)),
462 short_sha: None,
463 });
464 }
465
466 for commit in commits {
467 if is_ignored_name(&commit.message, &ignore_names) {
468 continue;
469 }
470 if today_names.contains(&normalize_task_name(&commit.message)) {
471 continue;
472 }
473 let short_sha = if commit.sha.len() >= 7 {
474 Some(commit.sha[..7].to_string())
475 } else if commit.sha.is_empty() {
476 None
477 } else {
478 Some(commit.sha.clone())
479 };
480 candidates.push(DiscoveryItem {
481 source: TaskSource::Gitlab,
482 task: Task::new(&commit.message, "", Some(100)),
483 short_sha,
484 });
485 }
486
487 let items = dedup_discovery_items(candidates);
488
489 if items.is_empty() {
490 msg_error!(Message::TasksNotFoundSad);
491 return Ok(());
492 }
493
494 let incomplete_count = items.iter().filter(|i| i.source == TaskSource::Incomplete).count();
495 let jira_count = items.iter().filter(|i| i.source == TaskSource::Jira).count();
496 let gitlab_count = items.iter().filter(|i| i.source == TaskSource::Gitlab).count();
497
498 msg_print!(
499 Message::TasksDiscoverySummary {
500 incomplete: incomplete_count,
501 jira: jira_count,
502 gitlab: gitlab_count,
503 },
504 true
505 );
506
507 let mut labels: Vec<String> = Vec::new();
509 let mut index_map: Vec<Option<usize>> = Vec::new();
510
511 for (idx, item) in items.iter().enumerate() {
512 if item.source == TaskSource::Incomplete {
513 labels.push(format_discovery_item(item));
514 index_map.push(Some(idx));
515 }
516 }
517
518 let has_rest = items.iter().any(|i| i.source != TaskSource::Incomplete);
519 if incomplete_count > 0 && has_rest {
520 labels.push(Message::TasksDiscoverySeparator.to_string());
521 index_map.push(None);
522 }
523
524 for (idx, item) in items.iter().enumerate() {
525 if item.source != TaskSource::Incomplete {
526 labels.push(format_discovery_item(item));
527 index_map.push(Some(idx));
528 }
529 }
530
531 let selected = MultiSelect::with_theme(&ColorfulTheme::default())
532 .with_prompt(Message::PromptSelectTasksToImport.to_string())
533 .items(&labels)
534 .interact()
535 .unwrap_or_default();
536
537 for sel in selected {
538 let Some(item_idx) = index_map.get(sel).copied().flatten() else {
539 continue; };
541 let Some(item) = items.get(item_idx) else {
542 continue;
543 };
544
545 let mut task = item.task.clone();
546
547 if item.source == TaskSource::Incomplete {
548 msg_print!(Message::SelectingTask(task.name.clone()));
549
550 if task.task_id.is_none() || task.task_id.is_some_and(|id| id == 0) {
551 task.task_id = task.id;
552 }
553
554 let default_completeness = (task.completeness.unwrap_or(0) + 1).min(100);
555 task.completeness = Some(
556 Input::with_theme(&ColorfulTheme::default())
557 .allow_empty(true)
558 .with_prompt(Message::PromptTaskCompleteness.to_string())
559 .default(default_completeness)
560 .interact_text()
561 .unwrap(),
562 );
563 }
564
565 let _ = Tasks::new()?.insert(&task);
566 }
567
568 let ignore_selected = MultiSelect::with_theme(&ColorfulTheme::default())
570 .with_prompt(Message::PromptSelectTasksToIgnore.to_string())
571 .items(&labels)
572 .interact()
573 .unwrap_or_default();
574
575 if !ignore_selected.is_empty() {
576 let mut names_to_ignore = Vec::new();
577 for sel in ignore_selected {
578 let Some(item_idx) = index_map.get(sel).copied().flatten() else {
579 continue;
580 };
581 if let Some(item) = items.get(item_idx) {
582 names_to_ignore.push(item.task.name.clone());
583 }
584 }
585
586 if !names_to_ignore.is_empty() {
587 let added = config.add_ignore_names(&names_to_ignore)?;
588 if added > 0 {
589 msg_success!(Message::TaskDiscoveryIgnoreNamesAdded(added));
590 }
591 }
592 }
593
594 Ok(())
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use crate::libs::config::default_ignore_names;
601
602 #[test]
603 fn normalize_collapses_whitespace_and_punctuation() {
604 assert_eq!(normalize_task_name("New commit"), "new commit");
605 assert_eq!(normalize_task_name("New commit "), "new commit");
606 assert_eq!(normalize_task_name("New commit."), "new commit");
607 assert_eq!(normalize_task_name(" New commit"), "new commit");
608 assert_eq!(normalize_task_name("New commit..."), "new commit");
609 }
610
611 #[test]
612 fn collapse_whitespace_turns_newlines_into_spaces() {
613 let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
614 assert_eq!(
615 collapse_whitespace(pasted),
616 "PROJ-42 Fix login redirect for OAuth callback"
617 );
618 assert_eq!(collapse_whitespace(" spaced out "), "spaced out");
619 }
620
621 #[test]
622 fn looks_like_issue_key_detects_jira_keys() {
623 assert!(looks_like_issue_key("PROJ-42"));
624 assert!(looks_like_issue_key("ABC-1001"));
625 assert!(!looks_like_issue_key("PROJ-42 summary"));
626 assert!(!looks_like_issue_key("update alert"));
627 assert!(!looks_like_issue_key(""));
628 }
629
630 #[test]
631 fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
632 let ignore = default_ignore_names();
633 assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
634
635 assert!(is_ignored_name(
636 "Merge remote-tracking branch 'origin/release/4.39.0' into feature/x",
637 &ignore
638 ));
639 assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
640 assert!(is_ignored_name("update webui", &ignore));
641 assert!(is_ignored_name(" Update WebUI ", &ignore));
642 assert!(!is_ignored_name("update alert", &ignore));
643 assert!(!is_ignored_name("Fix login validation", &ignore));
644 }
645
646 #[test]
647 fn custom_ignore_list_filters_update_alert() {
648 let mut ignore = default_ignore_names();
649 ignore.push("update alert".to_string());
650 assert!(is_ignored_name("update alert", &ignore));
651 assert!(is_ignored_name("Update alert.", &ignore));
652 }
653
654 #[test]
655 fn dedup_prefers_incomplete_over_jira_over_gitlab() {
656 let items = vec![
657 DiscoveryItem {
658 source: TaskSource::Gitlab,
659 task: Task::new("New commit.", "", Some(100)),
660 short_sha: Some("abc1234".into()),
661 },
662 DiscoveryItem {
663 source: TaskSource::Jira,
664 task: Task::new("New commit", "", Some(100)),
665 short_sha: None,
666 },
667 DiscoveryItem {
668 source: TaskSource::Incomplete,
669 task: Task::new(" New commit", "", Some(40)),
670 short_sha: None,
671 },
672 ];
673
674 let deduped = dedup_discovery_items(items);
675 assert_eq!(deduped.len(), 1);
676 assert_eq!(deduped[0].source, TaskSource::Incomplete);
677 assert_eq!(deduped[0].task.name, "New commit");
678 }
679}
680
681fn prompt_task_name_interactive() -> String {
683 let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string())
684 .unwrap_or_default();
685 let name = collapse_whitespace(&raw);
686 if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
687 msg_info!(Message::TaskNameMergedFromPaste);
688 }
689 if !name.is_empty() {
690 println!("✔ {}", name);
691 }
692 name
693}
694
695fn looks_like_issue_key(name: &str) -> bool {
697 let name = name.trim();
698 if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
699 return false;
700 }
701 let Some((prefix, rest)) = name.split_once('-') else {
702 return false;
703 };
704 !prefix.is_empty()
705 && prefix.chars().all(|c| c.is_ascii_alphanumeric())
706 && !rest.is_empty()
707 && rest.chars().all(|c| c.is_ascii_digit())
708}
709
710async fn handle_task_creation(task_args: TaskArgs) -> Result<()> {
721 let name_from_args = task_args.name.is_some();
723 let comment_from_args = task_args.comment.is_some();
724
725 let mut name = match task_args.name {
726 Some(n) => collapse_whitespace(&n),
727 None => prompt_task_name_interactive(),
728 };
729
730 let mut comment = match task_args.comment {
731 Some(c) => collapse_whitespace(&c),
732 None => {
733 let raw: String = Input::with_theme(&ColorfulTheme::default())
734 .allow_empty(true)
735 .with_prompt(Message::PromptTaskComment.to_string())
736 .interact_text()
737 .unwrap();
738 collapse_whitespace(&raw)
739 }
740 };
741
742 if !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
745 name = collapse_whitespace(&format!("{} {}", name, comment));
746 msg_info!(Message::TaskNameMergedFromPaste);
747 let raw: String = Input::with_theme(&ColorfulTheme::default())
748 .allow_empty(true)
749 .with_prompt(Message::PromptTaskComment.to_string())
750 .interact_text()
751 .unwrap();
752 comment = collapse_whitespace(&raw);
753 }
754
755 let _ = drain_available_stdin_lines();
757
758 let completeness = task_args.completeness.unwrap_or_else(|| {
759 Input::with_theme(&ColorfulTheme::default())
760 .allow_empty(true)
761 .with_prompt(Message::PromptTaskCompleteness.to_string())
762 .default(100)
763 .interact_text()
764 .unwrap()
765 });
766
767 let task = Task::new(&name, &comment, Some(completeness));
769 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
770 View::tasks(&new_task)?;
771
772 if let Some(tags_str) = task_args.tags {
774 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
775
776 let mut tags_db = crate::db::tags::Tags::new()?;
777 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
778
779 if let Some(task_id) = new_task[0].id {
780 tags_db.set_task_tags(task_id, &tag_ids)?;
781 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
782 }
783 }
784
785 Ok(())
786}
787
788async fn handle_delete_by_ids(ids: Vec<i32>) -> Result<()> {
806 if ids.is_empty() {
807 msg_error!(Message::NoTaskIdsProvided);
808 return Ok(());
809 }
810
811 let mut tasks_db = Tasks::new()?;
812
813 let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
815
816 if tasks.is_empty() {
817 msg_error!(Message::TasksNotFoundForIds(ids));
818 return Ok(());
819 }
820
821 msg_print!(Message::TasksToBeDeleted, true);
823 View::tasks(&tasks)?;
824
825 let confirmed = if ids.len() == 1 {
827 Confirm::with_theme(&ColorfulTheme::default())
828 .with_prompt(Message::ConfirmDeleteTask.to_string())
829 .default(false)
830 .interact()?
831 } else {
832 Confirm::with_theme(&ColorfulTheme::default())
833 .with_prompt(Message::ConfirmDeleteTasks(ids.len()).to_string())
834 .default(false)
835 .interact()?
836 };
837
838 if confirmed {
839 let deleted_count = tasks_db.delete_many(&ids)?;
840 msg_success!(Message::TasksDeletedCount(deleted_count));
841 } else {
842 msg_info!(Message::OperationCancelled);
843 }
844
845 Ok(())
846}
847
848async fn handle_delete_today() -> Result<()> {
862 let mut tasks_db = Tasks::new()?;
863 let today = Local::now().date_naive();
864
865 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
867
868 if tasks.is_empty() {
869 msg_info!(Message::NoTasksForToday);
870 return Ok(());
871 }
872
873 msg_print!(Message::TasksToBeDeleted, true);
875 View::tasks(&tasks)?;
876
877 let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
879 .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
880 .default(false)
881 .interact()?;
882
883 if !first_confirm {
884 msg_info!(Message::OperationCancelled);
885 return Ok(());
886 }
887
888 let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
890 .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
891 .default(false)
892 .interact()?;
893
894 if second_confirm {
895 let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
896 let deleted_count = tasks_db.delete_many(&ids)?;
897 msg_success!(Message::TasksDeletedCount(deleted_count));
898 } else {
899 msg_info!(Message::OperationCancelled);
900 }
901
902 Ok(())
903}
904
905async fn handle_edit_by_id(id: i32) -> Result<()> {
915 let mut tasks_db = Tasks::new()?;
916
917 let task = match tasks_db.get_by_id(id)? {
919 Some(task) => task,
920 None => {
921 msg_error!(Message::TaskNotFoundWithId(id));
922 return Ok(());
923 }
924 };
925
926 msg_print!(Message::CurrentTaskState, true);
928 View::tasks(&[task.clone()])?;
929
930 let edited_task = edit_task_interactive(&task)?;
932
933 if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
935 msg_info!(Message::NoChangesDetected);
936 return Ok(());
937 }
938
939 msg_print!(Message::TaskEditPreview, true);
941 View::tasks(&[edited_task.clone()])?;
942
943 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
945 .with_prompt(Message::ConfirmTaskUpdate.to_string())
946 .default(true)
947 .interact()?;
948
949 if confirmed {
950 let mut task_to_update = task;
951 task_to_update.update_from(&edited_task);
952 tasks_db.update(&task_to_update)?;
953 msg_success!(Message::TaskUpdated);
954 } else {
955 msg_info!(Message::OperationCancelled);
956 }
957
958 Ok(())
959}
960
961async fn handle_edit_interactive() -> Result<()> {
967 let mut tasks_db = Tasks::new()?;
968
969 let today = Local::now().date_naive();
971 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
972
973 if tasks.is_empty() {
974 msg_info!(Message::NoTasksForToday);
975 return Ok(());
976 }
977
978 let task_descriptions: Vec<String> = tasks
980 .iter()
981 .map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
982 .collect();
983
984 let selections = MultiSelect::with_theme(&ColorfulTheme::default())
985 .with_prompt(Message::SelectTasksToEdit.to_string())
986 .items(&task_descriptions)
987 .interact()?;
988
989 if selections.is_empty() {
990 msg_info!(Message::NoTasksSelected);
991 return Ok(());
992 }
993
994 for &index in &selections {
996 let task = &tasks[index];
997
998 msg_print!(Message::EditingTask(task.name.clone()), true);
999 View::tasks(&[task.clone()])?;
1000
1001 let edited_task = edit_task_interactive(task)?;
1002
1003 if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
1005 let mut task_to_update = task.clone();
1006 task_to_update.update_from(&edited_task);
1007 tasks_db.update(&task_to_update)?;
1008 msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
1009 } else {
1010 msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
1011 }
1012 }
1013
1014 msg_success!(Message::TaskEditingCompleted);
1015 Ok(())
1016}
1017
1018fn edit_task_interactive(task: &Task) -> Result<Task> {
1032 let name = collapse_whitespace(
1033 &Input::with_theme(&ColorfulTheme::default())
1034 .with_prompt(Message::PromptTaskNameEdit.to_string())
1035 .default(task.name.clone())
1036 .interact_text()?,
1037 );
1038
1039 let comment = collapse_whitespace(
1040 &Input::with_theme(&ColorfulTheme::default())
1041 .with_prompt(Message::PromptTaskCommentEdit.to_string())
1042 .default(task.comment.clone())
1043 .allow_empty(true)
1044 .interact_text()?,
1045 );
1046
1047 let completeness_range_msg = Message::TaskCompletenessRange.to_string();
1048 let completeness = Input::with_theme(&ColorfulTheme::default())
1049 .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
1050 .default(task.completeness.unwrap_or(100))
1051 .validate_with(|input: &i32| -> Result<(), &str> {
1052 if *input >= 0 && *input <= 100 {
1053 Ok(())
1054 } else {
1055 Err(&completeness_range_msg)
1056 }
1057 })
1058 .interact_text()?;
1059
1060 Ok(Task {
1061 id: task.id,
1062 task_id: task.task_id,
1063 timestamp: task.timestamp.clone(),
1064 name,
1065 comment,
1066 completeness: Some(completeness),
1067 excluded_from_search: task.excluded_from_search,
1068 tags: vec![], })
1070}
1071
1072async fn handle_create_from_template(template_name: String) -> Result<()> {
1082 let mut templates_db = Templates::new()?;
1083 let template = match templates_db.get(&template_name)? {
1084 Some(t) => t,
1085 None => {
1086 msg_error!(Message::TemplateNotFound(template_name));
1087 return Ok(());
1088 }
1089 };
1090
1091 msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
1092
1093 let name = Input::with_theme(&ColorfulTheme::default())
1095 .with_prompt(Message::PromptTaskName.to_string())
1096 .default(template.task_name)
1097 .interact_text()?;
1098
1099 let comment = Input::with_theme(&ColorfulTheme::default())
1100 .with_prompt(Message::PromptTaskComment.to_string())
1101 .default(template.comment)
1102 .allow_empty(true)
1103 .interact_text()?;
1104
1105 let completeness = Input::with_theme(&ColorfulTheme::default())
1106 .with_prompt(Message::PromptTaskCompleteness.to_string())
1107 .default(template.completeness)
1108 .interact_text()?;
1109
1110 let task = Task::new(&name, &comment, Some(completeness));
1112 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
1113 View::tasks(&new_task)?;
1114
1115 Ok(())
1116}
1117
1118async fn handle_create_from_template_interactive() -> Result<()> {
1123 let mut templates_db = Templates::new()?;
1124 let templates = templates_db.get_all()?;
1125
1126 if templates.is_empty() {
1127 msg_info!(Message::NoTemplatesFound);
1128 msg_info!(Message::CreateTemplateFirst);
1129 return Ok(());
1130 }
1131
1132 let template_options: Vec<String> = templates.iter().map(|t| format!("{} - {}", t.name, t.task_name)).collect();
1133
1134 let selection = Select::with_theme(&ColorfulTheme::default())
1135 .with_prompt(Message::SelectTemplate.to_string())
1136 .items(&template_options)
1137 .interact()?;
1138
1139 let template = &templates[selection];
1140 handle_create_from_template(template.name.clone()).await
1141}