1use anyhow::{Result, anyhow};
2use clap::Parser;
3
4use crate::bear::{join_tags, maybe_push, maybe_push_bool, open_bear_action};
5use crate::cli::Cli;
6use crate::cli::Commands;
7use crate::config::{encode_file, load_token, resolve_database_path, save_token};
8use crate::dates::parse_bear_date_filter;
9use crate::db::BearDb;
10use crate::export::export_notes;
11
12pub fn run() -> Result<()> {
13 let cli = Cli::parse();
14 let db = match &cli.command {
15 Commands::OpenNote(_)
16 | Commands::Tags
17 | Commands::OpenTag(_)
18 | Commands::Search(_)
19 | Commands::Export(_)
20 | Commands::Duplicates(_)
21 | Commands::Stats(_)
22 | Commands::Health(_)
23 | Commands::Untagged(_)
24 | Commands::Todo(_)
25 | Commands::Today(_)
26 | Commands::Locked(_) => Some(BearDb::open(resolve_database_path(
27 cli.database.as_deref(),
28 )?)?),
29 _ => None,
30 };
31
32 match cli.command {
33 Commands::Auth(cmd) => {
34 save_token(&cmd.token)?;
35 println!("Saved API token.");
36 }
37 Commands::OpenNote(cmd) => {
38 let note = db
39 .as_ref()
40 .expect("db available for read command")
41 .find_note(cmd.id.as_deref(), cmd.title.as_deref(), cmd.exclude_trashed)?;
42 println!("{}", note.text);
43 }
44 Commands::Tags => {
45 for tag in db.as_ref().expect("db available for read command").tags()? {
46 println!("{tag}");
47 }
48 }
49 Commands::OpenTag(cmd) => {
50 for note in db
51 .as_ref()
52 .expect("db available for read command")
53 .notes_for_tags(&split_csv(&cmd.name), false)?
54 {
55 println!("{}\t{}", note.identifier, note.title);
56 }
57 }
58 Commands::Search(cmd) => {
59 let since = cmd
60 .since
61 .as_deref()
62 .map(parse_bear_date_filter)
63 .transpose()?;
64 let before = cmd
65 .before
66 .as_deref()
67 .map(parse_bear_date_filter)
68 .transpose()?;
69 let results = db.as_ref().expect("db available for read command").search(
70 cmd.term.as_deref(),
71 cmd.tag.as_deref(),
72 false,
73 since,
74 before,
75 )?;
76
77 if cmd.json {
78 let output = serde_json::json!({
79 "results": results.iter().map(|note| serde_json::json!({
80 "id": note.identifier,
81 "title": note.title,
82 "snippet": note.snippet,
83 "modified": note.modified_at,
84 "rank": note.rank,
85 })).collect::<Vec<_>>()
86 });
87 println!("{}", serde_json::to_string_pretty(&output)?);
88 } else {
89 for note in results {
90 println!("{}\t{}", note.identifier, note.title);
91 if let Some(snippet) = note.snippet {
92 println!(" {}", snippet);
93 }
94 }
95 }
96 }
97 Commands::Export(cmd) => {
98 let notes = db
99 .as_ref()
100 .expect("db available for read command")
101 .export_notes(cmd.tag.as_deref())?;
102 let written = export_notes(&cmd.output, ¬es, cmd.frontmatter, cmd.by_tag)?;
103 println!(
104 "Exported {} note(s) to {}",
105 written.len(),
106 cmd.output.display()
107 );
108 }
109 Commands::Duplicates(cmd) => {
110 let groups = db
111 .as_ref()
112 .expect("db available for read command")
113 .duplicate_titles()?;
114
115 if cmd.json {
116 let total_duplicate_notes =
117 groups.iter().map(|group| group.notes.len()).sum::<usize>();
118 let output = serde_json::json!({
119 "duplicateGroups": groups.len(),
120 "totalDuplicateNotes": total_duplicate_notes,
121 "groups": groups.iter().map(|group| serde_json::json!({
122 "title": group.title,
123 "count": group.notes.len(),
124 "notes": group.notes.iter().map(|note| serde_json::json!({
125 "id": note.identifier,
126 "modified": note.modified_at,
127 })).collect::<Vec<_>>()
128 })).collect::<Vec<_>>()
129 });
130 println!("{}", serde_json::to_string_pretty(&output)?);
131 } else if groups.is_empty() {
132 println!("No duplicate titles found.");
133 } else {
134 println!("Found {} duplicate titles:\n", groups.len());
135 for group in groups {
136 println!("\"{}\" ({} copies)", group.title, group.notes.len());
137 for note in group.notes {
138 if let Some(modified) = note.modified_at {
139 println!(" {}\t{}", note.identifier, modified);
140 } else {
141 println!(" {}", note.identifier);
142 }
143 }
144 println!();
145 }
146 }
147 }
148 Commands::Stats(cmd) => {
149 let summary = db
150 .as_ref()
151 .expect("db available for read command")
152 .stats_summary()?;
153 let untagged_notes = summary.total_notes.saturating_sub(summary.tagged_notes);
154
155 if cmd.json {
156 let output = serde_json::json!({
157 "totalNotes": summary.total_notes,
158 "pinnedNotes": summary.pinned_notes,
159 "taggedNotes": summary.tagged_notes,
160 "untaggedNotes": untagged_notes,
161 "archivedNotes": summary.archived_notes,
162 "trashedNotes": summary.trashed_notes,
163 "uniqueTags": summary.unique_tags,
164 "totalWords": summary.total_words,
165 "notesWithTodos": summary.notes_with_todos,
166 "oldestModified": summary.oldest_modified,
167 "newestModified": summary.newest_modified,
168 "topTags": summary.top_tags.iter().map(|(tag, count)| serde_json::json!({
169 "tag": tag,
170 "count": count,
171 })).collect::<Vec<_>>(),
172 });
173 println!("{}", serde_json::to_string_pretty(&output)?);
174 } else {
175 println!("Notes: {}", summary.total_notes);
176 println!("Pinned: {}", summary.pinned_notes);
177 println!("Tagged: {}", summary.tagged_notes);
178 println!("Untagged: {}", untagged_notes);
179 println!("Archived: {}", summary.archived_notes);
180 println!("Trashed: {}", summary.trashed_notes);
181 println!("Tags: {}", summary.unique_tags);
182 println!("Words: {}", summary.total_words);
183 println!("Notes with TODOs: {}", summary.notes_with_todos);
184 if let Some(oldest) = summary.oldest_modified {
185 println!("Oldest modified: {}", oldest);
186 }
187 if let Some(newest) = summary.newest_modified {
188 println!("Newest modified: {}", newest);
189 }
190 if !summary.top_tags.is_empty() {
191 println!("\nTop tags:");
192 for (tag, count) in summary.top_tags {
193 println!(" #{}: {}", tag, count);
194 }
195 }
196 }
197 }
198 Commands::Health(cmd) => {
199 let summary = db
200 .as_ref()
201 .expect("db available for read command")
202 .health_summary()?;
203
204 if cmd.json {
205 let output = serde_json::json!({
206 "totalNotes": summary.total_notes,
207 "duplicateGroups": summary.duplicate_groups,
208 "duplicateNotes": summary.duplicate_notes,
209 "emptyNotes": summary.empty_notes.iter().map(|note| serde_json::json!({
210 "id": note.identifier,
211 "title": note.title,
212 })).collect::<Vec<_>>(),
213 "untaggedNotes": summary.untagged_notes,
214 "oldTrashedNotes": summary.old_trashed_notes.iter().map(|note| serde_json::json!({
215 "id": note.identifier,
216 "title": note.title,
217 })).collect::<Vec<_>>(),
218 "largeNotes": summary.large_notes.iter().map(|note| serde_json::json!({
219 "id": note.identifier,
220 "title": note.title,
221 "sizeBytes": note.size_bytes,
222 })).collect::<Vec<_>>(),
223 "conflictNotes": summary.conflict_notes.iter().map(|note| serde_json::json!({
224 "id": note.identifier,
225 "title": note.title,
226 })).collect::<Vec<_>>(),
227 });
228 println!("{}", serde_json::to_string_pretty(&output)?);
229 } else {
230 println!("Bear health report\n");
231 println!(
232 "{} duplicate title group(s) covering {} note(s)",
233 summary.duplicate_groups, summary.duplicate_notes
234 );
235 println!("{} empty note(s)", summary.empty_notes.len());
236 println!("{} untagged note(s)", summary.untagged_notes);
237 println!("{} old trashed note(s)", summary.old_trashed_notes.len());
238 println!("{} large note(s)", summary.large_notes.len());
239 println!("{} conflict-looking note(s)", summary.conflict_notes.len());
240 println!("\n{} active note(s) checked", summary.total_notes);
241 }
242 }
243 Commands::Untagged(cmd) => {
244 for note in db
245 .as_ref()
246 .expect("db available for read command")
247 .untagged(cmd.search.as_deref())?
248 {
249 println!("{}\t{}", note.identifier, note.title);
250 }
251 }
252 Commands::Todo(cmd) => {
253 for note in db
254 .as_ref()
255 .expect("db available for read command")
256 .todo(cmd.search.as_deref())?
257 {
258 println!("{}\t{}", note.identifier, note.title);
259 }
260 }
261 Commands::Today(cmd) => {
262 for note in db
263 .as_ref()
264 .expect("db available for read command")
265 .today(cmd.search.as_deref())?
266 {
267 println!("{}\t{}", note.identifier, note.title);
268 }
269 }
270 Commands::Locked(cmd) => {
271 for note in db
272 .as_ref()
273 .expect("db available for read command")
274 .locked(cmd.search.as_deref())?
275 {
276 println!("{}\t{}", note.identifier, note.title);
277 }
278 }
279 Commands::Create(cmd) => {
280 let mut query = Vec::new();
281 maybe_push(&mut query, "title", cmd.title);
282 maybe_push(&mut query, "text", cmd.text);
283 maybe_push(&mut query, "tags", join_tags(&cmd.tag));
284 maybe_push_bool(&mut query, "open_note", cmd.open_note);
285 maybe_push_bool(&mut query, "new_window", cmd.new_window);
286 maybe_push_bool(&mut query, "float", cmd.float);
287 maybe_push_bool(&mut query, "show_window", cmd.show_window);
288 maybe_push_bool(&mut query, "pin", cmd.pin);
289 maybe_push_bool(&mut query, "edit", cmd.edit);
290 maybe_push_bool(&mut query, "timestamp", cmd.timestamp);
291 maybe_push(&mut query, "type", cmd.kind);
292 maybe_push(&mut query, "url", cmd.url);
293
294 if let Some(file) = cmd.file {
295 let filename = cmd
296 .filename
297 .or_else(|| {
298 file.file_name()
299 .map(|name| name.to_string_lossy().into_owned())
300 })
301 .ok_or_else(|| {
302 anyhow!("--filename is required when the file path has no file name")
303 })?;
304 let encoded = encode_file(&file)?;
305 maybe_push(&mut query, "filename", Some(filename));
306 maybe_push(&mut query, "file", Some(encoded));
307 }
308
309 open_bear_action("create", &query)?;
310 }
311 Commands::AddText(cmd) => {
312 let mut query = Vec::new();
313 maybe_push(&mut query, "id", cmd.id);
314 maybe_push(&mut query, "title", cmd.title);
315 maybe_push(&mut query, "text", cmd.text);
316 maybe_push(&mut query, "header", cmd.header);
317 maybe_push(&mut query, "mode", Some(cmd.mode));
318 maybe_push(&mut query, "tags", join_tags(&cmd.tag));
319 maybe_push_bool(&mut query, "exclude_trashed", cmd.exclude_trashed);
320 maybe_push_bool(&mut query, "new_line", cmd.new_line);
321 maybe_push_bool(&mut query, "open_note", cmd.open_note);
322 maybe_push_bool(&mut query, "new_window", cmd.new_window);
323 maybe_push_bool(&mut query, "show_window", cmd.show_window);
324 maybe_push_bool(&mut query, "edit", cmd.edit);
325 maybe_push_bool(&mut query, "timestamp", cmd.timestamp);
326 open_bear_action("add-text", &query)?;
327 }
328 Commands::AddFile(cmd) => {
329 let mut query = Vec::new();
330 maybe_push(&mut query, "id", cmd.id);
331 maybe_push(&mut query, "title", cmd.title);
332 maybe_push(&mut query, "header", cmd.header);
333 maybe_push(&mut query, "mode", Some(cmd.mode));
334 maybe_push_bool(&mut query, "open_note", cmd.open_note);
335 maybe_push_bool(&mut query, "new_window", cmd.new_window);
336 maybe_push_bool(&mut query, "show_window", cmd.show_window);
337 maybe_push_bool(&mut query, "edit", cmd.edit);
338 maybe_push(
339 &mut query,
340 "filename",
341 cmd.filename.or_else(|| {
342 cmd.file
343 .file_name()
344 .map(|name| name.to_string_lossy().into_owned())
345 }),
346 );
347 maybe_push(&mut query, "file", Some(encode_file(&cmd.file)?));
348 open_bear_action("add-file", &query)?;
349 }
350 Commands::GrabUrl(cmd) => {
351 let mut query = Vec::new();
352 maybe_push(&mut query, "url", Some(cmd.url));
353 maybe_push(&mut query, "tags", join_tags(&cmd.tag));
354 maybe_push_bool(&mut query, "pin", cmd.pin);
355 maybe_push_bool(&mut query, "wait", cmd.wait);
356 open_bear_action("grab-url", &query)?;
357 }
358 Commands::Trash(cmd) => {
359 let mut query = Vec::new();
360 maybe_push(&mut query, "id", cmd.id);
361 maybe_push(&mut query, "search", cmd.search);
362 maybe_push_bool(&mut query, "show_window", cmd.show_window);
363 open_bear_action("trash", &query)?;
364 }
365 Commands::Archive(cmd) => {
366 let mut query = Vec::new();
367 maybe_push(&mut query, "id", cmd.id);
368 maybe_push(&mut query, "search", cmd.search);
369 maybe_push_bool(&mut query, "show_window", cmd.show_window);
370 open_bear_action("archive", &query)?;
371 }
372 Commands::RenameTag(cmd) => {
373 let mut query = Vec::new();
374 maybe_push(&mut query, "name", Some(cmd.name));
375 maybe_push(&mut query, "new_name", Some(cmd.new_name));
376 maybe_push_bool(&mut query, "show_window", cmd.show_window);
377 open_bear_action("rename-tag", &query)?;
378 }
379 Commands::DeleteTag(cmd) => {
380 let mut query = Vec::new();
381 maybe_push(&mut query, "name", Some(cmd.name));
382 maybe_push_bool(&mut query, "show_window", cmd.show_window);
383 open_bear_action("delete-tag", &query)?;
384 }
385 Commands::Raw(cmd) => {
386 let mut params = cmd.params;
387 let has_token = params.iter().any(|(key, _)| key == "token");
388 if !has_token {
389 if let Some(token) = cmd.token {
390 params.push(("token".into(), token));
391 } else if cmd.use_saved_token {
392 if let Some(token) = load_token()? {
393 params.push(("token".into(), token));
394 }
395 }
396 }
397 open_bear_action(&cmd.action, ¶ms)?;
398 }
399 }
400
401 Ok(())
402}
403
404fn split_csv(input: &str) -> Vec<String> {
405 input
406 .split(',')
407 .map(str::trim)
408 .filter(|part| !part.is_empty())
409 .map(ToOwned::to_owned)
410 .collect()
411}