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}