kanbrain_core/api/
text.rs1use super::ApiError;
7use crate::brain::Brain;
8use crate::model::{Author, Comment, DecisionState, Task, ThreadItem, counted, fmt_age, fmt_dur, now};
9use chrono::{DateTime, Local};
10use std::fmt::Write;
11
12const HISTORY_LIMIT: usize = 5;
14const CHANGES_LIMIT: usize = 10;
16pub const SEARCH_LIMIT: usize = 20;
18
19pub fn line(task: &Task) -> String {
23 let mut state = if task.is_task() { task.status.as_str() } else { task.kind.as_str() }.to_string();
24 if let Some(also) = state_note(task) {
25 write!(state, ", {also}").expect("writing to a String cannot fail");
26 }
27 let mut line = format!("#{} {} ({state})", task.id, task.title);
28 for tag in &task.tags {
29 write!(line, " #{tag}").expect("writing to a String cannot fail");
30 }
31 line
32}
33
34pub fn head(out: &mut String, task: &Task) -> std::fmt::Result {
36 write!(out, "#{} {}", task.id, task.title)?;
37 task.tags.iter().try_for_each(|tag| write!(out, " #{tag}"))
38}
39
40pub fn state_note(task: &Task) -> Option<&'static str> {
43 if let Some(asked) = task.asked() {
44 return Some(asked.label());
45 }
46 match task.decision {
47 DecisionState::Accepted => None,
48 state => Some(state.as_str()),
49 }
50}
51
52fn comment_heading(comment: &Comment) -> String {
54 let mut heading = when(comment.created);
55 if comment.author == Author::Agent {
56 heading.push_str(" · agent");
57 }
58 if comment.question {
59 heading.push_str(" · question");
60 }
61 if let Some(edited) = comment.edited {
62 write!(heading, " (edited {})", when(edited)).expect("writing to a String cannot fail");
63 }
64 heading
65}
66
67fn when(at: DateTime<Local>) -> String {
69 at.format("%Y-%m-%d %H:%M").to_string()
70}
71
72impl Brain {
73 pub fn entry_text(&self, id: u32) -> Result<String, ApiError> {
76 let task = self.entry(id)?;
77 let mut out = String::new();
78 self.write_entry(&mut out, task).expect("writing to a String cannot fail");
79 Ok(out)
80 }
81
82 pub fn search_text(&self, query: &str) -> String {
84 let found = self.search(query);
85 if found.is_empty() {
86 return "nothing found\n".to_string();
87 }
88 let mut out = String::new();
89 for task in found.iter().take(SEARCH_LIMIT) {
90 writeln!(out, "- {}", line(task)).expect("writing to a String cannot fail");
91 }
92 if let Some(more) = found.len().checked_sub(SEARCH_LIMIT).filter(|&n| n > 0) {
93 writeln!(out, "- … {more} more: add words to narrow it down").expect("writing to a String cannot fail");
94 }
95 out
96 }
97
98 fn write_entry(&self, out: &mut String, task: &Task) -> std::fmt::Result {
100 let now = now();
101 writeln!(out, "# {}", line(task))?;
102 let mut facts = vec![format!("created {}", when(task.created))];
103 if task.is_task() && !task.history.is_empty() {
104 facts.push(format!("{} for {}", task.status.as_str(), fmt_age(now - task.status_since())));
105 }
106 if !task.sessions.is_empty() {
107 let running = task.running_for(now).map(|r| format!(", running for {}", fmt_dur(r))).unwrap_or_default();
108 let sessions = counted(task.sessions.len(), "session", "sessions");
109 facts.push(format!("tracked {} in {sessions}{running}", fmt_dur(task.total(now))));
110 }
111 writeln!(out, "{}", facts.join(" · "))?;
112 if let Some(path) = &task.path {
113 writeln!(out, "file: {}", path.display())?;
114 }
115 if !task.description.trim().is_empty() {
116 writeln!(out, "\n## Description\n\n{}", task.description.trim())?;
117 }
118 let heading = match (task.comments.is_empty(), task.activity.is_empty()) {
119 (true, true) => None,
120 (false, true) => Some("Comments"),
121 (true, false) => Some("Changes"),
122 (false, false) => Some("Comments and changes"),
123 };
124 if let Some(heading) = heading {
125 let skipped = task.activity.len().saturating_sub(CHANGES_LIMIT);
126 match skipped {
127 0 => writeln!(out, "\n## {heading}")?,
128 _ => writeln!(out, "\n## {heading} (latest {CHANGES_LIMIT} of {} changes)", task.activity.len())?,
129 }
130 let mut listing = false;
131 let mut changes = 0;
132 for item in task.thread() {
133 if let ThreadItem::Change(_) = item {
134 changes += 1;
135 if changes <= skipped {
136 continue;
137 }
138 }
139 match item {
140 ThreadItem::Comment(_, comment) => {
141 writeln!(out, "\n### {}\n\n{}", comment_heading(comment), comment.body.trim())?;
142 listing = false;
143 }
144 ThreadItem::Change(change) => {
146 if !std::mem::replace(&mut listing, true) {
147 writeln!(out)?;
148 }
149 let by = if change.by == Author::Agent { " (agent)" } else { "" };
150 writeln!(out, "- {} {}{by}", when(change.at), change.change.said())?;
151 }
152 }
153 }
154 }
155 let related = self.related(task.id);
156 let elsewhere = self.repo_related(task.id);
157 if !related.is_empty() || !elsewhere.is_empty() {
158 writeln!(out, "\n## Related\n")?;
159 for other in related {
160 writeln!(out, "- {}", line(other))?;
161 }
162 for (repo, other) in &elsewhere {
163 writeln!(out, "- {repo}{}", line(other))?;
164 }
165 }
166 if !task.history.is_empty() {
167 let skipped = task.history.len().saturating_sub(HISTORY_LIMIT);
168 let heading = if skipped > 0 {
169 format!("History (latest {HISTORY_LIMIT} of {})", task.history.len())
170 } else {
171 "History".to_string()
172 };
173 writeln!(out, "\n## {heading}\n")?;
174 for change in &task.history[skipped..] {
175 let by = if change.by == Author::Agent { " (agent)" } else { "" };
176 writeln!(out, "- {} {} -> {}{by}", when(change.at), change.from.as_str(), change.to.as_str())?;
177 }
178 }
179 Ok(())
180 }
181}