use crate::config::Config;
use crate::context::AppContext;
use crate::journal::Action as JournalAction;
use crate::model::{AppIntent, DateType, Task, TaskStatus};
use chrono::{DateTime, Datelike, Utc};
use fastrand;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
pub const UNCATEGORIZED_ID: &str = ":::uncategorized:::";
#[derive(Debug, Clone)]
pub enum TaskListItem {
Task(Box<crate::model::Task>),
ExpandGroup(String, usize), CollapseGroup(String, usize), }
#[derive(Debug, Clone, PartialEq)]
pub struct AggregateItem {
pub full_key: String,
pub display_name: String,
pub count: u32,
pub depth: u32,
pub has_children: bool,
pub is_expanded: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct JournalPageItem {
pub key: String,
pub title: String,
pub depth: usize,
pub has_children: bool,
pub is_expanded: bool,
pub is_task: bool,
pub calendar_href: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DependencyWarning {
NotFound {
raw: String,
},
Ambiguous {
raw: String,
source_task_uid: String,
relation_type: String, candidates: Vec<(String, String)>,
},
}
impl std::fmt::Display for DependencyWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DependencyWarning::NotFound { raw } => write!(f, "Task reference '{}' not found.", raw),
DependencyWarning::Ambiguous {
raw, candidates, ..
} => {
let summaries: Vec<String> = candidates
.iter()
.take(3)
.map(|(_, s)| format!("'{}'", s))
.collect();
let mut matches_str = summaries.join(", ");
if candidates.len() > 3 {
matches_str.push_str(", ...");
}
write!(f, "Ambiguous reference '{}'. Matches: {}", raw, matches_str)
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DayContext {
pub date: chrono::NaiveDate,
pub due_tasks: Vec<crate::model::Task>,
pub started_tasks: Vec<crate::model::Task>,
pub ongoing_tasks: Vec<crate::model::Task>,
pub completed_tasks: Vec<crate::model::Task>,
pub session_tasks: Vec<(crate::model::Task, u32)>, pub total_tracked_mins: u32,
}
pub struct FilterResult {
pub items: Vec<TaskListItem>,
pub categories: Vec<AggregateItem>,
pub locations: Vec<AggregateItem>,
pub journal_pages: Vec<JournalPageItem>,
}
struct HierarchyContext<'a> {
children_map: &'a HashMap<String, Vec<Task>>,
result: &'a mut Vec<TaskListItem>,
visited_keys: &'a mut HashSet<(String, String)>,
expanded_groups: &'a HashSet<String>,
search_active: bool,
max_done_subtasks: usize,
search_collapsed_tasks: &'a HashSet<String>,
focused_task_uid: Option<&'a str>,
}
pub struct HierarchyOptions<'a> {
pub default_priority: u8,
pub sort_standard_by_priority: bool,
pub expanded_groups: &'a HashSet<String>,
pub max_done_roots: usize,
pub max_done_subtasks: usize,
pub search_active: bool,
pub sort_preset: crate::config::SortPreset,
pub search_collapsed_tasks: &'a HashSet<String>,
pub focused_task_uid: Option<&'a str>,
pub paused_sort_behavior: crate::config::PausedSortBehavior,
pub sort_tiebreak_recent: bool,
}
pub fn organize_hierarchy(
mut tasks: Vec<Task>,
options: HierarchyOptions<'_>,
) -> Vec<TaskListItem> {
let present_uids: HashSet<String> = tasks.iter().map(|t| t.uid.clone()).collect();
let mut children_map: HashMap<String, Vec<Task>> = HashMap::new();
let mut roots: Vec<Task> = Vec::new();
tasks.sort_by(|a, b| {
a.compare_for_sort(
b,
options.default_priority,
options.sort_standard_by_priority,
options.sort_preset,
options.paused_sort_behavior,
options.sort_tiebreak_recent,
)
});
for mut task in tasks {
let is_orphan = match &task.parent_uid {
Some(p_uid) => !present_uids.contains(p_uid),
None => true,
};
if is_orphan {
if task.parent_uid.is_some() {
task.depth = 0; }
roots.push(task);
} else {
let p_uid = task.parent_uid.as_ref().unwrap().clone();
children_map.entry(p_uid).or_default().push(task);
}
}
fn topological_sort(list: &mut Vec<Task>) {
let n = list.len();
if n <= 1 {
return;
}
let mut uid_to_idx: HashMap<&str, usize> = HashMap::with_capacity(n);
for (i, t) in list.iter().enumerate() {
uid_to_idx.insert(t.uid.as_str(), i);
}
let mut needs_sort = false;
let mut in_degree = vec![0usize; n];
let mut graph = vec![Vec::new(); n];
for (i, t) in list.iter().enumerate() {
for dep in &t.dependencies {
if let Some(&dep_idx) = uid_to_idx.get(dep.as_str()) {
in_degree[i] += 1;
graph[dep_idx].push(i);
needs_sort = true;
}
}
}
if !needs_sort {
return;
}
let mut result = Vec::with_capacity(n);
let mut zero_in_degree: std::collections::BinaryHeap<std::cmp::Reverse<usize>> = (0..n)
.filter(|&i| in_degree[i] == 0)
.map(std::cmp::Reverse)
.collect();
while let Some(std::cmp::Reverse(i)) = zero_in_degree.pop() {
result.push(i);
for &dependent in &graph[i] {
in_degree[dependent] -= 1;
if in_degree[dependent] == 0 {
zero_in_degree.push(std::cmp::Reverse(dependent));
}
}
}
if result.len() < n {
for (i, °) in in_degree.iter().enumerate() {
if deg > 0 {
result.push(i);
}
}
}
let mut old_list = std::mem::take(list);
let mut opt_list: Vec<Option<Task>> = old_list.drain(..).map(Some).collect();
for &idx in &result {
list.push(opt_list[idx].take().unwrap());
}
}
topological_sort(&mut roots);
for list in children_map.values_mut() {
topological_sort(list);
}
let mut result = Vec::new();
let mut visited_keys = HashSet::new();
let effective_max_done_roots = if options.search_active {
usize::MAX
} else {
options.max_done_roots
};
let effective_max_done_subtasks = if options.search_active {
usize::MAX
} else {
options.max_done_subtasks
};
let mut context = HierarchyContext {
children_map: &children_map,
result: &mut result,
visited_keys: &mut visited_keys,
expanded_groups: options.expanded_groups,
search_active: options.search_active,
max_done_subtasks: effective_max_done_subtasks,
search_collapsed_tasks: options.search_collapsed_tasks,
focused_task_uid: options.focused_task_uid,
};
fn mark_tree_as_visited(task: &Task, context: &mut HierarchyContext) {
let visit_key = (task.uid.clone(), task.calendar_href.clone());
if !context.visited_keys.insert(visit_key) {
return;
}
let mut stack = vec![task.uid.clone()];
while let Some(current) = stack.pop() {
if let Some(children) = context.children_map.get(¤t) {
for child in children {
let v_key = (child.uid.clone(), child.calendar_href.clone());
if context.visited_keys.insert(v_key) {
stack.push(child.uid.clone());
}
}
}
}
}
fn process_group(
raw_group: Vec<Task>,
parent_uid: String,
limit: usize,
is_root: bool,
context: &mut HierarchyContext,
depth: usize,
) {
let (active, done): (Vec<Task>, Vec<Task>) =
raw_group.into_iter().partition(|t| !t.status.is_done());
for task in active {
append_task_and_children(&task, context, depth);
}
if done.is_empty() {
return;
}
let effective_key = if is_root {
"".to_string()
} else {
parent_uid.clone()
};
let is_expanded = context.expanded_groups.contains(&effective_key);
if is_expanded {
for task in done {
append_task_and_children(&task, context, depth);
}
context
.result
.push(TaskListItem::CollapseGroup(effective_key.clone(), depth));
} else if done.len() > limit {
let count_to_show = limit.saturating_sub(1);
let mut iter = done.into_iter();
for _ in 0..count_to_show {
if let Some(task) = iter.next() {
append_task_and_children(&task, context, depth);
}
}
context
.result
.push(TaskListItem::ExpandGroup(effective_key.clone(), depth));
for task in iter {
mark_tree_as_visited(&task, context);
}
} else {
for task in done {
append_task_and_children(&task, context, depth);
}
}
}
fn append_task_and_children(task: &Task, context: &mut HierarchyContext, depth: usize) {
let visit_key = (task.uid.clone(), task.calendar_href.clone());
if context.visited_keys.contains(&visit_key) {
return;
}
context.visited_keys.insert(visit_key.clone());
let is_focused_root = context.focused_task_uid == Some(task.uid.as_str());
let force_expand = (context.search_active
&& !context.search_collapsed_tasks.contains(&task.uid))
|| is_focused_root;
let effectively_collapsed = task.collapsed && !force_expand;
let mut t = task.clone();
t.depth = depth;
t.has_visible_subtasks = context.children_map.contains_key(&task.uid);
t.collapsed = effectively_collapsed;
context.result.push(TaskListItem::Task(Box::new(t)));
if effectively_collapsed {
if let Some(children) = context.children_map.get(&task.uid) {
for child in children {
mark_tree_as_visited(child, context);
}
}
return;
}
if let Some(children) = context.children_map.get(&task.uid) {
let (active, done): (Vec<&Task>, Vec<&Task>) =
children.iter().partition(|t| !t.status.is_done());
for child in active {
append_task_and_children(child, context, depth + 1);
}
if !done.is_empty() {
let is_expanded = context.expanded_groups.contains(&task.uid);
if is_expanded {
for child in done {
append_task_and_children(child, context, depth + 1);
}
context
.result
.push(TaskListItem::CollapseGroup(task.uid.clone(), depth + 1));
} else if done.len() > context.max_done_subtasks {
let show = context.max_done_subtasks.saturating_sub(1);
let mut iter = done.into_iter();
for _ in 0..show {
if let Some(c) = iter.next() {
append_task_and_children(c, context, depth + 1);
}
}
context
.result
.push(TaskListItem::ExpandGroup(task.uid.clone(), depth + 1));
for c in iter {
mark_tree_as_visited(c, context);
}
} else {
for child in done {
append_task_and_children(child, context, depth + 1);
}
}
}
}
}
process_group(
roots,
"".to_string(),
effective_max_done_roots,
true,
&mut context,
0,
);
let mut unvisited = Vec::new();
for children in context.children_map.values() {
for child in children {
let visit_key = (child.uid.clone(), child.calendar_href.clone());
if !context.visited_keys.contains(&visit_key) {
unvisited.push(child.clone());
}
}
}
if !unvisited.is_empty() {
unvisited.sort_by(|a, b| {
a.compare_for_sort(
b,
options.default_priority,
options.sort_standard_by_priority,
options.sort_preset,
options.paused_sort_behavior,
options.sort_tiebreak_recent,
)
});
process_group(
unvisited,
"".to_string(),
effective_max_done_roots,
true,
&mut context,
0,
);
}
result
}
pub fn select_weighted_random_index(tasks: &[Task], default_priority: u8) -> Option<usize> {
if tasks.is_empty() {
return None;
}
let weights: Vec<u32> = tasks
.iter()
.map(|t| {
if t.status.is_done() {
return 0;
}
if t.is_blocked || t.is_implicitly_blocked {
return 0;
}
if t.is_future_start || t.is_implicitly_future {
return 0;
}
let p = if t.priority == 0 {
default_priority
} else {
t.priority
};
(10u32).saturating_sub(p as u32)
})
.collect();
let total_weight: u32 = weights.iter().sum();
if total_weight == 0 {
return None;
}
let mut rng = fastrand::Rng::new();
let mut choice = rng.u32(0..total_weight);
for (i, w) in weights.iter().enumerate() {
if *w == 0 {
continue;
}
if choice < *w {
return Some(i);
}
choice -= *w;
}
None
}
#[derive(Debug, Clone)]
pub struct TaskStore {
pub calendars: HashMap<String, HashMap<String, Task>>,
pub index: HashMap<String, String>,
pub related_from_index: HashMap<String, Vec<String>>,
pub blocking_index: HashMap<String, Vec<String>>,
pub children_index: HashMap<String, Vec<String>>,
pub ctx: Arc<dyn AppContext>,
}
pub struct FilterOptions<'a> {
pub active_cal_href: Option<&'a str>,
pub hidden_calendars: &'a HashSet<String>,
pub selected_categories: &'a HashSet<String>,
pub selected_locations: &'a HashSet<String>,
pub match_all_categories: bool,
pub search_term: &'a str,
pub hide_completed_global: bool,
pub hide_fully_completed_tags: bool,
pub hide_aliases_in_sidebar: bool,
pub cutoff_date: Option<DateTime<Utc>>,
pub min_duration: Option<u32>,
pub max_duration: Option<u32>,
pub include_unset_duration: bool,
pub urgent_days: u32,
pub urgent_prio: u8,
pub default_priority: u8,
pub start_grace_period_days: u32,
pub sort_standard_by_priority: bool,
pub sort_preset: crate::config::SortPreset,
pub expanded_done_groups: &'a HashSet<String>,
pub expanded_tags: &'a HashSet<String>,
pub expanded_locations: &'a HashSet<String>,
pub max_done_roots: usize,
pub max_done_subtasks: usize,
pub tag_aliases: &'a HashMap<String, Vec<String>>,
pub search_collapsed_tasks: &'a HashSet<String>,
pub focused_task_uid: Option<&'a str>,
pub paused_sort_behavior: crate::config::PausedSortBehavior,
pub sort_tiebreak_recent: bool,
}
pub struct SyncTreeOptions<'a> {
pub aliases: &'a std::collections::HashMap<String, Vec<String>>,
pub default_reminder_time: Option<chrono::NaiveTime>,
pub trash_retention_days: u32,
pub calendars: &'a [crate::model::CalendarListEntry],
}
fn session_fully_covered(s: i64, e: i64, intervals: &[(i64, i64)]) -> bool {
let mut covered = 0i64;
for (is, ie) in intervals {
let ov_start = (*is).max(s);
let ov_end = (*ie).min(e);
if ov_end > ov_start {
covered += ov_end - ov_start;
}
}
covered >= (e - s).max(0)
}
impl TaskStore {
pub fn new(ctx: Arc<dyn AppContext>) -> Self {
Self {
calendars: HashMap::new(),
index: HashMap::new(),
related_from_index: HashMap::new(),
blocking_index: HashMap::new(),
children_index: HashMap::new(),
ctx,
}
}
pub fn has_any_tasks(&self) -> bool {
!self.index.is_empty()
}
pub fn has_tasks_blocking(&self, uid: &str) -> bool {
self.blocking_index
.get(uid)
.map(|l| !l.is_empty())
.unwrap_or(false)
}
pub fn has_tasks_related_to(&self, uid: &str) -> bool {
self.related_from_index
.get(uid)
.map(|l| !l.is_empty())
.unwrap_or(false)
}
fn remove_task_from_indices(&mut self, task: &Task) {
for r in &task.related_to {
if let Some(sources) = self.related_from_index.get_mut(r) {
sources.retain(|u| u != &task.uid);
if sources.is_empty() {
self.related_from_index.remove(r);
}
}
}
for dep in &task.dependencies {
if let Some(list) = self.blocking_index.get_mut(dep) {
list.retain(|u| u != &task.uid);
if list.is_empty() {
self.blocking_index.remove(dep);
}
}
}
if let Some(p) = &task.parent_uid
&& let Some(list) = self.children_index.get_mut(p)
{
list.retain(|u| u != &task.uid);
if list.is_empty() {
self.children_index.remove(p);
}
}
}
fn add_task_to_indices(
&mut self,
uid: &str,
related_to: &[String],
dependencies: &[String],
parent_uid: Option<&str>,
) {
for r in related_to {
self.related_from_index
.entry(r.clone())
.or_default()
.push(uid.to_string());
}
for dep in dependencies {
self.blocking_index
.entry(dep.clone())
.or_default()
.push(uid.to_string());
}
if let Some(p) = parent_uid {
self.children_index
.entry(p.to_string())
.or_default()
.push(uid.to_string());
}
}
pub fn insert(&mut self, calendar_href: String, tasks: Vec<Task>) {
let tag_aliases = Config::tag_aliases(self.ctx.as_ref());
let mut new_map = HashMap::new();
let mut uids_to_add = Vec::new();
for mut task in tasks {
task.extract_transient_metadata(&tag_aliases);
let uid = task.uid.clone();
if let Some(existing_map) = self.calendars.get(&calendar_href)
&& let Some(existing_task) = existing_map.get(&uid)
&& existing_task.sequence > task.sequence
{
task = existing_task.clone();
}
if let Some(existing_href) = self.index.get(&uid)
&& existing_href != &calendar_href
{
if let Some(existing_task) =
self.calendars.get(existing_href).and_then(|m| m.get(&uid))
{
let is_system_cal = |href: &str| {
href == crate::storage::LOCAL_TRASH_HREF || href == "local://recovery"
};
let keep_existing = if is_system_cal(existing_href) {
false
} else if is_system_cal(&calendar_href) {
true
} else if existing_task.sequence != task.sequence {
existing_task.sequence > task.sequence
} else {
existing_href > &calendar_href
};
if keep_existing {
continue;
} else {
let old_href_clone = existing_href.clone();
if let Some(old_map) = self.calendars.get_mut(&old_href_clone)
&& let Some(old_task) = old_map.remove(&uid)
{
self.remove_task_from_indices(&old_task);
}
}
}
}
new_map.insert(uid.clone(), task);
uids_to_add.push(uid);
}
let mut fixes = Vec::new();
for task in new_map.values() {
if task.status.is_done()
&& task.rrule.is_none()
&& let Some(p_uid) = &task.parent_uid
&& let Some(parent) = new_map.get(p_uid)
&& parent.rrule.is_some()
{
let mut is_history_snapshot = task
.unmapped_properties
.iter()
.any(|p| p.key == "X-CFAIT-HISTORY-OF");
if !is_history_snapshot {
let task_created = task.unmapped_properties.iter().find(|p| p.key == "CREATED");
let parent_created = parent
.unmapped_properties
.iter()
.find(|p| p.key == "CREATED");
if let (Some(tc), Some(pc)) = (task_created, parent_created)
&& tc.value == pc.value
{
is_history_snapshot = true;
}
if !is_history_snapshot && parent.summary == task.summary {
is_history_snapshot = true;
}
}
if is_history_snapshot {
fixes.push(task.uid.clone());
}
}
}
for uid in fixes {
if let Some(task) = new_map.get_mut(&uid) {
let p_uid = task.parent_uid.take().unwrap();
if !task.related_to.contains(&p_uid) {
task.related_to.push(p_uid);
}
}
}
if let Some(old_map) = self.calendars.get(&calendar_href) {
for uid in old_map.keys() {
if !new_map.contains_key(uid) && self.index.get(uid) == Some(&calendar_href) {
self.index.remove(uid);
}
}
}
for uid in uids_to_add {
self.index.insert(uid, calendar_href.clone());
}
self.calendars.insert(calendar_href, new_map);
self.rebuild_relation_index();
}
pub fn add_task(&mut self, mut task: Task) {
let tag_aliases = Config::tag_aliases(self.ctx.as_ref());
task.extract_transient_metadata(&tag_aliases);
let href = task.calendar_href.clone();
let uid = task.uid.clone();
let related_to = task.related_to.clone();
let dependencies = task.dependencies.clone();
let parent_uid = task.parent_uid.clone();
self.index.insert(uid.clone(), href.clone());
if let Some(old) = self
.calendars
.entry(href)
.or_default()
.insert(uid.clone(), task)
{
self.remove_task_from_indices(&old);
}
self.add_task_to_indices(&uid, &related_to, &dependencies, parent_uid.as_deref());
}
pub fn collect_ancestor_uids(&self, uid: &str) -> Vec<String> {
let mut result = Vec::new();
let mut curr = uid;
let mut visited = HashSet::new();
while let Some(p_uid) = self
.get_task_ref(curr)
.and_then(|t| t.parent_uid.as_deref())
{
if !visited.insert(p_uid) {
break;
}
result.push(p_uid.to_string());
curr = p_uid;
}
result
}
fn is_descendant_of(&self, descendant_uid: &str, ancestor_uid: &str) -> bool {
let mut curr = descendant_uid;
let mut visited = HashSet::new();
while let Some(p_uid) = self
.get_task_ref(curr)
.and_then(|t| t.parent_uid.as_deref())
{
if p_uid == ancestor_uid {
return true;
}
if !visited.insert(p_uid) {
break;
}
curr = p_uid;
}
false
}
fn task_matches_path(
&self,
task: &Task,
path_segments: &[String],
is_relative: bool,
context_uid: Option<&str>,
) -> bool {
let target_summary =
crate::model::parser::strip_quotes(path_segments.last().unwrap()).to_lowercase();
let task_summary = task.summary.to_lowercase();
if !task_summary.contains(&target_summary) {
return false;
}
if is_relative {
if let Some(ctx_uid) = context_uid {
if !self.is_descendant_of(&task.uid, ctx_uid) {
return false;
}
} else {
return false;
}
}
if path_segments.len() <= 1 {
return true;
}
let mut current_task = task;
let mut segment_idx = path_segments.len().saturating_sub(2);
let mut visited = std::collections::HashSet::new();
while segment_idx < path_segments.len() {
if let Some(p_uid) = ¤t_task.parent_uid {
if !visited.insert(p_uid.clone()) {
return false;
}
if let Some(p_task) = self.get_task_ref(p_uid) {
let expected_summary =
crate::model::parser::strip_quotes(&path_segments[segment_idx])
.to_lowercase();
if crate::model::matcher::contains_ignore_case(
&p_task.summary,
&expected_summary,
) {
if segment_idx == 0 {
break;
}
segment_idx -= 1;
}
current_task = p_task;
} else {
return false;
}
} else {
return false;
}
}
true
}
pub fn resolve_dependency_ref(
&self,
reference: &str,
context_uid: Option<&str>,
) -> Result<String, String> {
let clean_ref = reference
.trim_start_matches("[[")
.trim_end_matches("]]")
.trim();
let is_relative = clean_ref.starts_with('+');
let path_str = if is_relative {
clean_ref[1..].trim()
} else {
clean_ref
};
let path_segments = crate::model::parser::split_path_respecting_quotes(path_str);
if path_segments.is_empty() {
return Err("Empty reference".to_string());
}
if path_segments.len() == 1 {
let single_ref = crate::model::parser::strip_quotes(&path_segments[0]);
if self.index.contains_key(&single_ref) {
return Ok(single_ref);
}
}
let mut matches = Vec::new();
for (href, map) in &self.calendars {
let is_system = href == crate::storage::LOCAL_TRASH_HREF || href == "local://recovery";
for (uid, task) in map {
if (path_segments.len() == 1
&& uid.starts_with(&crate::model::parser::strip_quotes(&path_segments[0])))
|| (!is_system
&& self.task_matches_path(task, &path_segments, is_relative, context_uid))
{
matches.push(task.clone());
}
}
}
let mut fell_back = false;
if matches.is_empty() && path_segments.len() > 1 {
fell_back = true;
let single = crate::model::parser::strip_quotes(path_str);
for (href, map) in &self.calendars {
let is_system =
href == crate::storage::LOCAL_TRASH_HREF || href == "local://recovery";
for (uid, task) in map {
if uid.starts_with(&single)
|| (!is_system
&& crate::model::matcher::contains_ignore_case(&task.summary, &single))
{
matches.push(task.clone());
}
}
}
}
matches.sort_by_key(|t| t.uid.clone());
matches.dedup_by(|a, b| a.uid == b.uid);
if matches.len() == 1 {
return Ok(matches[0].uid.clone());
}
if matches.len() > 1 {
let target_summary = if fell_back {
crate::model::parser::strip_quotes(path_str).to_lowercase()
} else {
crate::model::parser::strip_quotes(path_segments.last().unwrap()).to_lowercase()
};
let exact_matches: Vec<_> = matches
.iter()
.filter(|t| t.summary.to_lowercase() == target_summary)
.collect();
if exact_matches.len() == 1 {
return Ok(exact_matches[0].uid.clone());
}
let summaries: Vec<String> = matches
.into_iter()
.take(3)
.map(|t| format!("'{}'", t.summary))
.collect();
let mut matches_str = summaries.join(", ");
if summaries.len() == 3 {
matches_str.push_str(", ...");
}
return Err(rust_i18n::t!(
"error_ambiguous_dep",
reference = clean_ref,
matches = matches_str
)
.to_string());
}
Err(rust_i18n::t!("error_task_not_found_for_dep", reference = clean_ref).to_string())
}
pub fn walk_or_create_wiki_path(
&mut self,
clean_title: &str,
context_uid: Option<&str>,
context_is_journal: bool,
tag_aliases: &HashMap<String, Vec<String>>,
def_time: Option<chrono::NaiveTime>,
fallback_href: Option<String>,
) -> (String, Vec<crate::journal::Action>) {
let is_relative = clean_title.starts_with('+');
let path_str = if is_relative {
clean_title[1..].trim()
} else {
clean_title
};
let path_segments = crate::model::parser::split_path_respecting_quotes(path_str);
if path_segments.is_empty() {
return (String::new(), Vec::new());
}
let mut current_parent_uid: Option<String> = if is_relative {
context_uid.map(|s| s.to_string())
} else {
None
};
let mut final_uid = String::new();
let mut actions = Vec::new();
for (i, segment) in path_segments.iter().enumerate() {
let seg_clean = crate::model::parser::strip_quotes(segment);
let mut found_uid: Option<String> = None;
'search: for (href, map) in &self.calendars {
if href == crate::storage::LOCAL_TRASH_HREF || href == "local://recovery" {
continue;
}
for (uid, t) in map {
if t.parent_uid == current_parent_uid
&& t.summary.eq_ignore_ascii_case(&seg_clean)
{
found_uid = Some(uid.clone());
break 'search;
}
}
}
if let Some(uid) = found_uid {
current_parent_uid = Some(uid.clone());
if i == path_segments.len() - 1 {
final_uid = uid;
}
} else {
let mut new_task = crate::model::Task::new(&seg_clean, tag_aliases, def_time);
if context_is_journal {
new_task.is_journal = true;
new_task.is_note = true;
}
new_task.parent_uid = current_parent_uid.clone();
let target_href = if let Some(p_uid) = ¤t_parent_uid {
self.get_task_ref(p_uid).map(|t| t.calendar_href.clone())
} else {
None
}
.or_else(|| fallback_href.clone())
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
new_task.calendar_href = target_href;
let uid = new_task.uid.clone();
self.add_task(new_task.clone());
actions.push(crate::journal::Action::Create(new_task));
current_parent_uid = Some(uid.clone());
if i == path_segments.len() - 1 {
final_uid = uid;
}
}
}
(final_uid, actions)
}
pub fn get_dependency_candidates(
&self,
reference: &str,
context_uid: Option<&str>,
) -> Vec<(String, String)> {
let clean_ref = reference
.trim_start_matches("[[")
.trim_end_matches("]]")
.trim();
let is_relative = clean_ref.starts_with('+');
let path_str = if is_relative {
clean_ref[1..].trim()
} else {
clean_ref
};
let path_segments = crate::model::parser::split_path_respecting_quotes(path_str);
if path_segments.is_empty() {
return Vec::new();
}
let mut matches = Vec::new();
for (href, map) in &self.calendars {
let is_system = href == crate::storage::LOCAL_TRASH_HREF || href == "local://recovery";
for (uid, task) in map {
if (path_segments.len() == 1
&& uid.starts_with(&crate::model::parser::strip_quotes(&path_segments[0])))
|| (!is_system
&& self.task_matches_path(task, &path_segments, is_relative, context_uid))
{
matches.push((uid.clone(), task.summary.clone()));
}
}
}
let target_summary =
crate::model::parser::strip_quotes(path_segments.last().unwrap()).to_lowercase();
let mut keyed: Vec<(String, String, bool)> = matches
.into_iter()
.map(|(uid, summary)| {
let starts = summary.to_lowercase().starts_with(&target_summary);
(uid, summary, starts)
})
.collect();
keyed.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
let mut matches: Vec<(String, String)> = keyed
.into_iter()
.map(|(uid, summary, _)| (uid, summary))
.collect();
matches.dedup_by(|a, b| a.0 == b.0);
matches
}
fn resolve_ref_list(
&self,
refs: &[String],
context_uid: &str,
rel_type: &str,
warnings: &mut Vec<DependencyWarning>,
) -> Vec<String> {
let mut resolved = Vec::new();
for r in refs {
let uid = if r.len() == 36 && uuid::Uuid::parse_str(r).is_ok() {
r.clone()
} else {
match self.resolve_dependency_ref(r, Some(context_uid)) {
Ok(resolved_uid) => resolved_uid,
Err(_) => {
let candidates = self.get_dependency_candidates(r, Some(context_uid));
if candidates.len() > 1 {
warnings.push(DependencyWarning::Ambiguous {
raw: r.clone(),
source_task_uid: context_uid.to_string(),
relation_type: rel_type.to_string(),
candidates,
});
} else {
warnings.push(DependencyWarning::NotFound { raw: r.clone() });
}
r.clone()
}
}
};
if let Some(t) = self.get_task_ref(&uid)
&& (t.calendar_href == crate::storage::LOCAL_TRASH_HREF
|| t.calendar_href == "local://recovery")
{
continue;
}
resolved.push(uid);
}
resolved.sort();
resolved.dedup();
resolved
}
pub fn resolve_dependencies(&self, task: &mut Task) -> Vec<DependencyWarning> {
let mut warnings = Vec::new();
task.dependencies =
self.resolve_ref_list(&task.dependencies, &task.uid, "dep", &mut warnings);
task.related_to = self.resolve_ref_list(&task.related_to, &task.uid, "rel", &mut warnings);
warnings
}
pub fn update_or_add_task(&mut self, mut task: Task) {
let tag_aliases = Config::tag_aliases(self.ctx.as_ref());
task.extract_transient_metadata(&tag_aliases);
let href = task.calendar_href.clone();
let uid = task.uid.clone();
let related_to = task.related_to.clone();
let dependencies = task.dependencies.clone();
let parent_uid = task.parent_uid.clone();
if let Some(existing_href) = self.index.get(&uid) {
if existing_href == &href {
if let Some(map) = self.calendars.get_mut(&href) {
if let Some(old) = map.insert(uid.clone(), task) {
self.remove_task_from_indices(&old);
}
} else {
self.calendars
.entry(href.clone())
.or_default()
.insert(uid.clone(), task);
}
} else {
let existing_href_clone = existing_href.clone();
if let Some(map) = self.calendars.get_mut(&existing_href_clone)
&& let Some(old) = map.remove(&uid)
{
self.remove_task_from_indices(&old);
}
self.index.insert(uid.clone(), href.clone());
self.calendars
.entry(href.clone())
.or_default()
.insert(uid.clone(), task);
}
} else {
self.index.insert(uid.clone(), href.clone());
self.calendars
.entry(href.clone())
.or_default()
.insert(uid.clone(), task);
}
self.add_task_to_indices(&uid, &related_to, &dependencies, parent_uid.as_deref());
}
pub fn clear(&mut self) {
self.calendars.clear();
self.index.clear();
self.related_from_index.clear();
self.blocking_index.clear();
self.children_index.clear();
}
pub fn remove(&mut self, calendar_href: &str) {
if let Some(tasks_map) = self.calendars.remove(calendar_href) {
for uid in tasks_map.keys() {
self.index.remove(uid);
}
}
self.rebuild_relation_index();
}
pub fn get_task_mut(&mut self, uid: &str) -> Option<(&mut Task, String)> {
let href = self.index.get(uid)?.clone();
if let Some(map) = self.calendars.get_mut(&href)
&& let Some(task) = map.get_mut(uid)
{
return Some((task, href));
}
self.index.remove(uid);
None
}
pub fn get_task_ref(&self, uid: &str) -> Option<&Task> {
let href = self.index.get(uid)?;
self.calendars.get(href).and_then(|map| map.get(uid))
}
pub fn delete_task(&mut self, uid: &str) -> Option<(Task, String)> {
let href = self.index.get(uid)?.clone();
if let Some(map) = self.calendars.get_mut(&href)
&& let Some(task) = map.remove(uid)
{
self.index.remove(uid);
self.remove_task_from_indices(&task);
return Some((task, href));
}
None
}
pub fn soft_delete_task(
&mut self,
uid: &str,
retention_days: u32,
) -> Option<(Task, Option<Task>)> {
let is_already_trash = self
.get_task_ref(uid)
.map(|t| t.calendar_href == crate::storage::LOCAL_TRASH_HREF)
.unwrap_or(false);
if retention_days == 0 || is_already_trash {
let (deleted, _) = self.delete_task(uid)?;
Some((deleted, None))
} else {
self.calendars
.entry(crate::storage::LOCAL_TRASH_HREF.to_string())
.or_default();
let (orig, mut updated) =
self.move_task(uid, crate::storage::LOCAL_TRASH_HREF.to_string())?;
let now_str = chrono::Utc::now().to_rfc3339();
updated
.unmapped_properties
.retain(|p| p.key != "X-TRASHED-DATE");
updated.unmapped_properties.push(crate::model::RawProperty {
key: "X-TRASHED-DATE".to_string(),
value: now_str,
params: vec![],
});
self.update_or_add_task(updated.clone());
Some((orig, Some(updated)))
}
}
pub fn soft_delete_task_tree(
&mut self,
root_uid: &str,
retention_days: u32,
) -> Vec<(Task, Option<Task>)> {
let mut uids = self.get_descendant_uids(root_uid);
uids.push(root_uid.to_string());
let mut results = Vec::new();
for uid in uids {
if let Some(res) = self.soft_delete_task(&uid, retention_days) {
results.push(res);
}
}
results
}
pub fn toggle_task(&mut self, uid: &str) -> Option<(Task, Option<Task>, Vec<Task>)> {
let current_status = self.get_task_ref(uid)?.status;
let next_status = if current_status.is_done() {
TaskStatus::NeedsAction
} else {
TaskStatus::Completed
};
self.set_status(uid, next_status, false)
}
pub fn toggle_task_shift(&mut self, uid: &str) -> Option<(Task, Option<Task>, Vec<Task>)> {
let current_status = self.get_task_ref(uid)?.status;
let next_status = if current_status.is_done() {
TaskStatus::NeedsAction
} else {
TaskStatus::Completed
};
self.set_status(uid, next_status, true)
}
pub fn set_status(
&mut self,
uid: &str,
status: TaskStatus,
shift_schedule: bool,
) -> Option<(Task, Option<Task>, Vec<Task>)> {
let mut task_copy = self.get_task_ref(uid)?.clone();
task_copy.sequence += 1;
let is_permanent = task_copy.permanent;
if is_permanent && status == TaskStatus::Completed {
let mut duration_to_log = 0;
if let Some(start_ts) = task_copy.last_started_at {
let now = Utc::now().timestamp();
if now > start_ts {
duration_to_log = (now - start_ts) as u64;
}
task_copy.last_started_at = None;
} else {
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let est = task_copy
.estimated_duration
.unwrap_or(config.default_duration_goal_mins);
duration_to_log = (est as u64) * 60;
}
if duration_to_log > 0 {
let end = Utc::now().timestamp();
let start = end.saturating_sub(duration_to_log as i64);
let session = crate::model::item::WorkSession { start, end };
task_copy.add_session(session);
}
task_copy.status = TaskStatus::NeedsAction;
task_copy.percent_complete = None;
task_copy
.unmapped_properties
.retain(|p| p.key.to_uppercase() != "COMPLETED");
self.update_or_add_task(task_copy.clone());
return Some((task_copy, None, vec![]));
}
let should_reset_children = task_copy.rrule.is_some() && status.is_done();
let (primary, secondary) = task_copy.recycle(status, shift_schedule);
self.update_or_add_task(primary.clone());
if let Some(sec) = &secondary {
self.update_or_add_task(sec.clone());
}
let mut reset_children: Vec<Task> = Vec::new();
if should_reset_children && secondary.is_some() {
let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
for map in self.calendars.values() {
for t in map.values() {
if let Some(p) = &t.parent_uid {
adjacency.entry(p.clone()).or_default().push(t.uid.clone());
}
}
}
let mut queue = vec![uid.to_string()];
let mut descendants = HashSet::new();
while let Some(parent) = queue.pop() {
if let Some(children) = adjacency.get(&parent) {
for child_uid in children {
if descendants.insert(child_uid.clone()) {
queue.push(child_uid.clone());
}
}
}
}
for child_uid in descendants {
if let Some((child, _)) = self.get_task_mut(&child_uid)
&& child.status.is_done()
{
child.status = TaskStatus::NeedsAction;
child.percent_complete = None;
child
.unmapped_properties
.retain(|p| p.key.to_uppercase() != "COMPLETED");
let child_copy = child.clone();
self.update_or_add_task(child_copy.clone());
reset_children.push(child_copy);
}
}
}
Some((primary, secondary, reset_children))
}
pub fn set_status_in_process(&mut self, uid: &str) -> Vec<Task> {
let mut updated = Vec::new();
let now = Utc::now().timestamp();
let chain = std::iter::once(uid.to_string())
.chain(self.collect_ancestor_uids(uid))
.collect::<Vec<_>>();
for current_uid in chain {
if let Some((task, _)) = self.get_task_mut(¤t_uid) {
let mut changed = false;
if task.status != TaskStatus::InProcess {
task.status = TaskStatus::InProcess;
changed = true;
}
if task.last_started_at.is_none() {
task.last_started_at = Some(now);
changed = true;
}
if changed {
task.sequence += 1;
updated.push(task.clone());
}
}
}
updated
}
pub fn pause_task(&mut self, uid: &str) -> Vec<Task> {
let mut updated = Vec::new();
let now = Utc::now().timestamp();
let mut all_uids = self.get_descendant_uids(uid);
all_uids.insert(0, uid.to_string());
for current_uid in all_uids {
if let Some((task, _)) = self.get_task_mut(¤t_uid) {
let mut changed = false;
if task.status == TaskStatus::InProcess {
task.status = TaskStatus::NeedsAction;
changed = true;
}
if let Some(start) = task.last_started_at {
if now > start {
let duration = (now - start) as u64;
task.time_spent_seconds = task.time_spent_seconds.saturating_add(duration);
if duration > 60 {
task.sessions
.push(crate::model::item::WorkSession { start, end: now });
}
}
task.last_started_at = None;
changed = true;
}
if changed {
task.sequence += 1;
updated.push(task.clone());
}
}
}
updated
}
pub fn stop_task(&mut self, uid: &str) -> Vec<Task> {
let mut updated = Vec::new();
let mut all_uids = self.get_descendant_uids(uid);
all_uids.insert(0, uid.to_string());
for current_uid in all_uids {
if let Some((task, _)) = self.get_task_mut(¤t_uid) {
let mut changed = false;
if task.status != TaskStatus::NeedsAction {
task.status = TaskStatus::NeedsAction;
changed = true;
}
if task.last_started_at.is_some() {
task.last_started_at = None;
changed = true;
}
if task.time_spent_seconds > 0 {
task.time_spent_seconds = 0;
changed = true;
}
if !task.sessions.is_empty() {
task.sessions.clear();
changed = true;
}
if changed {
task.sequence += 1;
updated.push(task.clone());
}
}
}
updated
}
pub fn change_priority(&mut self, uid: &str, delta: i8, default_priority: u8) -> Option<Task> {
if let Some((task, _)) = self.get_task_mut(uid) {
let mut p = task.priority as i16;
if p == 0 {
p = default_priority as i16;
}
if delta > 0 {
p = (p - delta as i16).max(1);
} else if delta < 0 {
p = (p - delta as i16).min(9);
}
let new_p = p as u8;
if task.priority == new_p {
return None;
}
task.priority = new_p;
task.sequence += 1;
return Some(task.clone());
}
None
}
pub fn replace_dependency(
&mut self,
task_uid: &str,
old_dep: &str,
new_dep: String,
) -> Option<Task> {
if let Some((task, _)) = self.get_task_mut(task_uid)
&& let Some(pos) = task.dependencies.iter().position(|d| d == old_dep)
{
task.dependencies[pos] = new_dep.clone();
task.sequence += 1;
let task_clone = task.clone();
if let Some(list) = self.blocking_index.get_mut(old_dep) {
list.retain(|u| u != task_uid);
}
self.blocking_index
.entry(new_dep)
.or_default()
.push(task_uid.to_string());
return Some(task_clone);
}
None
}
pub fn replace_relation(
&mut self,
task_uid: &str,
old_rel: &str,
new_rel: String,
) -> Option<Task> {
if let Some((task, _)) = self.get_task_mut(task_uid)
&& let Some(pos) = task.related_to.iter().position(|r| r == old_rel)
{
task.related_to[pos] = new_rel.clone();
task.sequence += 1;
let task_clone = task.clone();
if let Some(list) = self.related_from_index.get_mut(old_rel) {
list.retain(|u| u != task_uid);
}
self.related_from_index
.entry(new_rel)
.or_default()
.push(task_uid.to_string());
return Some(task_clone);
}
None
}
pub fn sync_tree_from_markdown(
&mut self,
root_uid: &str,
markdown: &str,
options: &SyncTreeOptions,
is_journal: bool,
) -> Result<(Vec<crate::journal::Action>, Vec<DependencyWarning>), String> {
let mut actions = Vec::new();
let old_descendants = self.get_descendant_uids(root_uid);
let (clean_desc, extracted) =
crate::model::extractor::extract_markdown_tasks(markdown, is_journal);
let root_calendar_href = if let Some(root) = self.get_task_ref(root_uid) {
root.calendar_href.clone()
} else {
return Ok((actions, Vec::new()));
};
let root_in_extracted = extracted
.iter()
.any(|ext| ext.parsed_existing_uid.as_deref() == Some(root_uid) || ext.uid == root_uid);
let mut root_clone = self.get_task_ref(root_uid).unwrap().clone();
if !root_in_extracted {
root_clone.description = clean_desc;
root_clone.sequence += 1;
}
let mut active_uids = std::collections::HashSet::new();
let mut tasks_to_update = Vec::new();
let mut tasks_to_create = Vec::new();
let mut resolved_hrefs = HashMap::new();
resolved_hrefs.insert(root_uid.to_string(), root_calendar_href.clone());
let mut resolved_props: HashMap<String, (Vec<String>, Vec<String>, u8)> = HashMap::new();
resolved_props.insert(
root_uid.to_string(),
(
root_clone.categories.clone(),
root_clone.locations.clone(),
root_clone.priority,
),
);
for ext in extracted {
let task_uid = ext.parsed_existing_uid.clone().unwrap_or(ext.uid.clone());
active_uids.insert(task_uid.clone());
let p_uid_str = ext
.parent_uid
.clone()
.unwrap_or_else(|| root_uid.to_string());
let inherited_href = resolved_hrefs
.get(&p_uid_str)
.cloned()
.unwrap_or_else(|| root_calendar_href.clone());
let p_props = resolved_props.get(&p_uid_str).cloned();
let parent_uid = if task_uid == root_uid {
if let Some(t) = self.get_task_ref(&task_uid) {
t.parent_uid.clone()
} else {
None
}
} else {
Some(p_uid_str)
};
if let Some(existing) = self.get_task_ref(&task_uid) {
let old_href = existing.calendar_href.clone();
let mut expected_raw_text = existing.to_smart_string();
if existing.is_note {
if expected_raw_text.starts_with("- ") || expected_raw_text.starts_with("* ") {
expected_raw_text = expected_raw_text[2..].trim_start().to_string();
} else if expected_raw_text == "-" || expected_raw_text == "*" {
expected_raw_text = String::new();
}
}
if existing.calendar_href != root_calendar_href {
expected_raw_text.push_str(&format!(
" col:{}",
crate::model::parser::quote_value(&existing.calendar_href)
));
}
let mut dep_str = String::new();
let process_relations = |uids: &[String], prefix: &str, out: &mut String| {
for uid in uids {
if let Some(target_task) = self.get_task_ref(uid) {
if target_task.calendar_href == crate::storage::LOCAL_TRASH_HREF
|| target_task.calendar_href == "local://recovery"
{
continue;
}
} else {
continue;
}
let display_val = if uid.len() == 36 && uuid::Uuid::parse_str(uid).is_ok() {
&uid[..8]
} else {
uid
};
out.push_str(&format!(
" {}:{}",
prefix,
crate::model::parser::quote_value(display_val)
));
}
};
process_relations(&existing.dependencies, "dep", &mut dep_str);
process_relations(&existing.related_to, "rel", &mut dep_str);
expected_raw_text.push_str(&dep_str);
let mut clone = existing.clone();
let mut actually_changed = false;
if expected_raw_text.trim() != ext.raw_text.trim() {
let preserved_alarms: Vec<_> = existing
.alarms
.iter()
.filter(|a| a.is_snooze() || a.acknowledged.is_some())
.cloned()
.collect();
clone.apply_smart_input(
&ext.raw_text,
options.aliases,
options.default_reminder_time,
);
clone.alarms.extend(preserved_alarms);
if !ext.dependencies.is_empty() {
clone.dependencies.extend(ext.dependencies.clone());
}
actually_changed = true;
} else {
let dummy = crate::model::Task::new(
&ext.raw_text,
options.aliases,
options.default_reminder_time,
);
let mut new_deps = dummy.dependencies;
new_deps.extend(ext.dependencies.clone());
clone.dependencies = new_deps;
clone.related_to = dummy.related_to;
clone.target_collection = dummy.target_collection;
}
clone.description = ext.description.clone();
clone.is_note = ext.is_note;
clone.percent_complete = ext.percent_complete;
clone.parent_uid = parent_uid.clone();
clone.apply_extracted_status(ext.status);
let final_href = if let Some(target) = clone.target_collection.take() {
crate::model::resolve_collection(&target, options.calendars, &inherited_href)
} else {
inherited_href.clone()
};
clone.calendar_href = final_href.clone();
resolved_hrefs.insert(task_uid.clone(), final_href.clone());
let mut test_existing = existing.clone();
let mut test_clone = clone.clone();
test_existing.sequence = 0;
test_clone.sequence = 0;
test_existing.etag = String::new();
test_clone.etag = String::new();
test_existing.href = String::new();
test_clone.href = String::new();
let _ = self.resolve_dependencies(&mut test_existing);
let _ = self.resolve_dependencies(&mut test_clone);
if test_existing.summary != test_clone.summary
|| test_existing.description != test_clone.description
|| test_existing.status != test_clone.status
|| test_existing.priority != test_clone.priority
|| test_existing.due != test_clone.due
|| test_existing.dtstart != test_clone.dtstart
|| test_existing.categories != test_clone.categories
|| test_existing.locations != test_clone.locations
|| test_existing.url != test_clone.url
|| test_existing.geo != test_clone.geo
|| test_existing.percent_complete != test_clone.percent_complete
|| test_existing.is_note != test_clone.is_note
|| test_existing.pinned != test_clone.pinned
|| test_existing.manual_block != test_clone.manual_block
|| test_existing.permanent != test_clone.permanent
|| test_existing.parent_uid != test_clone.parent_uid
|| test_existing.dependencies != test_clone.dependencies
|| test_existing.related_to != test_clone.related_to
|| test_existing.calendar_href != test_clone.calendar_href
|| actually_changed
{
clone.sequence += 1;
if old_href != final_href {
actions.push(crate::journal::Action::Move(existing.clone(), final_href));
}
resolved_props.insert(
task_uid.clone(),
(
clone.categories.clone(),
clone.locations.clone(),
clone.priority,
),
);
tasks_to_update.push(clone);
} else {
resolved_props.insert(
task_uid.clone(),
(
existing.categories.clone(),
existing.locations.clone(),
existing.priority,
),
);
}
} else {
let mut new_task = crate::model::Task::new(
&ext.raw_text,
options.aliases,
options.default_reminder_time,
);
new_task.uid = task_uid.clone();
new_task.description = ext.description;
if let Some((p_cats, p_loc, p_prio)) = p_props {
new_task.inherit_properties(&p_cats, &p_loc, p_prio);
}
new_task.apply_extracted_status(ext.status);
new_task.parent_uid = parent_uid;
new_task.dependencies = ext.dependencies;
let final_href = if let Some(target) = new_task.target_collection.take() {
crate::model::resolve_collection(&target, options.calendars, &inherited_href)
} else {
inherited_href.clone()
};
new_task.calendar_href = final_href.clone();
resolved_hrefs.insert(task_uid.clone(), final_href);
new_task.is_note = ext.is_note;
new_task.percent_complete = ext.percent_complete;
resolved_props.insert(
task_uid.clone(),
(
new_task.categories.clone(),
new_task.locations.clone(),
new_task.priority,
),
);
tasks_to_create.push(new_task);
}
}
let mut all_warnings = Vec::new();
if !root_in_extracted {
all_warnings.extend(self.resolve_dependencies(&mut root_clone));
}
for t in &mut tasks_to_update {
all_warnings.extend(self.resolve_dependencies(t));
}
for t in &mut tasks_to_create {
all_warnings.extend(self.resolve_dependencies(t));
}
if !root_in_extracted {
actions.push(crate::journal::Action::Update(root_clone.clone()));
self.update_or_add_task(root_clone);
}
for t in tasks_to_update {
actions.push(crate::journal::Action::Update(t.clone()));
self.update_or_add_task(t);
}
for t in tasks_to_create {
actions.push(crate::journal::Action::Create(t.clone()));
self.add_task(t);
}
for old_uid in old_descendants {
if !active_uids.contains(&old_uid)
&& let Some((deleted, trashed_opt)) =
self.soft_delete_task(&old_uid, options.trash_retention_days)
{
actions.push(crate::journal::Action::Delete(deleted));
if let Some(trashed) = trashed_opt {
actions.push(crate::journal::Action::Create(trashed));
}
}
}
Ok((actions, all_warnings))
}
pub fn get_descendant_uids(&self, root_uid: &str) -> Vec<String> {
let mut descendants = Vec::new();
let mut queue: Vec<&str> = vec![root_uid];
let mut visited: HashSet<&str> = HashSet::new();
while let Some(curr) = queue.pop() {
if !visited.insert(curr) {
continue;
}
if curr != root_uid {
descendants.push(curr.to_string());
}
if let Some(children) = self.children_index.get(curr) {
queue.extend(children.iter().map(|s| s.as_str()));
}
}
descendants
}
pub fn duplicate_task_tree(&mut self, root_uid: &str) -> Vec<Task> {
let mut new_tasks = Vec::new();
let mut uid_map = HashMap::new(); let mut descendants = self.get_descendant_uids(root_uid);
descendants.insert(0, root_uid.to_string());
for old_uid in &descendants {
if let Some(t) = self.get_task_ref(old_uid) {
let mut clone = t.clone();
let new_uid = uuid::Uuid::new_v4().to_string();
uid_map.insert(old_uid.clone(), new_uid.clone());
clone.uid = new_uid;
clone.href = String::new();
clone.etag = String::new();
clone.status = crate::model::TaskStatus::NeedsAction;
clone.percent_complete = None;
clone.time_spent_seconds = 0;
clone.last_started_at = None;
clone.sessions.clear();
clone
.unmapped_properties
.retain(|p| p.key != "COMPLETED" && p.key != "CREATED");
clone
.alarms
.retain(|a| !a.is_snooze() && a.acknowledged.is_none());
if old_uid == root_uid {
clone.summary = format!("{} (Copy)", clone.summary);
}
new_tasks.push(clone);
}
}
for t in &mut new_tasks {
if let Some(p) = &t.parent_uid
&& let Some(new_p) = uid_map.get(p)
{
t.parent_uid = Some(new_p.clone());
}
let old_deps = std::mem::take(&mut t.dependencies);
for d in old_deps {
if let Some(new_d) = uid_map.get(&d) {
t.dependencies.push(new_d.clone());
} else {
t.dependencies.push(d); }
}
let old_rels = std::mem::take(&mut t.related_to);
for r in old_rels {
if let Some(new_r) = uid_map.get(&r) {
t.related_to.push(new_r.clone());
} else {
t.related_to.push(r);
}
}
}
for t in &new_tasks {
self.update_or_add_task(t.clone());
}
new_tasks
}
pub fn set_parent(
&mut self,
child_uid: &str,
parent_uid: Option<String>,
) -> Result<Task, &'static str> {
if let Some(p_uid) = &parent_uid {
if p_uid == child_uid {
return Err(Box::leak(
rust_i18n::t!("error_cannot_be_child_of_self")
.into_owned()
.into_boxed_str(),
));
}
if self.is_descendant_of(p_uid, child_uid) {
return Err("Cycle detected: Cannot set a task as a child of its own descendant");
}
}
let old_parent_uid = self
.get_task_ref(child_uid)
.and_then(|t| t.parent_uid.clone());
if let Some(old_parent) = old_parent_uid
&& let Some(list) = self.children_index.get_mut(&old_parent)
{
list.retain(|u| u != child_uid);
if list.is_empty() {
self.children_index.remove(&old_parent);
}
}
let new_parent_uid = parent_uid.clone();
if let Some((task, _)) = self.get_task_mut(child_uid) {
task.parent_uid = parent_uid;
task.sequence += 1;
let result = Ok(task.clone());
if let Some(p_uid) = new_parent_uid {
self.children_index
.entry(p_uid)
.or_default()
.push(child_uid.to_string());
}
return result;
}
Err(Box::leak(
rust_i18n::t!("error_task_not_found")
.into_owned()
.into_boxed_str(),
))
}
pub fn add_dependency(&mut self, task_uid: &str, dep_uid: String) -> Option<Task> {
if let Some((task, _)) = self.get_task_mut(task_uid)
&& !task.dependencies.contains(&dep_uid)
{
task.dependencies.push(dep_uid.clone());
task.sequence += 1;
let task_clone = task.clone();
self.blocking_index
.entry(dep_uid)
.or_default()
.push(task_uid.to_string());
return Some(task_clone);
}
None
}
pub fn remove_dependency(&mut self, task_uid: &str, dep_uid: &str) -> Option<Task> {
if let Some((task, _)) = self.get_task_mut(task_uid)
&& let Some(pos) = task.dependencies.iter().position(|d| d == dep_uid)
{
task.dependencies.remove(pos);
task.sequence += 1;
let task_clone = task.clone();
if let Some(list) = self.blocking_index.get_mut(dep_uid) {
list.retain(|u| u != task_uid);
if list.is_empty() {
self.blocking_index.remove(dep_uid);
}
}
return Some(task_clone);
}
None
}
pub fn add_related_to(&mut self, task_uid: &str, related_uid: String) -> Option<Task> {
let result = if let Some((task, _)) = self.get_task_mut(task_uid)
&& !task.related_to.contains(&related_uid)
{
task.related_to.push(related_uid.clone());
task.sequence += 1;
Some(task.clone())
} else {
None
};
if result.is_some() {
self.related_from_index
.entry(related_uid)
.or_default()
.push(task_uid.to_string());
}
result
}
pub fn remove_related_to(&mut self, task_uid: &str, related_uid: &str) -> Option<Task> {
let result = if let Some((task, _)) = self.get_task_mut(task_uid)
&& let Some(pos) = task.related_to.iter().position(|r| r == related_uid)
{
task.related_to.remove(pos);
task.sequence += 1;
Some(task.clone())
} else {
None
};
if result.is_some()
&& let Some(sources) = self.related_from_index.get_mut(related_uid)
{
sources.retain(|uid| uid != task_uid);
if sources.is_empty() {
self.related_from_index.remove(related_uid);
}
}
result
}
pub fn move_task(&mut self, uid: &str, target_href: String) -> Option<(Task, Task)> {
if let Some((mut task, old_href)) = self.delete_task(uid) {
if old_href == target_href {
self.add_task(task);
return None;
}
let original = task.clone();
task.calendar_href = target_href.clone();
task.sequence += 1;
self.add_task(task.clone());
return Some((original, task));
}
None
}
pub fn apply_alias_retroactively(
&mut self,
alias_key: &str,
raw_values: &[String],
) -> Vec<Task> {
let mut uids_to_update: Vec<String> = Vec::new();
let is_location_alias = alias_key.starts_with("@@");
let (clean_key, alias_prefix) = if is_location_alias {
let clean = alias_key.trim_start_matches("@@");
(clean, format!("{}:", clean))
} else {
(alias_key, format!("{}:", alias_key))
};
for map in self.calendars.values() {
for task in map.values() {
let has_alias_or_child = if is_location_alias {
task.locations
.iter()
.any(|loc| loc == clean_key || loc.starts_with(&alias_prefix))
} else {
task.categories
.iter()
.any(|cat| cat == clean_key || cat.starts_with(&alias_prefix))
};
if has_alias_or_child {
let mut needs_update = false;
for val in raw_values {
if let Some(tag) = val.strip_prefix('#') {
let clean = crate::model::parser::strip_quotes(tag);
if !task.categories.contains(&clean) {
needs_update = true;
break;
}
} else if let Some(loc) = val.strip_prefix("@@") {
let clean = crate::model::parser::strip_quotes(loc);
if !task.locations.contains(&clean) {
needs_update = true;
break;
}
} else if let Some(prio) = val.strip_prefix('!')
&& let Ok(p) = prio.parse::<u8>()
&& task.priority != p
{
needs_update = true;
break;
}
}
if needs_update {
uids_to_update.push(task.uid.clone());
}
}
}
}
if uids_to_update.is_empty() {
return Vec::new();
}
let mut modified_tasks = Vec::new();
for uid in uids_to_update {
if let Some((task, _)) = self.get_task_mut(&uid) {
for val in raw_values {
if let Some(tag) = val.strip_prefix('#') {
let clean = crate::model::parser::strip_quotes(tag);
if !task.categories.contains(&clean) {
task.categories.push(clean);
}
} else if let Some(loc) = val.strip_prefix("@@") {
let clean_loc = crate::model::parser::strip_quotes(loc);
if !task.locations.contains(&clean_loc) {
task.locations.push(clean_loc);
}
} else if let Some(prio) = val.strip_prefix('!')
&& let Ok(p) = prio.parse::<u8>()
{
task.priority = p.min(9);
}
}
task.categories.sort();
task.categories.dedup();
task.sequence += 1;
modified_tasks.push(task.clone());
}
}
modified_tasks
}
pub fn is_task_done(&self, uid: &str) -> Option<bool> {
self.get_task_ref(uid).map(|t| t.status.is_done())
}
pub fn is_blocked(&self, task: &Task) -> bool {
if task.manual_block {
return true;
}
if task
.categories
.iter()
.any(|c| c.eq_ignore_ascii_case("blocked"))
{
return true;
}
if task.dependencies.is_empty() {
return false;
}
for dep_uid in &task.dependencies {
if let Some(is_done) = self.is_task_done(dep_uid)
&& !is_done
{
return true;
}
}
false
}
pub fn get_tasks_related_to(&self, uid: &str) -> Vec<(String, String)> {
if let Some(source_uids) = self.related_from_index.get(uid) {
let mut tasks: Vec<_> = source_uids
.iter()
.filter_map(|source_uid| self.get_task_ref(source_uid))
.collect();
tasks.sort_by(|a, b| {
let a_date = a.completion_date().or_else(|| a.created_date());
let b_date = b.completion_date().or_else(|| b.created_date());
b_date.cmp(&a_date).then_with(|| a.summary.cmp(&b.summary))
});
tasks
.into_iter()
.map(|t| (t.uid.clone(), t.summary.clone()))
.collect()
} else {
Vec::new()
}
}
pub fn get_tasks_blocking(&self, uid: &str) -> Vec<(String, String)> {
if let Some(blocked_uids) = self.blocking_index.get(uid) {
let mut tasks: Vec<_> = blocked_uids
.iter()
.filter_map(|blocked_uid| self.get_task_ref(blocked_uid))
.collect();
tasks.sort_by(|a, b| {
let a_date = a.created_date();
let b_date = b.created_date();
a_date.cmp(&b_date).then_with(|| a.summary.cmp(&b.summary))
});
tasks
.into_iter()
.map(|t| (t.uid.clone(), t.summary.clone()))
.collect()
} else {
Vec::new()
}
}
pub fn get_completion_history_stats(&self, uid: &str, rrule: &str) -> (u32, u32, &'static str) {
let (days, key) = if rrule.contains("FREQ=YEARLY") {
(1825, "window_5_years")
} else if rrule.contains("FREQ=MONTHLY") && rrule.contains("INTERVAL=6") {
(1095, "window_3_years")
} else if rrule.contains("FREQ=MONTHLY") && rrule.contains("INTERVAL=3") {
(365, "window_12_months")
} else if rrule.contains("FREQ=MONTHLY") {
(180, "window_6_months")
} else if rrule.contains("FREQ=WEEKLY") {
(84, "window_12_weeks")
} else {
(30, "window_30_days")
};
let mut count = 0;
let now = chrono::Utc::now();
if let Some(sources) = self.related_from_index.get(uid) {
for s_uid in sources {
if let Some(t) = self.get_task_ref(s_uid)
&& t.status == crate::model::TaskStatus::Completed
&& t.calendar_href != crate::storage::LOCAL_TRASH_HREF
&& t.calendar_href != "local://recovery"
&& t.unmapped_properties
.iter()
.any(|p| p.key == "X-CFAIT-HISTORY-OF" && p.value == uid)
&& let Some(comp) = t.completion_date()
{
let days_diff = (now - comp).num_days();
if (-1..=days as i64).contains(&days_diff) {
count += 1;
}
}
}
}
(count, days, key)
}
fn task_is_claimed_by_goal(
&self,
t: &crate::model::Task,
is_tag: bool,
is_task: bool,
clean_key: &str,
) -> bool {
let prefix = format!("{}:", clean_key);
let explicit_match = |task: &crate::model::Task| {
if is_tag {
task.categories
.iter()
.any(|c| c == clean_key || c.starts_with(&prefix))
} else if is_task {
task.uid == clean_key
|| task
.unmapped_properties
.iter()
.any(|p| p.key == "X-CFAIT-HISTORY-OF" && p.value == clean_key)
} else {
task.locations
.iter()
.any(|l| l == clean_key || l.starts_with(&prefix))
}
};
if explicit_match(t) {
return true;
}
let mut curr = t.parent_uid.as_deref();
let mut visited = std::collections::HashSet::new();
while let Some(p_uid) = curr {
if !visited.insert(p_uid) {
break;
}
if let Some(p) = self.get_task_ref(p_uid) {
if is_task {
if p_uid == clean_key {
return true;
}
} else if explicit_match(p) {
return true;
}
curr = p.parent_uid.as_deref();
} else {
break;
}
}
false
}
pub fn calculate_goal_progress_for_bounds(
&self,
key: &str,
goal: &crate::config::Goal,
start_ts: i64,
end_ts: i64,
) -> u32 {
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let default_dur = config.default_duration_goal_mins;
let count_sessions = config.sessions_count_as_completions;
let now = chrono::Utc::now();
let now_ts = now.timestamp();
let is_tag = key.starts_with('#');
let is_task = key.starts_with("task:");
let clean_key = if is_tag {
key.trim_start_matches('#')
} else if key.starts_with("@@") {
key.trim_start_matches("@@")
} else if is_task {
key.trim_start_matches("task:")
} else {
key
};
struct ClaimedTask<'a> {
task: &'a crate::model::Task,
sessions: Vec<(i64, i64)>,
has_sessions_in_period: bool,
}
let mut claimed_tasks: Vec<ClaimedTask> = Vec::new();
let mut est_credits: Vec<u32> = Vec::new();
for (href, map) in &self.calendars {
if href == crate::storage::LOCAL_TRASH_HREF || href == "local://recovery" {
continue;
}
for t in map.values() {
if !self.task_is_claimed_by_goal(t, is_tag, is_task, clean_key) {
continue;
}
let mut sessions: Vec<(i64, i64)> = Vec::new();
let mut has_sessions_in_period = false;
for session in &t.sessions {
if session.end >= start_ts && session.start < end_ts {
let clip_start = session.start.max(start_ts);
let clip_end = session.end.min(end_ts);
if clip_end > clip_start {
sessions.push((clip_start, clip_end));
has_sessions_in_period = true;
}
}
}
if let Some(start) = t.last_started_at {
let current = now_ts.min(end_ts);
if current > start_ts {
let clip_start = start.max(start_ts);
if current > clip_start {
sessions.push((clip_start, current));
has_sessions_in_period = true;
}
}
}
if t.status == crate::model::TaskStatus::Completed
&& let Some(comp) = t.completion_date()
&& comp.timestamp() >= start_ts
&& comp.timestamp() < end_ts
{
let total_tracked = (t.time_spent_seconds / 60) as u32;
let est = t.estimated_duration.unwrap_or(default_dur);
if est > total_tracked {
est_credits.push(est - total_tracked);
}
}
claimed_tasks.push(ClaimedTask {
task: t,
sessions,
has_sessions_in_period,
});
}
}
match goal.goal_type {
crate::config::GoalType::Duration => {
let mut all_intervals: Vec<(i64, i64)> = claimed_tasks
.iter()
.flat_map(|ct| ct.sessions.iter().copied())
.collect();
all_intervals.sort_unstable_by_key(|(s, _)| *s);
let mut merged: Vec<(i64, i64)> = Vec::with_capacity(all_intervals.len());
for (s, e) in all_intervals {
if let Some(last) = merged.last_mut()
&& s <= last.1
{
last.1 = last.1.max(e);
continue;
}
merged.push((s, e));
}
let mut progress = 0u32;
for (s, e) in &merged {
if e > s {
progress += ((e - s) as u32) / 60;
}
}
for credit in &est_credits {
progress += credit;
}
progress
}
crate::config::GoalType::Count => {
let mut progress = 0u32;
for ct in &claimed_tasks {
let mut task_progress = 0u32;
if count_sessions {
let desc_intervals =
self.collect_descendant_intervals(&ct.task.uid, now_ts);
for session in &ct.task.sessions {
if session.start >= start_ts
&& session.start < end_ts
&& !session_fully_covered(
session.start,
session.end,
&desc_intervals,
)
{
task_progress += 1;
}
}
if let Some(start) = ct.task.last_started_at
&& start >= start_ts
&& start < end_ts
&& !session_fully_covered(start, now_ts, &desc_intervals)
{
task_progress += 1;
}
}
if ct.task.status == crate::model::TaskStatus::Completed
&& let Some(comp) = ct.task.completion_date()
&& comp.timestamp() >= start_ts
&& comp.timestamp() < end_ts
&& task_progress == 0
&& !ct.has_sessions_in_period
{
task_progress += 1;
}
progress += task_progress;
}
progress
}
}
}
fn collect_descendant_intervals(&self, uid: &str, now_ts: i64) -> Vec<(i64, i64)> {
if !self.children_index.contains_key(uid) {
return Vec::new();
}
let mut intervals: Vec<(i64, i64)> = self
.get_descendant_uids(uid)
.iter()
.filter_map(|d_uid| self.get_task_ref(d_uid))
.flat_map(|d| {
d.sessions
.iter()
.map(|s| (s.start, s.end))
.chain(d.last_started_at.map(|s| (s, now_ts)))
})
.collect();
intervals.sort_unstable_by_key(|(s, _)| *s);
let mut merged: Vec<(i64, i64)> = Vec::with_capacity(intervals.len());
for (s, e) in intervals {
if let Some(last) = merged.last_mut()
&& s <= last.1
{
last.1 = last.1.max(e);
continue;
}
merged.push((s, e));
}
merged
}
pub fn get_aggregated_time_seconds(&self, uid: &str) -> u64 {
let now_ts = chrono::Utc::now().timestamp();
let mut intervals: Vec<(i64, i64)> = Vec::new();
if let Some(t) = self.get_task_ref(uid) {
for s in &t.sessions {
intervals.push((s.start, s.end));
}
if let Some(start) = t.last_started_at {
intervals.push((start, now_ts));
}
}
for d_uid in self.get_descendant_uids(uid) {
if let Some(d) = self.get_task_ref(&d_uid) {
for s in &d.sessions {
intervals.push((s.start, s.end));
}
if let Some(start) = d.last_started_at {
intervals.push((start, now_ts));
}
}
}
if intervals.is_empty() {
return 0;
}
intervals.sort_unstable_by_key(|(s, _)| *s);
let mut total: i64 = 0;
let mut merged: Vec<(i64, i64)> = Vec::with_capacity(intervals.len());
for (s, e) in intervals {
if let Some(last) = merged.last_mut()
&& s <= last.1
{
last.1 = last.1.max(e);
continue;
}
merged.push((s, e));
}
for (s, e) in &merged {
if e > s {
total += e - s;
}
}
total.max(0) as u64
}
pub fn calculate_goal_progress(&self, key: &str, goal: &crate::config::Goal) -> u32 {
let now = chrono::Utc::now();
let (start_ts, end_ts) = goal.interval.get_period_bounds(now, 0);
self.calculate_goal_progress_for_bounds(key, goal, start_ts, end_ts)
}
pub fn calculate_goal_history(
&self,
key: &str,
goal: &crate::config::Goal,
periods: u32,
) -> Vec<f32> {
let now = chrono::Utc::now();
let mut history = Vec::with_capacity(periods as usize);
let start_offset = -(periods as i32) + 1;
for offset in start_offset..=0 {
let (start_ts, end_ts) = goal.interval.get_period_bounds(now, offset);
let prog = self.calculate_goal_progress_for_bounds(key, goal, start_ts, end_ts);
let pct = if goal.target > 0 {
(prog as f32 / goal.target as f32).clamp(0.0, 1.0)
} else {
0.0
};
history.push(pct);
}
history
}
pub fn rebuild_relation_index(&mut self) {
self.related_from_index.clear();
self.blocking_index.clear();
self.children_index.clear();
let mut relationships = Vec::new();
let mut blocking_rels = Vec::new();
let mut children_rels = Vec::new();
for map in self.calendars.values() {
for (uid, task) in map {
for r in &task.related_to {
relationships.push((r.clone(), uid.clone()));
}
for dep in &task.dependencies {
blocking_rels.push((dep.clone(), uid.clone()));
}
if let Some(p) = &task.parent_uid {
children_rels.push((p.clone(), uid.clone()));
}
}
}
for (from, to) in relationships {
self.related_from_index.entry(from).or_default().push(to);
}
for (from, to) in blocking_rels {
self.blocking_index.entry(from).or_default().push(to);
}
for (from, to) in children_rels {
self.children_index.entry(from).or_default().push(to);
}
}
pub fn get_summary(&self, uid: &str) -> Option<String> {
self.get_task_ref(uid).map(|t| t.summary.clone())
}
pub fn get_journal_entry(&self, calendar_href: &str, date: chrono::NaiveDate) -> Option<&Task> {
if let Some(map) = self.calendars.get(calendar_href) {
for task in map.values() {
if task.is_journal && task.dtstart.as_ref().map(|d| d.to_date_naive()) == Some(date)
{
return Some(task);
}
}
}
None
}
pub fn get_day_context(
&self,
date: chrono::NaiveDate,
visible_cals: &HashSet<String>,
) -> DayContext {
let mut ctx = DayContext {
date,
..Default::default()
};
let day_start_utc = crate::model::item::safe_local_to_utc(
date,
chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
)
.timestamp();
let day_end_utc = crate::model::item::safe_local_to_utc(
date,
chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap(),
)
.timestamp();
for (href, map) in &self.calendars {
if !visible_cals.contains(href)
|| href == crate::storage::LOCAL_TRASH_HREF
|| href == "local://recovery"
{
continue;
}
for task in map.values() {
if task.is_journal {
continue;
}
let is_done = task.status.is_done();
let mut completed_today = false;
if is_done && let Some(comp_dt) = task.completion_date() {
let local_comp = comp_dt.with_timezone(&chrono::Local).date_naive();
if local_comp == date {
ctx.completed_tasks.push(task.clone());
completed_today = true;
}
}
if !is_done
&& let Some(due) = &task.due
&& due.to_date_naive() == date
{
ctx.due_tasks.push(task.clone());
}
if !completed_today {
if let Some(start) = &task.dtstart
&& start.to_date_naive() == date
{
ctx.started_tasks.push(task.clone());
}
if task.status == crate::model::TaskStatus::InProcess {
ctx.ongoing_tasks.push(task.clone());
}
}
let mut task_mins_today = 0;
for session in &task.sessions {
if session.end >= day_start_utc && session.start <= day_end_utc {
let overlap_start = session.start.max(day_start_utc);
let overlap_end = session.end.min(day_end_utc);
if overlap_end > overlap_start {
task_mins_today += ((overlap_end - overlap_start) / 60) as u32;
}
}
}
if task_mins_today > 0 {
ctx.total_tracked_mins += task_mins_today;
if !completed_today {
ctx.session_tasks.push((task.clone(), task_mins_today));
}
}
}
}
ctx
}
fn task_matches_categories(t: &Task, selected: &HashSet<String>, match_all: bool) -> bool {
if selected.is_empty() {
return true;
}
let filter_uncategorized = selected.contains(UNCATEGORIZED_ID);
let check_match = |task_cat: &str, selected: &str| -> bool {
if task_cat.is_ascii() && selected.is_ascii() {
let tc = task_cat.as_bytes();
let sel = selected.as_bytes();
if tc.eq_ignore_ascii_case(sel) {
return true;
}
if tc.len() > sel.len()
&& tc[sel.len()] == b':'
&& tc[..sel.len()].eq_ignore_ascii_case(sel)
{
return true;
}
return false;
}
let tc_lower = task_cat.to_lowercase();
let sel_lower = selected.to_lowercase();
if tc_lower == sel_lower {
return true;
}
if let Some(stripped) = tc_lower.strip_prefix(&sel_lower) {
return stripped.starts_with(':');
}
false
};
if match_all {
for sel in selected {
if sel == UNCATEGORIZED_ID {
if !t.categories.is_empty() || !t.transient_desc_tags.is_empty() {
return false;
}
} else {
let mut has = false;
for c in t.categories.iter().chain(t.transient_desc_tags.iter()) {
if check_match(c, sel) {
has = true;
break;
}
}
if !has {
return false;
}
}
}
} else {
let mut hit = false;
if filter_uncategorized && t.categories.is_empty() && t.transient_desc_tags.is_empty() {
hit = true;
} else {
for sel in selected {
if sel != UNCATEGORIZED_ID {
for c in t.categories.iter().chain(t.transient_desc_tags.iter()) {
if check_match(c, sel) {
hit = true;
break;
}
}
}
if hit {
break;
}
}
}
if !hit {
return false;
}
}
true
}
fn task_matches_locations(t: &Task, selected: &HashSet<String>) -> bool {
if selected.is_empty() {
return true;
}
if t.locations.is_empty() && t.transient_desc_locs.is_empty() {
return false;
}
let mut hit = false;
for loc in t.locations.iter().chain(t.transient_desc_locs.iter()) {
for sel in selected {
if loc.is_ascii() && sel.is_ascii() {
let l_b = loc.as_bytes();
let s_b = sel.as_bytes();
if l_b.eq_ignore_ascii_case(s_b) {
hit = true;
break;
}
if l_b.len() > s_b.len()
&& l_b[s_b.len()] == b':'
&& l_b[..s_b.len()].eq_ignore_ascii_case(s_b)
{
hit = true;
break;
}
} else {
let loc_lower = loc.to_lowercase();
let sel_lower = sel.to_lowercase();
if loc_lower == sel_lower {
hit = true;
break;
}
if let Some(stripped) = loc_lower.strip_prefix(&sel_lower)
&& stripped.starts_with(':')
{
hit = true;
break;
}
}
}
if hit {
break;
}
}
hit
}
pub fn filter(&self, options: FilterOptions) -> FilterResult {
let lex_guard = crate::model::parser::LEXICON.read().unwrap();
let lex = &*lex_guard;
let mut completed_uids: HashSet<String> = HashSet::new();
let mut tree_loc_counts: HashMap<String, usize> = HashMap::new();
for map in self.calendars.values() {
for t in map.values() {
if t.status.is_done() {
completed_uids.insert(t.uid.clone());
}
if t.geo.is_some() {
*tree_loc_counts.entry(t.uid.clone()).or_insert(0) += 1;
let mut curr = t.uid.as_str();
let mut visited = HashSet::new();
while let Some(p_uid) = self
.get_task_ref(curr)
.and_then(|tsk| tsk.parent_uid.as_deref())
{
if !visited.insert(p_uid) {
break;
}
*tree_loc_counts.entry(p_uid.to_string()).or_insert(0) += 1;
curr = p_uid;
}
}
}
}
let check_is_blocked_explicit = |t: &Task, done_set: &HashSet<String>| -> bool {
if t.manual_block {
return true;
}
if t.dependencies.is_empty() {
return false;
}
for dep in &t.dependencies {
if self.index.contains_key(dep) && !done_set.contains(dep) {
return true;
}
}
false
};
let check_is_effectively_blocked = |t: &Task, done_set: &HashSet<String>| -> bool {
let mut curr = t;
let mut visited = HashSet::new();
loop {
if check_is_blocked_explicit(curr, done_set) {
return true;
}
if let Some(p_uid) = curr.parent_uid.as_deref() {
if !visited.insert(p_uid) {
break;
}
if let Some(p_task) = self.get_task_ref(p_uid) {
curr = p_task;
continue;
}
}
break;
}
false
};
let search_lower = options.search_term.to_lowercase();
let mut is_ready_mode = false;
let mut is_blocked_mode = false;
let mut has_status_filter = false;
for word in search_lower.split_whitespace() {
let w = word.trim_start_matches('-'); if w == "is:ready" || lex.search_is_ready.iter().any(|x| x.as_str() == w) {
is_ready_mode = true;
} else if w == "is:blocked" || lex.search_is_blocked.iter().any(|x| x.as_str() == w) {
is_blocked_mode = true;
} else if w == "is:done"
|| lex.search_is_done.iter().any(|x| x.as_str() == w)
|| w == "is:active"
|| lex.search_is_active.iter().any(|x| x.as_str() == w)
|| w == "is:started"
|| lex.search_is_started.iter().any(|x| x.as_str() == w)
|| w == "is:ongoing"
|| lex.search_is_ongoing.iter().any(|x| x.as_str() == w)
{
has_status_filter = true;
}
}
let now = Utc::now();
let check_is_effectively_future = |t: &Task| -> bool {
let mut current = t;
let mut visited = HashSet::new();
loop {
if let Some(start) = ¤t.dtstart
&& start.to_start_comparison_time() > now
{
return true;
}
if let Some(p_uid) = current.parent_uid.as_deref() {
if !visited.insert(p_uid) {
break;
}
if let Some(p_task) = self.get_task_ref(p_uid) {
current = p_task;
continue;
}
}
break;
}
false
};
let focus_set: Option<HashSet<String>> = options.focused_task_uid.map(|uid| {
let mut set: HashSet<String> = self.get_descendant_uids(uid).into_iter().collect();
set.insert(uid.to_string());
set
});
let all_allowed_refs: Vec<&Task> = self
.calendars
.iter()
.filter(|(href, _)| {
if options.focused_task_uid.is_some() {
*href != crate::storage::LOCAL_TRASH_HREF && *href != "local://recovery"
} else if let Some(active) = options.active_cal_href {
*href == active && !options.hidden_calendars.contains(*href)
} else {
!options.hidden_calendars.contains(*href)
}
})
.flat_map(|(_, map)| map.values())
.collect();
let mut eff_blocked_map: HashMap<&str, bool> =
HashMap::with_capacity(all_allowed_refs.len());
let mut eff_future_map: HashMap<&str, bool> =
HashMap::with_capacity(all_allowed_refs.len());
for t in &all_allowed_refs {
eff_blocked_map.insert(
t.uid.as_str(),
check_is_effectively_blocked(t, &completed_uids),
);
eff_future_map.insert(t.uid.as_str(), check_is_effectively_future(t));
}
let query = crate::model::matcher::Query::new(options.search_term);
let run_pipeline = |ignore_categories: bool,
ignore_locations: bool|
-> (Vec<&Task>, HashSet<&str>) {
let scoped_refs: Vec<&Task> = all_allowed_refs
.iter()
.copied()
.filter(|t| {
if let Some(fs) = &focus_set
&& !fs.contains(&t.uid)
{
return false;
}
if t.uid == "cfait-global-settings-v1"
|| t.summary.starts_with("âš™ Cfait Settings")
{
return false;
}
true
})
.collect();
let base_refs: Vec<&Task> = scoped_refs
.iter()
.copied()
.filter(|t| {
let is_system_cal = t.calendar_href == crate::storage::LOCAL_TRASH_HREF
|| t.calendar_href == "local://recovery";
if t.is_journal
&& !self.children_index.contains_key(&t.uid)
&& t.parent_uid.is_none()
&& !is_system_cal
&& !t.is_note
&& !t.pinned
&& options.search_term.trim().is_empty()
{
return false;
}
if !has_status_filter && t.status.is_done() && options.hide_completed_global {
return false;
}
if is_ready_mode {
if t.status.is_done() {
return false;
}
if t.status != TaskStatus::InProcess {
if *eff_future_map.get(t.uid.as_str()).unwrap_or(&false) {
return false;
}
if *eff_blocked_map.get(t.uid.as_str()).unwrap_or(&false) {
return false;
}
}
if (t.is_note || t.is_journal)
&& !self.children_index.contains_key(&t.uid)
&& !(t.is_journal && (t.is_note || t.pinned || t.parent_uid.is_some()))
{
return false;
}
}
if is_blocked_mode && !eff_blocked_map.get(t.uid.as_str()).unwrap_or(&false) {
return false;
}
if let Some(mins) = t.estimated_duration {
if let Some(min) = options.min_duration
&& mins < min
{
return false;
}
if let Some(max) = options.max_duration
&& mins > max
{
return false;
}
} else if !options.include_unset_duration {
return false;
}
true
})
.collect();
let is_match = |t: &Task| -> bool {
if !ignore_categories
&& !Self::task_matches_categories(
t,
options.selected_categories,
options.match_all_categories,
)
{
return false;
}
if !ignore_locations && !Self::task_matches_locations(t, options.selected_locations)
{
return false;
}
if !options.search_term.is_empty() && !query.matches(t, lex, self) {
return false;
}
true
};
let needs_expansion = !options.search_term.is_empty()
|| (!ignore_categories && !options.selected_categories.is_empty())
|| (!ignore_locations && !options.selected_locations.is_empty());
let mut valid_direct_matches: HashSet<&str> = HashSet::new();
let mut expanded: HashSet<&str> = HashSet::new();
if needs_expansion {
let mut children_map: HashMap<&str, Vec<&str>> = HashMap::new();
for t in &scoped_refs {
if let Some(p) = &t.parent_uid {
children_map
.entry(p.as_str())
.or_default()
.push(t.uid.as_str());
}
}
let mut direct_matches_initial: HashSet<&str> = HashSet::new();
for t in &scoped_refs {
if is_match(t) {
direct_matches_initial.insert(t.uid.as_str());
}
}
let mut expand_queue: Vec<&str> = direct_matches_initial.into_iter().collect();
let mut idx = 0;
while idx < expand_queue.len() {
let curr = expand_queue[idx];
idx += 1;
if !expanded.insert(curr) {
continue;
}
if let Some(children) = children_map.get(curr) {
for child in children {
expand_queue.push(*child);
}
}
}
for t in &base_refs {
if expanded.contains(t.uid.as_str()) {
valid_direct_matches.insert(t.uid.as_str());
}
}
} else {
for t in &base_refs {
valid_direct_matches.insert(t.uid.as_str());
expanded.insert(t.uid.as_str());
}
}
let mut context_matches: HashSet<&str> = HashSet::new();
for &uid in &valid_direct_matches {
let mut curr = uid;
while let Some(p) = self
.get_task_ref(curr)
.and_then(|t| t.parent_uid.as_deref())
{
if !context_matches.insert(p) {
break;
}
curr = p;
}
}
let mut filtered_refs: Vec<&Task> = scoped_refs
.iter()
.copied()
.filter(|t| {
valid_direct_matches.contains(t.uid.as_str())
|| context_matches.contains(t.uid.as_str())
})
.collect();
let scoped_uids: HashSet<&str> = scoped_refs.iter().map(|t| t.uid.as_str()).collect();
for &ctx_uid in &context_matches {
if !scoped_uids.contains(ctx_uid)
&& let Some(t) = self.get_task_ref(ctx_uid)
{
filtered_refs.push(t);
}
}
(filtered_refs, expanded)
};
let (final_refs, direct_matches) = run_pipeline(false, false);
let tag_refs = if options.match_all_categories {
final_refs.clone()
} else {
run_pipeline(true, false).0
};
let loc_refs = run_pipeline(false, true).0;
let mut cat_active_counts: HashMap<String, u32> = HashMap::new();
let mut cat_display_names: HashMap<String, String> = HashMap::new();
let mut cat_present_lower: HashSet<String> = HashSet::new();
let mut uncat_active_count: u32 = 0;
let mut uncat_any = false;
let mut loc_active_counts: HashMap<String, u32> = HashMap::new();
let mut loc_present: HashSet<String> = HashSet::new();
for t in &tag_refs {
let is_active = !t.status.is_done();
if t.categories.is_empty() && t.transient_desc_tags.is_empty() {
uncat_any = true;
if is_active {
uncat_active_count += 1;
}
} else {
let mut seen_for_task = HashSet::new();
for cat in t.categories.iter().chain(t.transient_desc_tags.iter()) {
let cat_lower = cat.to_lowercase();
let parts: Vec<&str> = cat.split(':').collect();
let parts_lower: Vec<&str> = cat_lower.split(':').collect();
let mut current_hierarchy = String::with_capacity(cat.len());
let mut current_lower = String::with_capacity(cat.len());
for (i, (part, part_lower)) in parts.iter().zip(parts_lower.iter()).enumerate()
{
if i > 0 {
current_hierarchy.push(':');
current_lower.push(':');
}
current_hierarchy.push_str(part);
current_lower.push_str(part_lower);
if !seen_for_task.insert(current_lower.clone()) {
continue;
}
let already_present = cat_present_lower.contains(¤t_lower);
if !already_present {
cat_present_lower.insert(current_lower.clone());
cat_display_names
.entry(current_lower.clone())
.or_insert_with(|| current_hierarchy.clone());
}
if is_active {
if let Some(v) = cat_active_counts.get_mut(¤t_lower) {
*v += 1;
} else {
cat_active_counts.insert(current_lower.clone(), 1);
}
}
}
}
}
}
for t in &loc_refs {
let is_active = !t.status.is_done();
for loc in t.locations.iter().chain(t.transient_desc_locs.iter()) {
let parts: Vec<&str> = loc.split(':').collect();
let mut current_hierarchy = String::with_capacity(loc.len());
for (i, part) in parts.iter().enumerate() {
if i > 0 {
current_hierarchy.push(':');
}
current_hierarchy.push_str(part);
if is_active {
*loc_active_counts
.entry(current_hierarchy.clone())
.or_insert(0) += 1;
}
loc_present.insert(current_hierarchy.clone());
}
}
}
let build_aggregates = |counts: HashMap<String, u32>,
display_names: HashMap<String, String>,
expanded_set: &HashSet<String>,
selected_set: &HashSet<String>,
is_location: bool|
-> Vec<AggregateItem> {
let mut forced_expanded = HashSet::new();
for sel in selected_set {
let clean_sel = if is_location {
sel.strip_prefix("@@").unwrap_or(sel)
} else {
sel.strip_prefix("#").unwrap_or(sel)
};
let parts: Vec<&str> = clean_sel.split(':').collect();
let mut current = String::new();
for part in parts {
if !current.is_empty() {
current.push(':');
}
current.push_str(part);
forced_expanded.insert(current.clone());
}
}
let mut sorted_keys: Vec<String> = counts.keys().cloned().collect();
sorted_keys.sort();
let mut results = Vec::new();
for key in &sorted_keys {
if key == UNCATEGORIZED_ID {
results.push(AggregateItem {
full_key: key.clone(),
display_name: rust_i18n::t!("uncategorized").to_string(),
count: counts[key],
depth: 0,
has_children: false,
is_expanded: false,
});
continue;
}
let count = counts[key];
let parts: Vec<&str> = key.split(':').collect();
let depth_val = parts.len() - 1;
let depth = depth_val as u32;
let original_full = display_names
.get(key)
.map(|s| s.as_str())
.unwrap_or(key.as_str());
let original_parts: Vec<&str> = original_full.split(':').collect();
let display_name = original_parts
.last()
.unwrap_or(parts.last().unwrap())
.to_string();
let has_children = sorted_keys
.binary_search(key)
.ok()
.map(|idx| {
idx + 1 < sorted_keys.len()
&& sorted_keys[idx + 1].starts_with(key)
&& sorted_keys[idx + 1].as_bytes().get(key.len()) == Some(&b':')
})
.unwrap_or(false);
let alias_key_to_check = if is_location {
format!("@@{}", key)
} else {
key.clone()
};
if options.hide_aliases_in_sidebar
&& options.tag_aliases.contains_key(&alias_key_to_check)
&& !has_children
{
continue;
}
let mut visible = true;
let mut ancestor = String::new();
for (i, part) in parts.iter().enumerate().take(depth_val) {
if i > 0 {
ancestor.push(':');
}
ancestor.push_str(part);
if !expanded_set.contains(&ancestor) && !forced_expanded.contains(&ancestor) {
visible = false;
break;
}
}
if visible {
results.push(AggregateItem {
full_key: key.clone(),
display_name,
count,
depth,
has_children,
is_expanded: expanded_set.contains(key) || forced_expanded.contains(key),
});
}
}
results
};
let mut journal_tasks = Vec::new();
for (href, map) in &self.calendars {
if options.hidden_calendars.contains(href)
|| href == crate::storage::LOCAL_TRASH_HREF
|| href == "local://recovery"
{
continue;
}
for t in map.values() {
if t.is_journal
&& Self::task_matches_categories(
t,
options.selected_categories,
options.match_all_categories,
)
&& Self::task_matches_locations(t, options.selected_locations)
{
journal_tasks.push(t);
}
}
}
let mut date_journals = Vec::new();
let mut pages = Vec::new();
let mut all_journal_uids = HashSet::new();
for t in journal_tasks {
all_journal_uids.insert(t.uid.as_str());
if t.dtstart.is_some() {
date_journals.push(t);
} else {
pages.push(t);
}
}
let mut journal_children: HashMap<&str, Vec<&Task>> = HashMap::new();
let mut root_pages = Vec::new();
for p in &pages {
if let Some(parent) = &p.parent_uid
&& all_journal_uids.contains(parent.as_str())
{
journal_children.entry(parent.as_str()).or_default().push(p);
continue;
}
root_pages.push(*p);
}
root_pages.sort_by(|a, b| a.summary.cmp(&b.summary));
for list in journal_children.values_mut() {
list.sort_by(|a, b| a.summary.cmp(&b.summary));
}
let mut timeline: std::collections::BTreeMap<
i32,
std::collections::BTreeMap<u32, Vec<&Task>>,
> = std::collections::BTreeMap::new();
for t in &date_journals {
if let Some(dt) = &t.dtstart {
let d = dt.to_date_naive();
timeline
.entry(d.year())
.or_default()
.entry(d.month())
.or_default()
.push(*t);
}
}
for months in timeline.values_mut() {
for tasks in months.values_mut() {
tasks.sort_by(|a, b| {
let da = a.dtstart.as_ref().unwrap().to_date_naive();
let db = b.dtstart.as_ref().unwrap().to_date_naive();
da.cmp(&db)
});
}
}
let mut journal_pages_out = Vec::new();
fn flatten_journal<'a>(
node: &'a Task,
children_map: &HashMap<&'a str, Vec<&'a Task>>,
depth: usize,
out: &mut Vec<JournalPageItem>,
) {
let title = if node.summary.is_empty() {
rust_i18n::t!("untitled_page", default = "Untitled page").to_string()
} else {
node.summary.clone()
};
let has_children = children_map.contains_key(node.uid.as_str());
let is_expanded = !node.collapsed;
out.push(JournalPageItem {
key: node.uid.clone(),
title,
depth,
has_children,
is_expanded,
is_task: true,
calendar_href: node.calendar_href.clone(),
});
if is_expanded && let Some(children) = children_map.get(node.uid.as_str()) {
for child in children {
flatten_journal(child, children_map, depth + 1, out);
}
}
}
if !root_pages.is_empty() {
let pages_key = "j:pages";
let pages_expanded = options.expanded_tags.contains(pages_key);
journal_pages_out.push(JournalPageItem {
key: pages_key.to_string(),
title: rust_i18n::t!("pages", default = "Pages").to_string(),
depth: 0,
has_children: true,
is_expanded: pages_expanded,
is_task: false,
calendar_href: String::new(),
});
if pages_expanded {
for p in &root_pages {
flatten_journal(p, &journal_children, 1, &mut journal_pages_out);
}
}
}
if !timeline.is_empty() {
let timeline_key = "j:timeline";
let timeline_expanded = options.expanded_tags.contains(timeline_key);
journal_pages_out.push(JournalPageItem {
key: timeline_key.to_string(),
title: rust_i18n::t!("timeline", default = "Timeline").to_string(),
depth: 0,
has_children: true,
is_expanded: timeline_expanded,
is_task: false,
calendar_href: String::new(),
});
if timeline_expanded {
for (year, months) in timeline.iter().rev() {
let year_key = format!("j:y:{}", year);
let year_expanded = options.expanded_tags.contains(&year_key);
journal_pages_out.push(JournalPageItem {
key: year_key.clone(),
title: year.to_string(),
depth: 1,
has_children: true,
is_expanded: year_expanded,
is_task: false,
calendar_href: String::new(),
});
if year_expanded {
for (month, tasks) in months.iter().rev() {
let raw_month = match month {
1 => rust_i18n::t!("parser_months_jan"),
2 => rust_i18n::t!("parser_months_feb"),
3 => rust_i18n::t!("parser_months_mar"),
4 => rust_i18n::t!("parser_months_apr"),
5 => rust_i18n::t!("parser_months_may"),
6 => rust_i18n::t!("parser_months_jun"),
7 => rust_i18n::t!("parser_months_jul"),
8 => rust_i18n::t!("parser_months_aug"),
9 => rust_i18n::t!("parser_months_sep"),
10 => rust_i18n::t!("parser_months_oct"),
11 => rust_i18n::t!("parser_months_nov"),
12 => rust_i18n::t!("parser_months_dec"),
_ => std::borrow::Cow::Borrowed(""),
};
let raw_month_str = raw_month.split(',').next().unwrap_or("").trim();
let mut chars = raw_month_str.chars();
let month_name = match chars.next() {
None => format!("{:02}", month),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
};
let month_key = format!("j:m:{}:{}", year, month);
let month_expanded = options.expanded_tags.contains(&month_key);
journal_pages_out.push(JournalPageItem {
key: month_key.clone(),
title: month_name.to_string(),
depth: 2,
has_children: true,
is_expanded: month_expanded,
is_task: false,
calendar_href: String::new(),
});
if month_expanded {
for t in tasks.iter().rev() {
flatten_journal(
t,
&journal_children,
3,
&mut journal_pages_out,
);
}
}
}
}
}
}
}
let categories = build_aggregates(
cat_active_counts,
cat_display_names,
options.expanded_tags,
options.selected_categories,
false,
);
let empty_names = HashMap::new(); let locations = build_aggregates(
loc_active_counts,
empty_names,
options.expanded_locations,
options.selected_locations,
true,
);
let mut final_categories = categories;
if uncat_any {
final_categories.insert(
0,
AggregateItem {
full_key: UNCATEGORIZED_ID.to_string(),
display_name: rust_i18n::t!("uncategorized").to_string(),
count: uncat_active_count,
depth: 0,
has_children: false,
is_expanded: false,
},
);
}
let mut final_tasks_processed: Vec<Task> = final_refs
.into_iter()
.map(|t_ref| {
let mut t = t_ref.clone();
t.is_search_context = !direct_matches.contains(t.uid.as_str());
t.transient_is_paused = t.is_paused();
t.transient_recent_ts = t
.last_modified_date()
.or_else(|| t.created_date())
.map(|d| d.timestamp())
.unwrap_or(0);
t.is_blocked = check_is_blocked_explicit(&t, &completed_uids);
t.is_implicitly_blocked = !t.is_blocked
&& eff_blocked_map
.get(t.uid.as_str())
.copied()
.unwrap_or_else(|| check_is_effectively_blocked(&t, &completed_uids));
t.effective_priority = if t.is_blocked || t.is_implicitly_blocked {
if t.priority == 0 {
9
} else {
(t.priority.saturating_add(3)).min(9)
}
} else {
t.priority
};
t.effective_due = t.due.clone();
t.effective_dtstart = t.dtstart.clone();
t
})
.collect();
let mut blocking_to_blocked: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, t) in final_tasks_processed.iter().enumerate() {
for dep in &t.dependencies {
blocking_to_blocked.entry(dep.as_str()).or_default().push(i);
}
}
let mut dep_cache: HashMap<usize, Option<DateType>> = HashMap::new();
let mut dep_visiting: HashSet<usize> = HashSet::new();
fn resolve_deps_due(
idx: usize,
tasks: &[Task],
blocking_to_blocked: &HashMap<&str, Vec<usize>>,
cache: &mut HashMap<usize, Option<DateType>>,
visiting: &mut HashSet<usize>,
) -> Option<DateType> {
if let Some(cached) = cache.get(&idx) {
return cached.clone();
}
if visiting.contains(&idx) {
return tasks[idx].effective_due.clone();
}
visiting.insert(idx);
let mut min_d = tasks[idx].effective_due.clone();
let uid = tasks[idx].uid.as_str();
if let Some(blocked_indices) = blocking_to_blocked.get(uid) {
for &b_idx in blocked_indices {
let d = resolve_deps_due(b_idx, tasks, blocking_to_blocked, cache, visiting);
min_d = match (&min_d, &d) {
(Some(da), Some(db)) => {
if da.to_comparison_time() < db.to_comparison_time() {
Some(da.clone())
} else {
Some(db.clone())
}
}
(Some(da), None) => Some(da.clone()),
(None, Some(db)) => Some(db.clone()),
(None, None) => None,
};
}
}
visiting.remove(&idx);
cache.insert(idx, min_d.clone());
min_d
}
for i in 0..final_tasks_processed.len() {
if !dep_cache.contains_key(&i) {
resolve_deps_due(
i,
&final_tasks_processed,
&blocking_to_blocked,
&mut dep_cache,
&mut dep_visiting,
);
}
}
for (i, t) in final_tasks_processed.iter_mut().enumerate() {
if let Some(min_due) = dep_cache.get(&i) {
t.effective_due = min_due.clone();
}
}
let empty_parent_tags: HashSet<String> = HashSet::new();
let empty_parent_locs: Vec<String> = Vec::new();
let mut parent_visual_cache: HashMap<String, (HashSet<String>, Vec<String>)> =
HashMap::new();
for t in final_tasks_processed.iter_mut() {
let eff_blocked = t.is_blocked || t.is_implicitly_blocked;
t.sort_rank = t.calculate_base_rank(
options.cutoff_date,
options.urgent_days,
options.urgent_prio,
options.start_grace_period_days,
eff_blocked,
options.sort_preset,
);
t.has_blocking_tasks = self.has_tasks_blocking(&t.uid);
t.has_related_tasks = self.has_tasks_related_to(&t.uid);
t.is_future_start = t
.dtstart
.as_ref()
.map(|start| start.to_start_comparison_time() > now)
.unwrap_or(false);
t.is_implicitly_future = !t.is_future_start
&& eff_future_map
.get(t.uid.as_str())
.copied()
.unwrap_or_else(|| check_is_effectively_future(t));
t.is_overdue = t
.effective_due
.as_ref()
.map(|d| !t.status.is_done() && d.to_comparison_time() < now)
.unwrap_or(false);
let now_local_date = chrono::Local::now().date_naive();
t.is_due_today = t
.effective_due
.as_ref()
.map(|d| !t.status.is_done() && d.to_date_naive() == now_local_date)
.unwrap_or(false);
t.tree_location_count = *tree_loc_counts.get(&t.uid).unwrap_or(&0);
let (p_tags, p_loc) = if let Some(p_uid) = &t.parent_uid {
let entry = parent_visual_cache.entry(p_uid.clone()).or_insert_with(|| {
self.get_task_ref(p_uid)
.map(|p| (p.categories.iter().cloned().collect(), p.locations.clone()))
.unwrap_or_default()
});
(&entry.0, &entry.1)
} else {
(&empty_parent_tags, &empty_parent_locs)
};
let (visible_tags, visible_locations) =
t.resolve_visual_attributes(p_tags, p_loc, options.tag_aliases);
t.visible_categories = visible_tags;
t.visible_locations = visible_locations;
}
let mut uid_to_index = HashMap::new();
for (i, t) in final_tasks_processed.iter().enumerate() {
uid_to_index.insert(t.uid.clone(), i);
}
let mut map: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, t) in final_tasks_processed.iter().enumerate() {
if let Some(p) = &t.parent_uid {
map.entry(p.as_str()).or_default().push(i);
}
}
type ResolvedSortTuple = (crate::model::item::SortKey, i64, String);
fn resolve(
idx: usize,
tasks: &[Task],
map: &HashMap<&str, Vec<usize>>,
cache: &mut HashMap<usize, ResolvedSortTuple>,
visiting: &mut HashSet<usize>,
options: &FilterOptions,
) -> ResolvedSortTuple {
if let Some(cached) = cache.get(&idx) {
return cached.clone();
}
let t = &tasks[idx];
let mut best_sort = t.to_sort_key();
let mut best_ts = t.transient_recent_ts;
let best_summary = t.summary.clone();
if visiting.contains(&idx) {
return (best_sort, best_ts, best_summary);
}
visiting.insert(idx);
let is_suppressed = t.status.is_done() || t.is_blocked || t.is_implicitly_blocked;
if !is_suppressed && let Some(children) = map.get(t.uid.as_str()) {
let mut best_child_opt: Option<ResolvedSortTuple> = None;
for &child_idx in children {
let child_eff = resolve(child_idx, tasks, map, cache, visiting, options);
if let Some(ref mut best_child) = best_child_opt {
let ordering = crate::model::item::compare_sortkeys(
&child_eff.0,
&best_child.0,
options.default_priority,
options.sort_standard_by_priority,
options.sort_preset,
options.paused_sort_behavior,
)
.then_with(|| {
if options.sort_tiebreak_recent {
best_child
.1
.cmp(&child_eff.1)
.then_with(|| child_eff.2.cmp(&best_child.2))
} else {
child_eff.2.cmp(&best_child.2)
}
});
if ordering == std::cmp::Ordering::Less {
*best_child = child_eff;
}
} else {
best_child_opt = Some(child_eff);
}
}
if let Some(bc) = best_child_opt {
if t.is_note || t.is_journal || t.is_search_context {
best_sort.rank = bc.0.rank;
best_sort.prio = bc.0.prio;
best_sort.due = bc.0.due.clone();
best_sort.start = bc.0.start.clone();
best_sort.is_paused = bc.0.is_paused;
best_ts = bc.1;
} else {
let ordering = crate::model::item::compare_sortkeys(
&bc.0,
&best_sort,
options.default_priority,
options.sort_standard_by_priority,
options.sort_preset,
options.paused_sort_behavior,
)
.then_with(|| {
if options.sort_tiebreak_recent {
best_ts.cmp(&bc.1).then_with(|| bc.2.cmp(&best_summary))
} else {
bc.2.cmp(&best_summary)
}
});
if ordering == std::cmp::Ordering::Less {
best_sort.rank = bc.0.rank;
best_sort.prio = bc.0.prio;
best_sort.due = bc.0.due.clone();
best_sort.start = bc.0.start.clone();
best_sort.is_paused = bc.0.is_paused;
best_ts = bc.1;
}
}
}
}
visiting.remove(&idx);
let result = (best_sort, best_ts, best_summary);
cache.insert(idx, result.clone());
result
}
let mut cache: HashMap<usize, ResolvedSortTuple> = HashMap::new();
let mut visiting: HashSet<usize> = HashSet::new();
for i in 0..final_tasks_processed.len() {
if !cache.contains_key(&i) {
let _ = resolve(
i,
&final_tasks_processed,
&map,
&mut cache,
&mut visiting,
&options,
);
}
}
for (i, t) in final_tasks_processed.iter_mut().enumerate() {
t.has_subtasks = self.children_index.contains_key(&t.uid);
if let Some(best) = cache.get(&i) {
t.sort_rank = best.0.rank;
t.effective_priority = best.0.prio;
t.effective_due = best.0.due.clone();
t.effective_dtstart = best.0.start.clone();
t.transient_is_paused = best.0.is_paused;
t.transient_recent_ts = best.1;
}
}
let organized_items = organize_hierarchy(
final_tasks_processed,
HierarchyOptions {
default_priority: options.default_priority,
sort_standard_by_priority: options.sort_standard_by_priority,
expanded_groups: options.expanded_done_groups,
max_done_roots: options.max_done_roots,
max_done_subtasks: options.max_done_subtasks,
search_active: !options.search_term.is_empty(),
sort_preset: options.sort_preset,
search_collapsed_tasks: options.search_collapsed_tasks,
focused_task_uid: options.focused_task_uid,
paused_sort_behavior: options.paused_sort_behavior,
sort_tiebreak_recent: options.sort_tiebreak_recent,
},
);
FilterResult {
items: organized_items,
categories: final_categories,
locations,
journal_pages: journal_pages_out,
}
}
pub fn get_tree_waypoints(&self, root_uid: &str) -> Vec<(String, String)> {
let mut uids = self.get_descendant_uids(root_uid);
uids.push(root_uid.to_string());
uids.iter()
.filter_map(|id| self.get_task_ref(id))
.filter_map(|t| t.geo.as_ref().map(|g| (t.summary.clone(), g.clone())))
.collect()
}
pub fn count_tree_locations(&self, root_uid: &str) -> usize {
let mut uids = self.get_descendant_uids(root_uid);
uids.push(root_uid.to_string());
uids.iter()
.filter_map(|id| self.get_task_ref(id))
.filter(|t| t.geo.is_some())
.count()
}
pub fn get_all_parent_uids(&self) -> HashSet<String> {
self.children_index.keys().cloned().collect()
}
pub fn apply_actions(&mut self, actions: &[crate::journal::Action]) {
for act in actions {
match act {
crate::journal::Action::Create(t) | crate::journal::Action::Update(t) => {
self.update_or_add_task(t.clone())
}
crate::journal::Action::Delete(t) => {
self.delete_task(&t.uid);
}
crate::journal::Action::Move(t, target) => {
self.move_task(&t.uid, target.clone());
}
}
}
}
pub fn cleanup_references(&mut self, target_uid: &str) -> Vec<crate::journal::Action> {
let mut actions = Vec::new();
let mut to_update = Vec::new();
if let Some(blocked) = self.blocking_index.get(target_uid) {
to_update.extend(blocked.iter().cloned());
}
if let Some(related) = self.related_from_index.get(target_uid) {
to_update.extend(related.iter().cloned());
}
if let Some(children) = self.children_index.get(target_uid) {
to_update.extend(children.iter().cloned());
}
to_update.sort();
to_update.dedup();
for uid in to_update {
if let Some(task_ref) = self.get_task_ref(&uid) {
let mut task = task_ref.clone();
let mut changed = false;
if let Some(pos) = task.dependencies.iter().position(|d| d == target_uid) {
task.dependencies.remove(pos);
changed = true;
}
if let Some(pos) = task.related_to.iter().position(|r| r == target_uid) {
task.related_to.remove(pos);
changed = true;
}
if task.parent_uid.as_deref() == Some(target_uid) {
task.parent_uid = None;
changed = true;
}
if changed {
task.sequence += 1;
actions.push(crate::journal::Action::Update(task.clone()));
self.update_or_add_task(task);
}
}
}
actions
}
pub fn apply_task_intent(
&mut self,
intent: &AppIntent,
config: &Config,
) -> (
Vec<JournalAction>,
Vec<JournalAction>,
String,
Option<String>,
) {
let mut primary_uid = None;
let mut potential_uids = Vec::new();
match intent {
AppIntent::ToggleTask { uid }
| AppIntent::ToggleTaskShift { uid }
| AppIntent::CancelTask { uid }
| AppIntent::StartTask { uid }
| AppIntent::PauseTask { uid }
| AppIntent::StopTask { uid }
| AppIntent::ChangePriority { uid, .. }
| AppIntent::TogglePin { uid }
| AppIntent::ToggleTreeCollapse { uid }
| AppIntent::SetTreeCollapse { uid, .. } => {
primary_uid = Some(uid.clone());
potential_uids.push(uid.clone());
potential_uids.extend(self.get_descendant_uids(uid));
}
AppIntent::DeleteTask { uid }
| AppIntent::MoveTask { uid, .. }
| AppIntent::RemoveParent { uid } => {
primary_uid = Some(uid.clone());
potential_uids.push(uid.clone());
}
AppIntent::DeleteTaskTree { uid }
| AppIntent::MoveTaskTree { uid, .. }
| AppIntent::DuplicateTaskTree { uid }
| AppIntent::CompleteTree { uid } => {
primary_uid = Some(uid.clone());
potential_uids.push(uid.clone());
potential_uids.extend(self.get_descendant_uids(uid));
}
AppIntent::MakeChild { uid, parent_uid } => {
primary_uid = Some(uid.clone());
potential_uids.push(uid.clone());
potential_uids.push(parent_uid.clone());
}
AppIntent::AddDependency { uid, blocker_uid }
| AppIntent::RemoveDependency { uid, blocker_uid } => {
primary_uid = Some(uid.clone());
potential_uids.push(uid.clone());
potential_uids.push(blocker_uid.clone());
}
AppIntent::AddRelatedTo { uid, related_uid }
| AppIntent::RemoveRelatedTo { uid, related_uid } => {
primary_uid = Some(uid.clone());
potential_uids.push(uid.clone());
potential_uids.push(related_uid.clone());
}
_ => {}
}
let mut old_states = HashMap::new();
for u in &potential_uids {
if let Some(t) = self.get_task_ref(u) {
old_states.insert(u.clone(), t.clone());
}
}
let summary_text = primary_uid
.as_ref()
.and_then(|u| self.get_summary(u))
.unwrap_or_default();
let mut actions = Vec::new();
match intent {
AppIntent::CompleteTree { uid } => {
let mut uids_to_complete = self.get_descendant_uids(uid);
uids_to_complete.push(uid.clone());
let target_status = if let Some(t) = self.get_task_ref(uid) {
if t.status.is_done() {
crate::model::TaskStatus::NeedsAction
} else {
crate::model::TaskStatus::Completed
}
} else {
crate::model::TaskStatus::Completed
};
for u in uids_to_complete {
if let Some(t) = self.get_task_ref(&u) {
if (t.is_note || t.is_journal) && target_status.is_done() {
continue;
}
}
if let Some((primary, secondary, children)) =
self.set_status(&u, target_status, false)
{
if let Some(sec) = secondary {
actions.push(JournalAction::Create(primary));
actions.push(JournalAction::Update(sec));
} else {
actions.push(JournalAction::Update(primary));
}
for c in children {
actions.push(JournalAction::Update(c));
}
}
}
}
AppIntent::ToggleTask { uid } => {
if let Some((primary, secondary, children)) = self.toggle_task(uid) {
if let Some(sec) = secondary {
actions.push(JournalAction::Create(primary));
actions.push(JournalAction::Update(sec));
} else {
actions.push(JournalAction::Update(primary));
}
for c in children {
actions.push(JournalAction::Update(c));
}
}
}
AppIntent::ToggleTaskShift { uid } => {
if let Some((primary, secondary, children)) = self.toggle_task_shift(uid) {
if let Some(sec) = secondary {
actions.push(JournalAction::Create(primary));
actions.push(JournalAction::Update(sec));
} else {
actions.push(JournalAction::Update(primary));
}
for c in children {
actions.push(JournalAction::Update(c));
}
}
}
AppIntent::DeleteTask { uid } => {
let cleanup_actions = self.cleanup_references(uid);
actions.extend(cleanup_actions);
if let Some((deleted, trashed_opt)) =
self.soft_delete_task(uid, config.trash_retention_days)
{
actions.push(JournalAction::Delete(deleted));
if let Some(trashed) = trashed_opt {
actions.push(JournalAction::Create(trashed));
}
}
}
AppIntent::DeleteTaskTree { uid } => {
let uids = self.get_descendant_uids(uid);
let mut all_uids = uids;
all_uids.push(uid.clone());
let pairs = self.soft_delete_task_tree(uid, config.trash_retention_days);
for (deleted, trashed_opt) in pairs {
actions.push(JournalAction::Delete(deleted));
if let Some(trashed) = trashed_opt {
actions.push(JournalAction::Create(trashed));
}
}
for u in &all_uids {
actions.extend(self.cleanup_references(u));
}
}
AppIntent::CancelTask { uid } => {
if let Some((primary, secondary, children)) =
self.set_status(uid, crate::model::TaskStatus::Cancelled, false)
{
if let Some(sec) = secondary {
actions.push(JournalAction::Create(primary));
actions.push(JournalAction::Update(sec));
} else {
actions.push(JournalAction::Update(primary));
}
for c in children {
actions.push(JournalAction::Update(c));
}
}
}
AppIntent::ChangePriority { uid, delta } => {
if let Some(updated) = self.change_priority(uid, *delta, config.default_priority) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::StartTask { uid } => {
let updated = self.set_status_in_process(uid);
actions.extend(updated.into_iter().map(JournalAction::Update));
}
AppIntent::PauseTask { uid } => {
let updated = self.pause_task(uid);
actions.extend(updated.into_iter().map(JournalAction::Update));
}
AppIntent::StopTask { uid } => {
let updated = self.stop_task(uid);
actions.extend(updated.into_iter().map(JournalAction::Update));
}
AppIntent::MoveTask { uid, target_href } => {
let safe_target = if target_href == crate::storage::LOCAL_TRASH_HREF
|| target_href == "local://recovery"
{
crate::storage::LOCAL_CALENDAR_HREF.to_string()
} else {
target_href.clone()
};
if let Some((orig, updated)) = self.move_task(uid, safe_target.clone()) {
if !orig.calendar_href.starts_with("local://")
&& safe_target.starts_with("local://")
{
actions.push(JournalAction::Delete(orig));
actions.push(JournalAction::Create(updated));
} else if orig.calendar_href.starts_with("local://")
&& !safe_target.starts_with("local://")
{
actions.push(JournalAction::Delete(orig));
let mut moved = updated.clone();
moved.href = String::new();
moved.etag = String::new();
actions.push(JournalAction::Create(moved));
} else {
actions.push(JournalAction::Move(orig, safe_target));
}
}
}
AppIntent::MoveTaskTree { uid, target_href } => {
let safe_target = if target_href == crate::storage::LOCAL_TRASH_HREF
|| target_href == "local://recovery"
{
crate::storage::LOCAL_CALENDAR_HREF.to_string()
} else {
target_href.clone()
};
let mut uids = self.get_descendant_uids(uid);
uids.push(uid.clone());
for u in uids {
if let Some((orig, updated)) = self.move_task(&u, safe_target.clone()) {
if !orig.calendar_href.starts_with("local://")
&& safe_target.starts_with("local://")
{
actions.push(JournalAction::Delete(orig));
actions.push(JournalAction::Create(updated));
} else if orig.calendar_href.starts_with("local://")
&& !safe_target.starts_with("local://")
{
actions.push(JournalAction::Delete(orig));
let mut moved = updated.clone();
moved.href = String::new();
moved.etag = String::new();
actions.push(JournalAction::Create(moved));
} else {
actions.push(JournalAction::Move(orig, safe_target.clone()));
}
}
}
}
AppIntent::DuplicateTaskTree { uid } => {
let new_tasks = self.duplicate_task_tree(uid);
actions.extend(new_tasks.into_iter().map(JournalAction::Create));
}
AppIntent::ReplaceDependency {
uid,
old_dep,
new_dep,
} => {
if let Some(updated) = self.replace_dependency(uid, old_dep, new_dep.clone()) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::ReplaceRelation {
uid,
old_rel,
new_rel,
} => {
if let Some(updated) = self.replace_relation(uid, old_rel, new_rel.clone()) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::RemoveParent { uid } => {
let new_parent = self
.get_task_ref(uid)
.and_then(|t| t.parent_uid.as_ref())
.and_then(|p_uid| self.get_task_ref(p_uid))
.and_then(|p| p.parent_uid.clone());
if let Ok(updated) = self.set_parent(uid, new_parent) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::MakeChild { uid, parent_uid } => {
if let Ok(updated) = self.set_parent(uid, Some(parent_uid.clone())) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::AddDependency { uid, blocker_uid } => {
if let Some(updated) = self.add_dependency(uid, blocker_uid.clone()) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::RemoveDependency { uid, blocker_uid } => {
if let Some(updated) = self.remove_dependency(uid, blocker_uid) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::AddRelatedTo { uid, related_uid } => {
if let Some(updated) = self.add_related_to(uid, related_uid.clone()) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::RemoveRelatedTo { uid, related_uid } => {
if let Some(updated) = self.remove_related_to(uid, related_uid) {
actions.push(JournalAction::Update(updated));
}
}
AppIntent::TogglePin { uid } => {
if let Some((task, _)) = self.get_task_mut(uid) {
task.pinned = !task.pinned;
task.sequence += 1;
let updated = task.clone();
actions.push(JournalAction::Update(updated));
}
}
AppIntent::ToggleTreeCollapse { uid } => {
let is_parent = self.children_index.contains_key(uid);
if let Some((task, _)) = self.get_task_mut(uid)
&& (is_parent || task.collapsed)
{
task.collapsed = !task.collapsed;
task.sequence += 1;
let updated = task.clone();
actions.push(JournalAction::Update(updated));
}
}
AppIntent::SetTreeCollapse { uid, collapsed } => {
let is_parent = self.children_index.contains_key(uid);
if let Some((task, _)) = self.get_task_mut(uid)
&& (is_parent || task.collapsed)
&& task.collapsed != *collapsed
{
task.collapsed = *collapsed;
task.sequence += 1;
let updated = task.clone();
actions.push(JournalAction::Update(updated));
}
}
_ => {} }
let mut reverse_actions = Vec::new();
for act in &actions {
match act {
JournalAction::Create(t) => reverse_actions.push(JournalAction::Delete(t.clone())),
JournalAction::Update(t) => {
if let Some(old) = old_states.get(&t.uid) {
reverse_actions.push(JournalAction::Update(old.clone()));
} else {
reverse_actions.push(JournalAction::Delete(t.clone()));
}
}
JournalAction::Delete(t) => {
if let Some(old) = old_states.get(&t.uid) {
reverse_actions.push(JournalAction::Create(old.clone()));
} else {
reverse_actions.push(JournalAction::Create(t.clone()));
}
}
JournalAction::Move(t, _target) => {
if let Some(old) = old_states.get(&t.uid) {
reverse_actions
.push(JournalAction::Move(t.clone(), old.calendar_href.clone()));
}
}
}
}
reverse_actions.reverse();
let desc = match intent {
AppIntent::ToggleTask { uid } | AppIntent::ToggleTaskShift { uid } => {
if let Some(old) = old_states.get(uid) {
if old.status.is_done() {
"Marked as pending"
} else {
"Marked as completed"
}
} else {
"Status change"
}
}
AppIntent::CompleteTree { .. } => "Completed tree",
AppIntent::CancelTask { .. } => "Cancelled",
AppIntent::DeleteTask { .. } => "Deleted task",
AppIntent::DeleteTaskTree { .. } => "Deleted tree",
AppIntent::MoveTask { .. } => "Moved task",
AppIntent::MoveTaskTree { .. } => "Moved tree",
AppIntent::ChangePriority { delta, .. } => {
if *delta > 0 {
"Increased priority"
} else {
"Decreased priority"
}
}
AppIntent::StartTask { .. } => "Started timer",
AppIntent::PauseTask { .. } => "Paused timer",
AppIntent::StopTask { .. } => "Stopped timer",
AppIntent::MakeChild { .. } => "Indented",
AppIntent::RemoveParent { .. } => "Outdented",
AppIntent::AddDependency { .. } => "Added dependency",
AppIntent::RemoveDependency { .. } => "Removed dependency",
AppIntent::AddRelatedTo { .. } => "Added relation",
AppIntent::RemoveRelatedTo { .. } => "Removed relation",
AppIntent::TogglePin { uid } => {
if let Some(old) = old_states.get(uid) {
if old.pinned { "Unpinned" } else { "Pinned" }
} else {
"Toggled pin"
}
}
AppIntent::ToggleTreeCollapse { uid } | AppIntent::SetTreeCollapse { uid, .. } => {
if let Some(old) = old_states.get(uid) {
if old.collapsed {
"Expanded tree"
} else {
"Collapsed tree"
}
} else {
"Toggled tree"
}
}
AppIntent::DuplicateTaskTree { .. } => "Duplicated",
AppIntent::ReplaceDependency { .. } | AppIntent::ReplaceRelation { .. } => {
"Fixed relationship"
}
_ => "Action",
}
.to_string();
let full_desc = if summary_text.is_empty() {
desc
} else {
let trunc = if summary_text.chars().count() > 30 {
format!("{}...", summary_text.chars().take(27).collect::<String>())
} else {
summary_text
};
format!("{}: {}", desc, trunc)
};
(actions, reverse_actions, full_desc, primary_uid)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Task, TaskStatus};
fn make_task(uid: &str, parent_uid: Option<&str>, status: TaskStatus, collapsed: bool) -> Task {
Task {
uid: uid.to_string(),
summary: uid.to_string(),
description: String::new(),
status,
estimated_duration: None,
estimated_duration_max: None,
due: None,
dtstart: None,
alarms: vec![],
exdates: vec![],
priority: 5,
percent_complete: None,
parent_uid: parent_uid.map(|s| s.to_string()),
dependencies: vec![],
related_to: vec![],
etag: String::new(),
href: String::new(),
calendar_href: "local://default".to_string(),
categories: vec![],
depth: 0,
rrule: None,
locations: vec![],
url: None,
geo: None,
collapsed,
pinned: false,
is_note: false,
manual_block: false,
permanent: false,
is_journal: false,
time_spent_seconds: 0,
last_started_at: None,
sessions: vec![],
unmapped_properties: vec![],
sequence: 0,
raw_alarms: vec![],
raw_components: vec![],
create_event: None,
goal: None,
target_collection: None,
is_blocked: false,
is_implicitly_blocked: false,
is_implicitly_future: false,
has_subtasks: false,
has_visible_subtasks: false,
sort_rank: 0,
effective_priority: 5,
effective_due: None,
effective_dtstart: None,
visible_categories: vec![],
visible_locations: vec![],
has_blocking_tasks: false,
has_related_tasks: false,
is_future_start: false,
is_overdue: false,
is_due_today: false,
tree_location_count: 0,
is_search_context: false,
transient_is_paused: false,
transient_recent_ts: 0,
transient_desc_tags: Vec::new(),
transient_desc_locs: Vec::new(),
cached_has_subtasks: None,
}
}
#[test]
fn test_collapsed_tree_children_not_orphaned() {
let parent = make_task("parent", None, TaskStatus::NeedsAction, true);
let child1 = make_task("child1", Some("parent"), TaskStatus::NeedsAction, false);
let child2 = make_task("child2", Some("parent"), TaskStatus::NeedsAction, false);
let tasks = vec![parent, child1, child2];
let expanded_groups = HashSet::new();
let result = organize_hierarchy(
tasks,
HierarchyOptions {
default_priority: 5,
sort_standard_by_priority: false,
expanded_groups: &expanded_groups,
max_done_roots: 10,
max_done_subtasks: 10,
search_active: false,
sort_preset: crate::config::SortPreset::UrgentStartedDue,
search_collapsed_tasks: &HashSet::new(),
focused_task_uid: None,
paused_sort_behavior: crate::config::PausedSortBehavior::default(),
sort_tiebreak_recent: false,
},
);
let result_uids: Vec<String> = result
.into_iter()
.filter_map(|item| match item {
TaskListItem::Task(t) => Some(t.uid),
_ => None,
})
.collect();
assert!(
result_uids.contains(&"parent".to_string()),
"Parent should be present"
);
assert!(
!result_uids.contains(&"child1".to_string()),
"Child1 should not appear when parent is collapsed"
);
assert!(
!result_uids.contains(&"child2".to_string()),
"Child2 should not appear when parent is collapsed"
);
}
#[test]
fn test_collapsed_tree_with_done_children_not_orphaned() {
let parent = make_task("parent", None, TaskStatus::NeedsAction, true);
let child1 = make_task("child1", Some("parent"), TaskStatus::Completed, false);
let child2 = make_task("child2", Some("parent"), TaskStatus::Completed, false);
let tasks = vec![parent, child1, child2];
let expanded_groups = HashSet::new();
let result = organize_hierarchy(
tasks,
HierarchyOptions {
default_priority: 5,
sort_standard_by_priority: false,
expanded_groups: &expanded_groups,
max_done_roots: 10,
max_done_subtasks: 1, search_active: false,
sort_preset: crate::config::SortPreset::UrgentStartedDue,
search_collapsed_tasks: &HashSet::new(),
focused_task_uid: None,
paused_sort_behavior: crate::config::PausedSortBehavior::default(),
sort_tiebreak_recent: false,
},
);
let result_uids: Vec<String> = result
.into_iter()
.filter_map(|item| match item {
TaskListItem::Task(t) => Some(t.uid),
_ => None,
})
.collect();
assert!(
result_uids.contains(&"parent".to_string()),
"Parent should be present"
);
let child_count = result_uids
.iter()
.filter(|u| u.starts_with("child"))
.count();
assert!(
child_count <= 1,
"At most 1 child should appear due to truncation"
);
}
#[test]
fn test_truncated_done_tasks_not_orphaned() {
let tasks: Vec<Task> = (0..5)
.map(|i| make_task(&format!("done{}", i), None, TaskStatus::Completed, false))
.collect();
let expanded_groups = HashSet::new();
let result = organize_hierarchy(
tasks,
HierarchyOptions {
default_priority: 5,
sort_standard_by_priority: false,
expanded_groups: &expanded_groups,
max_done_roots: 3, max_done_subtasks: 10,
search_active: false,
sort_preset: crate::config::SortPreset::UrgentStartedDue,
search_collapsed_tasks: &HashSet::new(),
focused_task_uid: None,
paused_sort_behavior: crate::config::PausedSortBehavior::default(),
sort_tiebreak_recent: false,
},
);
let result_uids: Vec<String> = result
.clone()
.into_iter()
.filter_map(|item| match item {
TaskListItem::Task(t) => Some(t.uid),
_ => None,
})
.collect();
assert_eq!(
result_uids.len(),
2,
"Only 2 done tasks should appear at root level (3-1=2)"
);
let has_expand = result
.iter()
.any(|item| matches!(item, TaskListItem::ExpandGroup(_, _)));
assert!(
has_expand,
"Should have an ExpandGroup item for truncated tasks"
);
}
}