use std::collections::HashMap;
use uuid::Uuid;
#[derive(Debug)]
pub struct ExtractedTask {
pub uid: String,
pub parsed_existing_uid: Option<String>, pub parent_uid: Option<String>,
pub dependencies: Vec<String>,
pub raw_text: String,
pub description: String,
pub status: crate::model::TaskStatus,
pub percent_complete: Option<u8>,
pub is_note: bool,
}
fn parse_checkbox(s: &str) -> Option<(crate::model::TaskStatus, Option<u8>, &str)> {
if s.len() < 4 || !s.starts_with('[') {
return None;
}
let mut chars = s.chars();
chars.next(); let inner = chars.next()?;
if chars.next()? != ']' || chars.next()? != ' ' {
return None;
}
let rest = chars.as_str();
match inner {
' ' => Some((crate::model::TaskStatus::NeedsAction, None, rest)),
'x' | 'X' | '*' => Some((crate::model::TaskStatus::Completed, Some(100), rest)),
'/' => Some((crate::model::TaskStatus::NeedsAction, Some(50), rest)),
'>' | 'â–¶' => Some((crate::model::TaskStatus::InProcess, None, rest)),
'<' => Some((crate::model::TaskStatus::NeedsAction, Some(50), rest)),
'-' | '~' => Some((crate::model::TaskStatus::Cancelled, None, rest)),
_ => None,
}
}
fn extract_uid_tag(line: &str) -> (String, Option<String>) {
if let Some(idx) = line.rfind("<!-- uid:")
&& let Some(end_idx) = line[idx..].find("-->")
{
let uid = line[idx + 9..idx + end_idx].trim().to_string();
let clean_line = line[..idx].trim().to_string();
return (clean_line, Some(uid));
}
(line.trim_end().to_string(), None)
}
fn compute_task_lines(input: &str, is_journal: bool) -> Vec<bool> {
let lines_vec: Vec<&str> = input.lines().collect();
let mut is_task_line = vec![false; lines_vec.len()];
let mut indents = vec![0; lines_vec.len()];
let mut is_list = vec![false; lines_vec.len()];
for (i, line) in lines_vec.iter().enumerate() {
let mut indent = 0;
let mut byte_offset = 0;
for c in line.chars() {
if c == ' ' {
indent += 1;
byte_offset += c.len_utf8();
} else if c == '\t' {
indent += 4;
byte_offset += c.len_utf8();
} else {
break;
}
}
indents[i] = indent;
let rest = &line[byte_offset..];
let mut list_marker = false;
let mut after_marker = rest;
let mut is_header = false;
if !is_journal
&& rest.starts_with('#')
&& rest
.find(' ')
.is_some_and(|idx| idx <= 6 && rest[..idx].chars().all(|c| c == '#'))
{
is_header = true;
let depth = rest.find(' ').unwrap();
after_marker = &rest[depth + 1..];
} else if rest.starts_with("- ") || rest.starts_with("* ") || rest.starts_with("+ ") {
list_marker = true;
after_marker = &rest[2..];
} else {
let mut digit_bytes = 0;
for c in rest.chars() {
if c.is_ascii_digit() {
digit_bytes += c.len_utf8();
} else {
break;
}
}
if digit_bytes > 0 && rest[digit_bytes..].starts_with(". ") {
list_marker = true;
after_marker = &rest[digit_bytes + 2..];
}
}
if is_header {
is_task_line[i] = true;
} else {
is_list[i] = list_marker;
if list_marker {
let has_checkbox = parse_checkbox(after_marker).is_some();
let has_uid = after_marker.contains("<!-- uid:");
let has_is_note = after_marker.contains("is:note")
|| after_marker.contains("is:page")
|| after_marker.contains("is:journal");
let has_wiki_link = after_marker.trim().starts_with("[[");
if has_checkbox
|| has_uid
|| has_is_note
|| has_wiki_link
|| (!is_journal && !has_checkbox)
{
is_task_line[i] = true;
}
}
}
}
for i in (0..lines_vec.len()).rev() {
if is_list[i] && !is_task_line[i] {
let curr_indent = indents[i];
for j in (i + 1)..lines_vec.len() {
if !lines_vec[j].trim().is_empty() {
if indents[j] <= curr_indent {
break; }
if is_task_line[j] {
is_task_line[i] = true;
break;
}
}
}
}
}
is_task_line
}
pub fn extract_list_prefix(line: &str) -> String {
let mut prefix = String::new();
let mut byte_offset = 0;
let chars = line.chars();
for c in chars {
if c == ' ' || c == '\t' {
prefix.push(c);
byte_offset += c.len_utf8();
} else {
break;
}
}
let rest = &line[byte_offset..];
if rest.starts_with("- [ ] ")
|| rest.starts_with("- [x] ")
|| rest.starts_with("- [X] ")
|| rest.starts_with("- [/] ")
|| rest.starts_with("- [-] ")
|| rest.starts_with("- [<] ")
|| rest.starts_with("- [>] ")
{
prefix.push_str("- [ ] ");
} else if rest.starts_with("* [ ] ")
|| rest.starts_with("* [x] ")
|| rest.starts_with("* [X] ")
|| rest.starts_with("* [/] ")
|| rest.starts_with("* [-] ")
|| rest.starts_with("* [<] ")
|| rest.starts_with("* [>] ")
{
prefix.push_str("* [ ] ");
} else if rest.starts_with("- ") {
prefix.push_str("- ");
} else if rest.starts_with("* ") {
prefix.push_str("* ");
} else {
let mut digit_bytes = 0;
for c in rest.chars() {
if c.is_ascii_digit() {
digit_bytes += c.len_utf8();
} else {
break;
}
}
if digit_bytes > 0 {
let after = &rest[digit_bytes..];
if after.starts_with(". [ ] ")
|| after.starts_with(". [x] ")
|| after.starts_with(". [X] ")
|| after.starts_with(". [/] ")
|| after.starts_with(". [-] ")
|| after.starts_with(". [<] ")
|| after.starts_with(". [>] ")
{
let num_str = &rest[..digit_bytes];
let num: usize = num_str.parse().unwrap_or(1);
prefix.push_str(&format!("{}. [ ] ", num + 1));
} else if after.starts_with(". ") {
let num_str = &rest[..digit_bytes];
let num: usize = num_str.parse().unwrap_or(1);
prefix.push_str(&format!("{}. ", num + 1));
}
}
}
prefix
}
pub fn has_extractable_subtasks(input: &str, is_journal: bool) -> bool {
let is_task = compute_task_lines(input, is_journal);
is_task.into_iter().any(|b| b)
}
#[derive(PartialEq, Clone, Copy, Debug)]
enum StackItemKind {
Heading(usize), List(usize), }
pub fn extract_markdown_tasks(input: &str, is_journal: bool) -> (String, Vec<ExtractedTask>) {
let mut cleaned_root_desc = String::new();
let mut extracted: Vec<ExtractedTask> = Vec::new();
let lines_vec: Vec<&str> = input.lines().collect();
let is_task_line = compute_task_lines(input, is_journal);
let mut indent_stack: Vec<(StackItemKind, String, usize)> = Vec::new();
let mut item_kind_at_indent: HashMap<usize, usize> = HashMap::new(); let mut next_block_id = 0;
let mut numbered_tasks: Vec<(usize, usize, usize)> = Vec::new();
let mut active_task_idx: Option<usize> = None;
for (line_idx, line) in lines_vec.into_iter().enumerate() {
let mut indent = 0;
let mut byte_offset = 0;
for c in line.chars() {
if c == ' ' {
indent += 1;
byte_offset += c.len_utf8();
} else if c == '\t' {
indent += 4;
byte_offset += c.len_utf8();
} else {
break;
}
}
let rest = &line[byte_offset..];
if rest.is_empty() {
if let Some(idx) = active_task_idx {
extracted[idx].description.push('\n');
} else {
cleaned_root_desc.push('\n');
}
continue;
}
item_kind_at_indent.retain(|&k, _| k <= indent);
if is_task_line[line_idx] {
let mut is_numbered = false;
let mut parsed_num = 0;
let mut parsed_status = crate::model::TaskStatus::NeedsAction;
let mut parsed_pc = None;
let mut is_note = true;
let mut raw_text = rest;
let mut is_header = false;
let mut header_depth = 0;
if !is_journal {
for depth in (1..=6).rev() {
let prefix = format!("{} ", "#".repeat(depth));
if let Some(stripped) = rest.strip_prefix(&prefix) {
is_header = true;
header_depth = depth;
raw_text = stripped;
break;
}
}
}
if is_header {
if let Some((status, pc, r)) = parse_checkbox(raw_text) {
is_note = false;
parsed_status = status;
parsed_pc = pc;
raw_text = r;
}
} else if rest.starts_with("- ") || rest.starts_with("* ") || rest.starts_with("+ ") {
let after_marker = &rest[2..];
if let Some((status, pc, r)) = parse_checkbox(after_marker) {
is_note = false;
parsed_status = status;
parsed_pc = pc;
raw_text = r;
} else {
raw_text = after_marker;
}
} else {
let mut digit_bytes = 0;
for c in rest.chars() {
if c.is_ascii_digit() {
digit_bytes += c.len_utf8();
} else {
break;
}
}
if digit_bytes > 0 && rest[digit_bytes..].starts_with(". ") {
let after_marker = &rest[digit_bytes + 2..];
if let Some((status, pc, r)) = parse_checkbox(after_marker) {
is_numbered = true;
is_note = false;
parsed_num = rest[..digit_bytes].parse::<usize>().unwrap_or(1);
parsed_status = status;
parsed_pc = pc;
raw_text = r;
} else {
is_numbered = true;
parsed_num = rest[..digit_bytes].parse::<usize>().unwrap_or(1);
raw_text = after_marker;
}
}
}
let (clean_text, parsed_uid) = extract_uid_tag(raw_text);
let uid = parsed_uid
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string());
let current_kind = if is_header {
StackItemKind::Heading(header_depth)
} else {
StackItemKind::List(indent)
};
while let Some(&(kind, _, _)) = indent_stack.last() {
match current_kind {
StackItemKind::Heading(curr_lvl) => match kind {
StackItemKind::Heading(stack_lvl) => {
if stack_lvl >= curr_lvl {
indent_stack.pop();
} else {
break;
}
}
StackItemKind::List(_) => {
indent_stack.pop();
}
},
StackItemKind::List(curr_indent) => match kind {
StackItemKind::Heading(_) => {
break;
}
StackItemKind::List(stack_indent) => {
if stack_indent >= curr_indent {
indent_stack.pop();
} else {
break;
}
}
},
}
}
let parent_uid = indent_stack.last().map(|(_, id, _)| id.clone());
let new_idx = extracted.len();
if is_numbered {
let block_id = match item_kind_at_indent.get(&indent) {
Some(&b) => b,
_ => {
let b = next_block_id;
next_block_id += 1;
b
}
};
item_kind_at_indent.insert(indent, block_id);
numbered_tasks.push((block_id, parsed_num, new_idx));
} else {
item_kind_at_indent.remove(&indent);
}
indent_stack.push((current_kind, uid.clone(), new_idx));
extracted.push(ExtractedTask {
uid,
parsed_existing_uid: parsed_uid,
parent_uid,
dependencies: Vec::new(),
raw_text: clean_text,
description: String::new(),
status: parsed_status,
percent_complete: parsed_pc,
is_note,
});
active_task_idx = Some(new_idx);
} else {
item_kind_at_indent.remove(&indent);
while let Some(&(stack_indent, _, _)) = indent_stack.last() {
if let StackItemKind::List(list_indent) = stack_indent {
if list_indent >= indent {
indent_stack.pop();
} else {
break;
}
} else {
break; }
}
let target_idx = indent_stack.last().map(|&(_, _, idx)| idx);
let strip_amount = if let Some(&(kind, _, _)) = indent_stack.last() {
match kind {
StackItemKind::Heading(_) => 0,
StackItemKind::List(list_indent) => list_indent + 2,
}
} else {
0
};
let mut bytes_to_strip = 0;
let mut spaces_seen = 0;
for c in line.chars() {
if spaces_seen >= strip_amount {
break;
}
if c == ' ' {
spaces_seen += 1;
bytes_to_strip += c.len_utf8();
} else if c == '\t' {
spaces_seen += 4;
bytes_to_strip += c.len_utf8();
} else {
break;
}
}
let line_content = &line[bytes_to_strip..];
if let Some(idx) = target_idx {
if !extracted[idx].description.is_empty()
&& !extracted[idx].description.ends_with('\n')
{
extracted[idx].description.push('\n');
}
extracted[idx].description.push_str(line_content);
extracted[idx].description.push('\n');
active_task_idx = Some(idx);
} else {
if !cleaned_root_desc.is_empty() && !cleaned_root_desc.ends_with('\n') {
cleaned_root_desc.push('\n');
}
cleaned_root_desc.push_str(line_content);
cleaned_root_desc.push('\n');
active_task_idx = None;
}
}
}
let mut blocks: HashMap<usize, Vec<(usize, usize)>> = HashMap::new();
for (b_id, p_num, e_idx) in numbered_tasks {
blocks.entry(b_id).or_default().push((p_num, e_idx));
}
for (_, list) in blocks {
let mut uids_by_num: HashMap<usize, Vec<String>> = HashMap::new();
for &(num, e_idx) in &list {
uids_by_num
.entry(num)
.or_default()
.push(extracted[e_idx].uid.clone());
}
let mut unique_nums: Vec<usize> = uids_by_num.keys().copied().collect();
unique_nums.sort_unstable();
for (num, e_idx) in list {
let prev_num = unique_nums.iter().rev().find(|&&n| n < num).copied();
if let Some(p_num) = prev_num
&& let Some(deps) = uids_by_num.get(&p_num)
{
extracted[e_idx].dependencies.extend(deps.iter().cloned());
}
}
}
let cleaned_root_desc = cleaned_root_desc.trim_end().to_string();
for task in &mut extracted {
task.description = task.description.trim_end().to_string();
}
(cleaned_root_desc, extracted)
}
pub fn serialize_task_tree(
store: &crate::store::TaskStore,
root_uid: &str,
calendars: &[crate::model::CalendarListEntry],
is_journal: bool,
) -> String {
let mut out = String::new();
let root = if let Some(r) = store.get_task_ref(root_uid) {
r
} else {
return out;
};
let mut children_map: std::collections::HashMap<String, Vec<&crate::model::Task>> =
std::collections::HashMap::new();
for map in store.calendars.values() {
for t in map.values() {
if let Some(p) = &t.parent_uid {
if (t.calendar_href == crate::storage::LOCAL_TRASH_HREF
|| t.calendar_href == "local://recovery")
&& t.calendar_href != root.calendar_href
{
continue;
}
children_map.entry(p.clone()).or_default().push(t);
}
}
}
for list in children_map.values_mut() {
list.sort_by_cached_key(|t| (t.created_date(), t.summary.clone(), t.uid.clone()));
if list.len() <= 1 {
continue;
}
let mut uids_in_list = std::collections::HashSet::new();
for t in list.iter() {
uids_in_list.insert(t.uid.as_str());
}
let mut needs_sort = false;
for t in list.iter() {
for dep in &t.dependencies {
if uids_in_list.contains(dep.as_str()) {
needs_sort = true;
break;
}
}
if needs_sort {
break;
}
}
if !needs_sort {
continue;
}
let n = list.len();
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 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);
}
}
}
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<&crate::model::Task>> = old_list.drain(..).map(Some).collect();
for &idx in &result {
list.push(opt_list[idx].take().unwrap());
}
}
struct SerializeContext<'a> {
children_map: &'a std::collections::HashMap<String, Vec<&'a crate::model::Task>>,
store: &'a crate::store::TaskStore,
calendars: &'a [crate::model::CalendarListEntry],
}
fn serialize_node(
ctx: &SerializeContext,
task: &crate::model::Task,
depth: usize,
out: &mut String,
prefix: &str,
parent_href: &str,
) {
let status_str = if task.is_note || task.is_journal {
String::new()
} else {
format!(
"{} ",
match task.status {
crate::model::TaskStatus::NeedsAction => {
if task.is_paused() { "[/]" } else { "[ ]" }
}
crate::model::TaskStatus::InProcess => "[>]",
crate::model::TaskStatus::Completed => "[x]",
crate::model::TaskStatus::Cancelled => "[-]",
}
)
};
let mut smart_string = task.to_smart_string();
if task.is_note {
if smart_string.starts_with("- ") || smart_string.starts_with("* ") {
smart_string = smart_string[2..].trim_start().to_string();
} else if smart_string == "-" || smart_string == "*" {
smart_string = String::new();
}
}
if task.calendar_href != parent_href {
let cal_name = ctx
.calendars
.iter()
.find(|c| c.href == task.calendar_href)
.map(|c| c.name.as_str())
.unwrap_or(task.calendar_href.as_str());
smart_string.push_str(&format!(
" col:{}",
crate::model::parser::quote_value(cal_name)
));
}
let uid_tag = format!("<!-- uid:{} -->", task.uid);
let indent = " ".repeat(depth);
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) = ctx.store.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(&task.dependencies, "dep", &mut dep_str);
process_relations(&task.related_to, "rel", &mut dep_str);
out.push_str(&format!(
"{}{}{}{}{}{} {}\n",
indent,
prefix,
if prefix.ends_with(' ') { "" } else { " " },
status_str,
smart_string,
dep_str,
uid_tag
));
if !task.description.is_empty() {
for line in task.description.lines() {
out.push_str(&format!("{} {}\n", indent, line));
}
}
if let Some(children) = ctx.children_map.get(&task.uid) {
let mut prefixes = Vec::new();
let mut current_number = 1;
let mut uses_number_prev = false;
for i in 0..children.len() {
let child = children[i];
let mut uses_number = false;
if i > 0 {
let prev_child = children[i - 1];
if child.dependencies.contains(&prev_child.uid) {
current_number += 1;
uses_number = true;
} else if prev_child.dependencies == child.dependencies && uses_number_prev {
uses_number = true;
} else {
current_number = 1;
let has_successor = children
.iter()
.skip(i + 1)
.any(|c| c.dependencies.contains(&child.uid));
if has_successor {
uses_number = true;
}
}
} else {
let has_successor = children
.iter()
.skip(1)
.any(|c| c.dependencies.contains(&child.uid));
if has_successor {
uses_number = true;
}
}
uses_number_prev = uses_number;
if uses_number {
prefixes.push(format!("{}.", current_number));
} else {
prefixes.push("-".to_string());
}
}
for (child, prefix) in children.iter().zip(prefixes.iter()) {
serialize_node(ctx, child, depth + 1, out, prefix, &task.calendar_href);
}
}
}
let ctx = SerializeContext {
children_map: &children_map,
store,
calendars,
};
if is_journal {
if !root.description.is_empty() {
out.push_str(&root.description);
out.push('\n');
}
if let Some(children) = children_map.get(&root.uid) {
let mut prefixes = Vec::new();
let mut current_number = 1;
let mut uses_number_prev = false;
for i in 0..children.len() {
let child = children[i];
let mut uses_number = false;
if i > 0 {
let prev_child = children[i - 1];
if child.dependencies.contains(&prev_child.uid) {
current_number += 1;
uses_number = true;
} else if prev_child.dependencies == child.dependencies && uses_number_prev {
uses_number = true;
} else {
current_number = 1;
let has_successor = children
.iter()
.skip(i + 1)
.any(|c| c.dependencies.contains(&child.uid));
if has_successor {
uses_number = true;
}
}
} else {
let has_successor = children
.iter()
.skip(1)
.any(|c| c.dependencies.contains(&child.uid));
if has_successor {
uses_number = true;
}
}
uses_number_prev = uses_number;
if uses_number {
prefixes.push(format!("{}.", current_number));
} else {
prefixes.push("-".to_string());
}
}
for (child, prefix) in children.iter().zip(prefixes.iter()) {
serialize_node(&ctx, child, 0, &mut out, prefix, &root.calendar_href);
}
}
} else {
serialize_node(&ctx, root, 0, &mut out, "-", &root.calendar_href);
}
out.trim_end().to_string()
}