#![forbid(unsafe_code)]
use std::fmt::{self, Display, Formatter};
use std::fs::{self, File};
use arboard::Clipboard;
use chrono::{Datelike, Days, Local, NaiveDate};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Text},
widgets::{Block, Paragraph, Row, Table, TableState, Wrap},
};
use tui_dialog::{Dialog, centered_rect};
use crate::priority::make_priority_map;
use crate::{
App, CompletionStatus, FileStatus, Priority, RichTask, SIDEBAR_SIZE, TASK_IDENTIFIERS, TfError,
};
#[derive(PartialEq)]
pub enum RecurringStatus {
All,
Recurring,
NonRecurring,
}
impl Display for RecurringStatus {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
RecurringStatus::All => write!(f, ""),
RecurringStatus::NonRecurring => write!(f, "nonrecurring "),
RecurringStatus::Recurring => write!(f, "recurring "),
}
}
}
pub struct TasksMode {
pub data: Vec<RichTask>,
pub table: TableState,
pub file_status: FileStatus,
pub completion_status: CompletionStatus,
pub recurring_status: RecurringStatus,
pub tag_dialog: Dialog,
pub search_dialog: Dialog,
pub dates: Dates,
}
pub fn render(
app: &mut App,
frame: &mut Frame,
mut info_block: Block,
mut main_block: Block,
top_right: Rect,
left: Rect,
controls_content: &mut Vec<Line<'_>>,
) {
info_block = info_block.title_top(Line::from(" Status ").centered());
let mut info_content = vec![];
let mut overview_text = vec![
"Viewing ".into(),
format!("{} ", app.taskmode.data.len()).into(),
format!("{}", app.taskmode.recurring_status).green(),
format!("{}", app.taskmode.completion_status).green(),
" tasks".into(),
];
if !app.taskmode.search_dialog.submitted_input.is_empty() {
overview_text.push(" that include the term ".into());
overview_text.push(
app.taskmode
.search_dialog
.submitted_input
.to_string()
.green(),
)
}
overview_text.push(" from ".into());
overview_text.push(format!("{}", app.taskmode.file_status).green());
overview_text.push(" files".into());
if !app.taskmode.tag_dialog.submitted_input.is_empty() {
overview_text.push(", tagged ".into());
overview_text.push(app.taskmode.tag_dialog.submitted_input.to_string().green());
}
overview_text.push(".".into());
match app.taskmode.completion_status {
CompletionStatus::Incomplete => {
let mut due_today = 0;
let mut due_tomorrow = 0;
let mut due_this_week = 0;
for t in app.taskmode.data.iter() {
if !t.task.completed
&& let Some(v) = t.task.due_date
{
if v <= app.taskmode.dates.next_week {
due_this_week += 1;
}
if v == app.taskmode.dates.today {
due_today += 1;
}
if v == app.taskmode.dates.tomorrow {
due_tomorrow += 1;
}
}
}
if due_this_week != 0 {
overview_text.push(format!(" ({due_this_week} due in the next week").into());
if due_today != 0 || due_tomorrow != 0 {
overview_text
.push(format!(" - {due_today} today and {due_tomorrow} tomorrow").into())
}
overview_text.push(".)".into());
}
}
CompletionStatus::Completed => {
let mut completed_today = 0;
let mut completed_yesterday = 0;
let mut completed_past_week = 0;
for t in app.taskmode.data.iter() {
if t.task.completed
&& let Some(v) = t.task.completed_date
{
if v >= app.taskmode.dates.last_week {
completed_past_week += 1;
}
if v == app.taskmode.dates.today {
completed_today += 1;
}
if v == app.taskmode.dates.yesterday {
completed_yesterday += 1;
}
}
}
if completed_past_week != 0 {
overview_text
.push(format!(" ({completed_past_week} completed in past week").into());
if completed_today != 0 || completed_yesterday != 0 {
overview_text.push(
format!(" - {completed_today} today and {completed_yesterday} yesterday")
.into(),
)
}
overview_text.push(".)".into());
}
}
}
info_content.push(Line::from(overview_text));
info_content.push(Line::from(""));
if !app.taskmode.data.is_empty()
&& let Some(v) = app.taskmode.table.selected()
{
let task = app.taskmode.data[v].clone();
info_content.push(Line::from(vec!["File: ".green(), task.file_name.into()]));
info_content.push(Line::from(vec!["Task set: ".green(), task.task_set.into()]));
if !task.task.tags.is_empty() {
info_content.push(Line::from(vec![
"Tags: ".green(),
task.task.tags.join(", ").into(),
]))
}
match app.taskmode.completion_status {
CompletionStatus::Incomplete => {
if let Some(due_date) = task.task.due_date {
info_content.push(Line::from(vec![
"Due day: ".green(),
format!("{}", due_date.weekday()).into(),
]));
}
}
CompletionStatus::Completed => {
if let Some(completed_date) = task.task.completed_date {
info_content.push(Line::from(vec![
"Completed day: ".green(),
format!("{}", completed_date.weekday()).into(),
]));
}
}
}
};
if let Some(v) = &app.message_to_user {
info_content.push(Line::from(""));
info_content.push(Line::from(v.clone()));
}
frame.render_widget(
Paragraph::new(info_content)
.wrap(Wrap { trim: false })
.block(info_block),
top_right,
);
main_block = main_block
.title(" Tasks ".bold())
.title_alignment(Alignment::Center);
let header_style = Style::default().bg(Color::DarkGray);
let selected_style = Style::default().add_modifier(Modifier::REVERSED);
let widths = vec![
Constraint::Min(9),
Constraint::Min(6),
Constraint::Percentage(100),
Constraint::Min(11),
];
let mut rows: Vec<Row> = vec![];
if app.taskmode.data.is_empty() {
rows.push(Row::new([
Text::from(""),
Text::from(""),
Text::from(format!(
"No files/tasks to show for current filters at configured path {:?}.",
app.config.path
)),
]))
} else {
let priority_map = make_priority_map(app.config.priority_markers.clone());
for task in &mut app.taskmode.data {
let due_date = if let Some(v) = task.task.due_date {
let text = if v == app.taskmode.dates.today {
Text::from("today")
} else if v == app.taskmode.dates.tomorrow {
Text::from("tomorrow")
} else if v == app.taskmode.dates.yesterday {
Text::from("yesterday")
} else if app.taskmode.dates.last_week <= v && v <= app.taskmode.dates.next_week {
Text::from(format!("{}", v.format("%a, %b %e")))
} else {
Text::from(format!("{v}"))
};
if !task.task.completed {
if app.taskmode.dates.today > v {
text.red()
} else {
text
}
} else {
text
}
} else {
Text::from("")
};
let completed_date = if let Some(v) = task.task.completed_date {
if !task.task.completed || v > app.taskmode.dates.today {
Text::from(" ?")
} else if v == app.taskmode.dates.today {
Text::from("today")
} else if v == app.taskmode.dates.yesterday {
Text::from("yesterday")
} else if v >= app.taskmode.dates.last_week {
Text::from(format!("{}", v.format("%a, %b %e")))
} else {
Text::from(format!("{v}"))
}
} else {
Text::from("")
};
let mut text = task.task.text.clone().trim_ascii_start().to_string();
for ider in TASK_IDENTIFIERS {
if let Some(v) = text.strip_prefix(ider) {
text = v.trim_ascii_start().to_string();
break;
}
}
let order = match task.task.order {
Some(v) => Text::from(format!("{v}")),
None => Text::from(""),
};
let priority = match task.task.priority {
Some(Priority::NoPriority) | None => Text::from("").alignment(Alignment::Center),
Some(v) => {
Text::from(v.as_string(priority_map.clone())).alignment(Alignment::Center)
}
};
match app.taskmode.completion_status {
CompletionStatus::Incomplete => {
rows.push(Row::new(vec![
priority,
order.alignment(Alignment::Center),
Text::from(text),
due_date,
]));
}
CompletionStatus::Completed => {
rows.push(Row::new(vec![
priority,
order.alignment(Alignment::Center),
Text::from(text),
completed_date,
]));
}
}
}
}
let dd_or_cd = match app.taskmode.completion_status {
CompletionStatus::Incomplete => Text::from("due"),
CompletionStatus::Completed => Text::from("completed"),
};
let table = Table::new(rows, widths)
.header(
Row::new(vec![
Text::from("priority"),
Text::from("order"),
Text::from("task name"),
dd_or_cd,
])
.style(header_style),
)
.row_highlight_style(selected_style)
.flex(ratatui::layout::Flex::SpaceBetween);
frame.render_stateful_widget(table.block(main_block), left, &mut app.taskmode.table);
let dialog_area = centered_rect(frame.area(), 45, 5, -(SIDEBAR_SIZE as i16), 0);
frame.render_widget(
app.taskmode.tag_dialog.title_top("Enter tag to filter by:"),
dialog_area,
);
frame.render_widget(
app.taskmode
.search_dialog
.title_top("Enter search term to filter by:"),
dialog_area,
);
controls_content.push(Line::from(""));
controls_content.push(Line::from("Navigation".blue().bold().underlined()));
controls_content.push(vec!["Home".blue(), " Go to top".into()].into());
controls_content.push(
vec![
"↑".blue(),
" or ".into(),
"k".blue(),
" Previous task".into(),
]
.into(),
);
controls_content.push(vec!["↓".blue(), " or ".into(), "j".blue(), " Next task".into()].into());
controls_content.push(vec!["Enter".blue(), " Edit task's file".into()].into());
controls_content.push(Line::from(""));
controls_content.push(vec!["Filter tasks by".blue().bold().underlined()].into());
controls_content.push(vec!["c".blue(), " completion status".into()].into());
controls_content.push(vec!["a".blue(), " archived status".into()].into());
controls_content.push(vec!["s".blue(), " staleness".into()].into());
controls_content.push(vec!["#".blue(), " tag".into()].into());
controls_content.push(vec!["/".blue(), " search term".into()].into());
controls_content.push(vec!["r".blue(), " recurring status".into()].into());
controls_content.push(vec!["Esc".blue(), " Clear all".into()].into());
controls_content.push(Line::from(""));
controls_content.push(vec!["Other".blue().bold().underlined()].into());
controls_content.push(vec!["X".blue(), " Export upcoming tasks".into()].into());
}
pub struct Dates {
today: NaiveDate,
tomorrow: NaiveDate,
yesterday: NaiveDate,
next_week: NaiveDate,
last_week: NaiveDate,
}
impl Default for Dates {
fn default() -> Self {
let today = Local::now().date_naive();
Self {
today,
tomorrow: today + Days::new(1),
yesterday: today - Days::new(1),
next_week: today + Days::new(7),
last_week: today - Days::new(7),
}
}
}
pub fn export_upcoming_tasks(app: &mut App) -> Result<(), TfError> {
let in_two_weeks = app.current_date + Days::new(14);
let mut upcoming_tasks = String::new();
let mut date = NaiveDate::from_ymd_opt(1854, 12, 4).unwrap();
for (i, task) in app.taskmode.data.iter().enumerate() {
if let Some(d) = task.task.due_date
&& d < in_two_weeks
{
if d != date {
if i != 0 {
upcoming_tasks.push('\n');
}
upcoming_tasks.push_str(&format!("# {}\n\n", d.format("%a %b %e")))
}
let order = match task.task.order {
Some(v) => format!("{v}"),
None => "".to_string(),
};
let task_text = task.task.text.trim();
let task_text = match task_text.strip_prefix("[ ]") {
Some(v) => v.trim().to_string(),
None => task.task.text.clone().trim().to_string(),
};
upcoming_tasks.push_str(&format!("{order:>3} {task_text}\n",));
date = d;
}
}
let mut data_dir = match dirs::data_dir() {
Some(v) => v,
None => {
return Err(TfError::ExportError(
"Cannot locate data directory.".to_string(),
));
}
};
data_dir.push("taskfinder");
if !data_dir.exists()
&& let Err(e) = fs::create_dir_all(&data_dir)
{
return Err(TfError::ExportError(e.to_string()));
}
let mut upcoming_tasks_file = data_dir;
upcoming_tasks_file.push("upcoming_tasks.md");
if !upcoming_tasks_file.exists()
&& let Err(e) = File::create(&upcoming_tasks_file)
{
return Err(TfError::ExportError(e.to_string()));
}
if let Err(e) = fs::write(upcoming_tasks_file, upcoming_tasks.clone()) {
return Err(TfError::ExportError(e.to_string()));
}
let mut clipboard = Clipboard::new()?;
clipboard.set_text(upcoming_tasks)?;
Ok(())
}