Skip to main content

blotter/commands/
digest.rs

1use crate::cli::{DigestArgs, OutputFormat};
2use crate::commands::triage::{self, TriageCluster};
3use crate::error::{AppError, AppResult};
4use crate::output::{self, Meta};
5use crate::store;
6use crate::{ItemStatus, ListItem, format_timestamp, parse_since};
7use jiff::Timestamp;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Serialize, Deserialize)]
14pub struct DigestData {
15    pub chronic: Vec<TriageCluster>,
16    pub new_cuts: NewCuts,
17    pub open_dogears: OpenDogears,
18    pub window: DigestWindow,
19}
20
21#[derive(Debug, Serialize, Deserialize)]
22pub struct NewCuts {
23    pub count: usize,
24    pub by_tag: Vec<TagGroup>,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
28pub struct TagGroup {
29    pub tag: String,
30    pub count: usize,
31    pub ids: Vec<String>,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
35pub struct OpenDogears {
36    pub count: usize,
37    pub items: Vec<OpenDogear>,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct OpenDogear {
42    pub id: String,
43    pub ts: String,
44    pub text: String,
45    pub tags: Vec<String>,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
49pub struct DigestWindow {
50    pub since: String,
51    pub until: String,
52}
53
54pub fn run(
55    args: DigestArgs,
56    file: Option<PathBuf>,
57    pretty: bool,
58    now: Timestamp,
59) -> AppResult<i32> {
60    let since = parse_since(&args.since, now)?;
61    let resolved = store::discover(file)?;
62    let store::LoadedFold {
63        items,
64        mut warnings,
65    } = store::load_folded(&resolved)?;
66    let (items, auto_captures) = crate::partition_auto_captures(items, args.include_auto);
67    let hidden = auto_captures
68        .iter()
69        .filter(|item| item.status == ItemStatus::Open)
70        .count();
71    if hidden > 0 {
72        warnings.push(crate::auto_capture_warning(hidden));
73    }
74
75    let data = digest(items, since, now);
76    if args.format == OutputFormat::Md {
77        write_markdown(&data, &warnings)?;
78    } else {
79        let mut meta = Meta::new();
80        meta.file = Some(resolved.path.to_string_lossy().into_owned());
81        meta.warnings = warnings;
82        output::write_success(data, pretty, meta)
83            .map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
84    }
85    Ok(0)
86}
87
88fn digest(items: Vec<ListItem>, since: Timestamp, until: Timestamp) -> DigestData {
89    let chronic = triage::triage(items.clone(), 2).clusters;
90    let mut tags = BTreeMap::<String, Vec<String>>::new();
91    let mut new_cut_count = 0;
92    let mut open_dogears = Vec::new();
93
94    for item in items {
95        if item.status != ItemStatus::Open {
96            continue;
97        }
98        match item.kind.as_str() {
99            "cut" => {
100                let timestamp = item
101                    .ts
102                    .parse::<Timestamp>()
103                    .expect("folded items have valid RFC3339 timestamps");
104                if timestamp >= since && timestamp <= until {
105                    new_cut_count += 1;
106                    let item_tags = if item.tags.is_empty() {
107                        vec![String::new()]
108                    } else {
109                        item.tags
110                    };
111                    for tag in item_tags {
112                        tags.entry(tag).or_default().push(item.id.clone());
113                    }
114                }
115            }
116            "dogear" => open_dogears.push(OpenDogear {
117                id: item.id,
118                ts: item.ts,
119                text: item.text,
120                tags: item.tags,
121            }),
122            _ => unreachable!("folded items are cut or dogear"),
123        }
124    }
125
126    let mut by_tag: Vec<_> = tags
127        .into_iter()
128        .map(|(tag, mut ids)| {
129            ids.sort();
130            TagGroup {
131                tag,
132                count: ids.len(),
133                ids,
134            }
135        })
136        .collect();
137    by_tag.sort_by(|left, right| {
138        right
139            .count
140            .cmp(&left.count)
141            .then_with(|| left.tag.cmp(&right.tag))
142    });
143    open_dogears.sort_by(|left, right| {
144        right
145            .ts
146            .parse::<Timestamp>()
147            .expect("folded dogears have valid RFC3339 timestamps")
148            .cmp(
149                &left
150                    .ts
151                    .parse::<Timestamp>()
152                    .expect("folded dogears have valid RFC3339 timestamps"),
153            )
154            .then_with(|| left.id.cmp(&right.id))
155    });
156
157    DigestData {
158        chronic,
159        new_cuts: NewCuts {
160            count: new_cut_count,
161            by_tag,
162        },
163        open_dogears: OpenDogears {
164            count: open_dogears.len(),
165            items: open_dogears,
166        },
167        window: DigestWindow {
168            since: format_timestamp(since),
169            until: format_timestamp(until),
170        },
171    }
172}
173
174fn write_markdown(data: &DigestData, warnings: &[String]) -> AppResult<()> {
175    let mut output =
176        output::stdout_writer().map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
177    let result: std::io::Result<()> = (|| {
178        let mut wrote_section = false;
179        if !data.chronic.is_empty() {
180            writeln!(output, "## Chronic")?;
181            for cluster in &data.chronic {
182                writeln!(
183                    output,
184                    "- {} ({}): {}",
185                    crate::output::collapse_markdown_text(&cluster.text),
186                    cluster.count,
187                    cluster.ids.join(", ")
188                )?;
189            }
190            wrote_section = true;
191        }
192        if !data.new_cuts.by_tag.is_empty() {
193            if wrote_section {
194                writeln!(output)?;
195            }
196            writeln!(output, "## New cuts")?;
197            for group in &data.new_cuts.by_tag {
198                let tag = if group.tag.is_empty() {
199                    "untagged".into()
200                } else {
201                    crate::output::collapse_markdown_text(&group.tag)
202                };
203                writeln!(output, "### {tag} ({})", group.count)?;
204                for id in &group.ids {
205                    writeln!(output, "- {id}")?;
206                }
207            }
208            wrote_section = true;
209        }
210        if !data.open_dogears.items.is_empty() {
211            if wrote_section {
212                writeln!(output)?;
213            }
214            writeln!(output, "## Open dogears")?;
215            for dogear in &data.open_dogears.items {
216                let tags = if dogear.tags.is_empty() {
217                    String::new()
218                } else {
219                    format!(" ({})", dogear.tags.join(","))
220                };
221                let line = format!("- [{}] {} — {}{tags}", dogear.id, dogear.text, dogear.ts);
222                writeln!(output, "{}", crate::output::collapse_markdown_text(&line))?;
223            }
224            wrote_section = true;
225        }
226        if !wrote_section {
227            writeln!(output, "No friction in window.")?;
228        }
229        for warning in warnings {
230            writeln!(output, "> note: {warning}")?;
231        }
232        output.flush()?;
233        Ok(())
234    })();
235    result.map_err(|error| AppError::from_io(error, Path::new("stdout")))
236}