Skip to main content

bear_cli/
runner.rs

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, &notes, 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, &params)?;
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}