Skip to main content

kanbrain_core/api/
json.rs

1//! Entries as JSON, for `--json`. A separate shape from `Task`, so the file
2//! format and the JSON can evolve independently, and timestamps read the same as in the files.
3
4use crate::brain::Brain;
5use crate::model::{Activity, Asked, Author, Changed, Comment, Kind, Task, fmt_ts, now};
6use serde::Serialize;
7
8/// An entry with everything an agent needs to act on it.
9#[derive(Debug, Serialize)]
10#[non_exhaustive]
11pub struct Entry<'a> {
12    /// The id to pass to other commands.
13    pub id: u32,
14    /// `task`, `note` or `decision`.
15    pub kind: &'static str,
16    /// One-line title.
17    pub title: &'a str,
18    /// Tasks only: `todo`, `doing`, `parked` or `done`.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub status: Option<&'static str>,
21    /// Decisions only: `accepted`, `proposed` or `rejected`.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub decision: Option<&'static str>,
24    /// A parked task's question: `waiting for an answer` or `answered`.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub asked: Option<&'static str>,
27    /// Lowercase, without `#`.
28    pub tags: &'a [String],
29    /// Ids this entry links to. Empty fields are left out, as every token counts for an agent.
30    #[serde(skip_serializing_if = "<[u32]>::is_empty")]
31    pub links: &'a [u32],
32    /// Entries of the project's other repositories this one links to, as `shop-api#3`.
33    #[serde(skip_serializing_if = "Vec::is_empty")]
34    pub repo_links: Vec<String>,
35    /// Ids of entries linking to this one.
36    #[serde(skip_serializing_if = "Vec::is_empty")]
37    pub linked_from: Vec<u32>,
38    /// RFC 3339.
39    pub created: String,
40    /// Free text (markdown).
41    #[serde(skip_serializing_if = "str::is_empty")]
42    pub description: &'a str,
43    /// Oldest first.
44    #[serde(skip_serializing_if = "Vec::is_empty")]
45    pub comments: Vec<EntryComment<'a>>,
46    /// Changes to the title, tags, related entries and description, oldest first.
47    #[serde(skip_serializing_if = "Vec::is_empty")]
48    pub activity: Vec<EntryChange<'a>>,
49    /// Tracked time in minutes, if the task was ever tracked.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub tracked_minutes: Option<i64>,
52    /// Whether the task's clock is running now; only present when it is.
53    #[serde(skip_serializing_if = "std::ops::Not::not")]
54    pub running: bool,
55    /// The markdown file, to read; a change goes through `kb`, which takes the brain's lock.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub path: Option<String>,
58}
59
60/// A comment on an entry.
61#[derive(Debug, Serialize)]
62#[non_exhaustive]
63pub struct EntryComment<'a> {
64    /// RFC 3339.
65    pub created: String,
66    /// RFC 3339, if edited after posting.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub edited: Option<String>,
69    /// The comment text (markdown).
70    pub body: &'a str,
71    /// `agent` for an agent's; left out for a person's.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub author: Option<&'static str>,
74    /// Whether it's a question to the user; only present when it is.
75    #[serde(skip_serializing_if = "std::ops::Not::not")]
76    pub question: bool,
77}
78
79/// A change to an entry, as its `## Activity` keeps it.
80#[derive(Debug, Serialize)]
81#[non_exhaustive]
82pub struct EntryChange<'a> {
83    /// RFC 3339.
84    pub at: String,
85    /// `agent` for an agent's; left out for a person's.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub author: Option<&'static str>,
88    /// `title`, `tags`, `related` or `description`.
89    pub changed: &'static str,
90    /// The title before; title changes only.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub from: Option<&'a str>,
93    /// The title after; title changes only.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub to: Option<&'a str>,
96    /// Tags, or related entries (`5`, `shop-api#3`), it got.
97    #[serde(skip_serializing_if = "<[String]>::is_empty")]
98    pub added: &'a [String],
99    /// Tags, or related entries, it lost.
100    #[serde(skip_serializing_if = "<[String]>::is_empty")]
101    pub removed: &'a [String],
102}
103
104/// A short form for lists and search results: no description or comments.
105#[derive(Debug, Serialize)]
106#[non_exhaustive]
107pub struct Summary<'a> {
108    /// The id to pass to other commands.
109    pub id: u32,
110    /// `task`, `note` or `decision`.
111    pub kind: &'static str,
112    /// One-line title.
113    pub title: &'a str,
114    /// Tasks only.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub status: Option<&'static str>,
117    /// Decisions only.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub decision: Option<&'static str>,
120    /// A parked task's question, as in [`Entry`].
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub asked: Option<&'static str>,
123    /// Lowercase, without `#`.
124    pub tags: &'a [String],
125}
126
127impl<'a> Entry<'a> {
128    /// The full JSON shape of `task`, with its backlinks from `brain`.
129    pub fn new(brain: &Brain, task: &'a Task) -> Self {
130        let now = now();
131        Self {
132            id: task.id,
133            kind: task.kind.as_str(),
134            title: &task.title,
135            status: task.is_task().then(|| task.status.as_str()),
136            decision: decision(task),
137            asked: asked(task),
138            tags: &task.tags,
139            links: &task.links,
140            repo_links: task.repo_links.iter().map(ToString::to_string).collect(),
141            linked_from: brain.backlinks(task.id).iter().map(|t| t.id).collect(),
142            created: fmt_ts(task.created),
143            description: &task.description,
144            comments: task.comments.iter().map(EntryComment::new).collect(),
145            activity: task.activity.iter().map(EntryChange::new).collect(),
146            tracked_minutes: (!task.sessions.is_empty()).then(|| task.total(now).num_minutes()),
147            running: task.is_running(),
148            path: task.path.as_ref().map(|p| p.display().to_string()),
149        }
150    }
151}
152
153impl<'a> EntryComment<'a> {
154    /// The JSON shape of `comment`.
155    fn new(comment: &'a Comment) -> Self {
156        Self {
157            created: fmt_ts(comment.created),
158            edited: comment.edited.map(fmt_ts),
159            body: &comment.body,
160            author: (comment.author != Author::Human).then(|| comment.author.as_str()),
161            question: comment.question,
162        }
163    }
164}
165
166impl<'a> EntryChange<'a> {
167    /// The JSON shape of `activity`.
168    fn new(activity: &'a Activity) -> Self {
169        let none: &[String] = &[];
170        let (changed, from, to, added, removed) = match &activity.change {
171            Changed::Title { from, to } => ("title", Some(from.as_str()), Some(to.as_str()), none, none),
172            Changed::Tags { added, removed } => ("tags", None, None, added.as_slice(), removed.as_slice()),
173            Changed::Related { added, removed } => ("related", None, None, added.as_slice(), removed.as_slice()),
174            Changed::Description => ("description", None, None, none, none),
175        };
176        Self {
177            at: fmt_ts(activity.at),
178            author: (activity.by != Author::Human).then(|| activity.by.as_str()),
179            changed,
180            from,
181            to,
182            added,
183            removed,
184        }
185    }
186}
187
188impl<'a> Summary<'a> {
189    /// The short JSON shape of `task`.
190    pub fn new(task: &'a Task) -> Self {
191        Self {
192            id: task.id,
193            kind: task.kind.as_str(),
194            title: &task.title,
195            status: task.is_task().then(|| task.status.as_str()),
196            decision: decision(task),
197            asked: asked(task),
198            tags: &task.tags,
199        }
200    }
201}
202
203/// A decision's state; `None` for tasks and notes.
204fn decision(task: &Task) -> Option<&'static str> {
205    (task.kind == Kind::Decision).then(|| task.decision.as_str())
206}
207
208/// Where a parked task's question stands; `None` without one.
209fn asked(task: &Task) -> Option<&'static str> {
210    task.asked().map(Asked::label)
211}