Skip to main content

bear_cli/
runner.rs

1use anyhow::{Result, anyhow, bail};
2use chrono::Duration;
3use clap::Parser;
4
5use crate::cli::{AddFileMode, AddTextMode, Cli, Commands};
6use crate::cloudkit::auth::AuthConfig;
7use crate::cloudkit::client::{AttachPosition, CloudKitClient, extract_title, now_ms};
8use crate::cloudkit::models::CkRecord;
9use crate::dates::parse_bear_date_filter;
10use crate::export::{ExportNote, export_notes};
11use crate::verbose;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14struct SearchResult {
15    identifier: String,
16    title: String,
17    snippet: Option<String>,
18    modified_at: Option<i64>,
19    rank: u8,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23struct DuplicateNote {
24    identifier: String,
25    modified_at: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29struct DuplicateGroup {
30    title: String,
31    notes: Vec<DuplicateNote>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35struct StatsSummary {
36    total_notes: usize,
37    pinned_notes: usize,
38    tagged_notes: usize,
39    archived_notes: usize,
40    trashed_notes: usize,
41    unique_tags: usize,
42    total_words: usize,
43    notes_with_todos: usize,
44    oldest_modified: Option<i64>,
45    newest_modified: Option<i64>,
46    top_tags: Vec<(String, usize)>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50struct HealthNoteIssue {
51    identifier: String,
52    title: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56struct LargeNoteIssue {
57    identifier: String,
58    title: String,
59    size_bytes: usize,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63struct HealthSummary {
64    total_notes: usize,
65    duplicate_groups: usize,
66    duplicate_notes: usize,
67    empty_notes: Vec<HealthNoteIssue>,
68    untagged_notes: usize,
69    old_trashed_notes: Vec<HealthNoteIssue>,
70    large_notes: Vec<LargeNoteIssue>,
71    conflict_notes: Vec<HealthNoteIssue>,
72}
73
74pub fn run() -> Result<()> {
75    let cli = Cli::parse();
76    crate::verbose::set(cli.verbose);
77
78    match cli.command {
79        Commands::Auth(cmd) => {
80            command_log(1, "auth");
81            let token = match cmd.token {
82                Some(t) => t,
83                None => crate::cloudkit::auth_server::acquire_token()?,
84            };
85            AuthConfig {
86                ck_web_auth_token: token,
87            }
88            .save()?;
89            println!("CloudKit auth token saved.");
90        }
91
92        Commands::OpenNote(cmd) => {
93            command_log(1, "open-note");
94            let ck = load_ck()?;
95            let note = resolve_note(
96                cmd.id.as_deref(),
97                cmd.title.as_deref(),
98                !cmd.exclude_trashed,
99                true,
100                &ck,
101            )?;
102            println!("{}", note.str_field("textADP").unwrap_or(""));
103        }
104        Commands::InspectNote(cmd) => {
105            command_log(1, "inspect-note");
106            let ck = load_ck()?;
107            let note = if let Some(id) = cmd.id.as_deref() {
108                ck.fetch_note(id)?
109            } else if let Some(title) = cmd.title.as_deref() {
110                ck.fetch_note_by_title(title, !cmd.exclude_trashed, true)?
111            } else {
112                bail!("provide --id or --title");
113            };
114            println!("{}", serde_json::to_string_pretty(&note)?);
115        }
116        Commands::Tags => {
117            command_log(1, "tags");
118            for tag in load_ck()?.list_tags()? {
119                if let Some(name) = tag.str_field("title") {
120                    println!("{name}");
121                }
122            }
123        }
124        Commands::OpenTag(cmd) => {
125            command_log(1, format!("open-tag name={}", cmd.name));
126            let names = split_csv(&cmd.name);
127            verbose::eprintln(2, format!("[runner] open-tag parsed names={names:?}"));
128            for note in load_ck()?.list_notes(false, false, None)? {
129                let note_tags = note.string_list_field("tagsStrings");
130                if names
131                    .iter()
132                    .any(|name| note_tags.iter().any(|tag| tag == name))
133                {
134                    println!(
135                        "{}\t{}",
136                        note.record_name,
137                        note.str_field("title").unwrap_or("")
138                    );
139                }
140            }
141        }
142        Commands::Search(cmd) => {
143            command_log(
144                1,
145                format!(
146                    "search term={:?} tag={:?} since={:?} before={:?}",
147                    cmd.term, cmd.tag, cmd.since, cmd.before
148                ),
149            );
150            let since = cmd
151                .since
152                .as_deref()
153                .map(parse_cloudkit_date_filter)
154                .transpose()?;
155            let before = cmd
156                .before
157                .as_deref()
158                .map(parse_cloudkit_date_filter)
159                .transpose()?;
160            let results = search_notes(
161                &load_ck()?.list_notes(false, false, None)?,
162                cmd.term.as_deref(),
163                cmd.tag.as_deref(),
164                since,
165                before,
166            );
167            verbose::eprintln(
168                1,
169                format!("[runner] search matched {} note(s)", results.len()),
170            );
171
172            if cmd.json {
173                let output = serde_json::json!({
174                    "results": results.iter().map(|note| serde_json::json!({
175                        "id": note.identifier,
176                        "title": note.title,
177                        "snippet": note.snippet,
178                        "modified": note.modified_at,
179                        "rank": note.rank,
180                    })).collect::<Vec<_>>()
181                });
182                println!("{}", serde_json::to_string_pretty(&output)?);
183            } else {
184                for note in results {
185                    println!("{}\t{}", note.identifier, note.title);
186                    if let Some(snippet) = note.snippet {
187                        println!("  {snippet}");
188                    }
189                }
190            }
191        }
192        Commands::Notes(cmd) => {
193            command_log(
194                1,
195                format!(
196                    "notes include_trashed={} include_archived={} limit={:?}",
197                    cmd.trashed, cmd.archived, cmd.limit
198                ),
199            );
200            let notes = load_ck()?.list_notes(cmd.trashed, cmd.archived, cmd.limit)?;
201            verbose::eprintln(
202                1,
203                format!("[runner] notes returned {} note(s)", notes.len()),
204            );
205
206            if cmd.json {
207                println!(
208                    "{}",
209                    serde_json::to_string_pretty(&serde_json::json!({
210                        "notes": notes.iter().map(|note| serde_json::json!({
211                            "recordName": note.record_name,
212                            "id": note.str_field("uniqueIdentifier"),
213                            "title": note.str_field("title"),
214                            "subtitle": note.str_field("subtitleADP"),
215                            "created": note.i64_field("sf_creationDate"),
216                            "modified": note.i64_field("sf_modificationDate"),
217                            "trashed": note.i64_field("trashed").unwrap_or(0) != 0,
218                            "archived": note.i64_field("archived").unwrap_or(0) != 0,
219                            "pinned": note.i64_field("pinned").unwrap_or(0) != 0,
220                            "tags": note.fields.get("tagsStrings").and_then(|f| f.value.as_array()).map(|arr|
221                                arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>()
222                            ).unwrap_or_default(),
223                        })).collect::<Vec<_>>()
224                    }))?
225                );
226            } else {
227                for note in notes {
228                    let title = note.str_field("title").unwrap_or("");
229                    println!("{}\t{}", note.record_name, title);
230                }
231            }
232        }
233        Commands::PhantomNotes(cmd) => {
234            command_log(
235                1,
236                format!("phantom-notes delete={} limit={:?}", cmd.delete, cmd.limit),
237            );
238            let ck = load_ck()?;
239            let notes = ck.list_phantom_notes(cmd.limit)?;
240            verbose::eprintln(
241                1,
242                format!("[runner] phantom-notes found {} record(s)", notes.len()),
243            );
244
245            if cmd.delete {
246                let deleted = ck.delete_phantom_notes(&notes)?;
247                println!("Deleted {} phantom note(s).", deleted.len());
248            } else if cmd.json {
249                println!(
250                    "{}",
251                    serde_json::to_string_pretty(&serde_json::json!({
252                        "notes": notes.iter().map(|note| serde_json::json!({
253                            "recordName": note.record_name,
254                            "id": note.str_field("uniqueIdentifier"),
255                            "title": note.str_field("title"),
256                            "subtitle": note.str_field("subtitleADP"),
257                            "created": note.i64_field("sf_creationDate"),
258                            "modified": note.i64_field("sf_modificationDate"),
259                        })).collect::<Vec<_>>()
260                    }))?
261                );
262            } else {
263                for note in notes {
264                    let title = note.str_field("title").unwrap_or("");
265                    println!("{}\t{}", note.record_name, title);
266                }
267            }
268        }
269        Commands::Export(cmd) => {
270            command_log(
271                1,
272                format!(
273                    "export output={} tag={:?} frontmatter={} by_tag={}",
274                    cmd.output.display(),
275                    cmd.tag,
276                    cmd.frontmatter,
277                    cmd.by_tag
278                ),
279            );
280            let notes = exportable_notes(
281                &load_ck()?.list_notes(false, false, None)?,
282                cmd.tag.as_deref(),
283            );
284            verbose::eprintln(
285                1,
286                format!("[runner] export selected {} note(s)", notes.len()),
287            );
288            let written = export_notes(&cmd.output, &notes, cmd.frontmatter, cmd.by_tag)?;
289            println!(
290                "Exported {} note(s) to {}",
291                written.len(),
292                cmd.output.display()
293            );
294        }
295        Commands::Duplicates(cmd) => {
296            command_log(1, format!("duplicates json={}", cmd.json));
297            let groups = duplicate_groups(&load_ck()?.list_notes(false, true, None)?);
298            verbose::eprintln(
299                1,
300                format!("[runner] duplicates found {} group(s)", groups.len()),
301            );
302            if cmd.json {
303                let total = groups.iter().map(|g| g.notes.len()).sum::<usize>();
304                println!(
305                    "{}",
306                    serde_json::to_string_pretty(&serde_json::json!({
307                        "duplicateGroups": groups.len(),
308                        "totalDuplicateNotes": total,
309                        "groups": groups.iter().map(|g| serde_json::json!({
310                            "title": g.title,
311                            "count": g.notes.len(),
312                            "notes": g.notes.iter().map(|n| serde_json::json!({
313                                "id": n.identifier,
314                                "modified": n.modified_at,
315                            })).collect::<Vec<_>>()
316                        })).collect::<Vec<_>>()
317                    }))?
318                );
319            } else if groups.is_empty() {
320                println!("No duplicate titles found.");
321            } else {
322                for g in groups {
323                    println!("\"{}\" ({} copies)", g.title, g.notes.len());
324                    for n in g.notes {
325                        match n.modified_at {
326                            Some(m) => println!("  {}\t{m}", n.identifier),
327                            None => println!("  {}", n.identifier),
328                        }
329                    }
330                }
331            }
332        }
333        Commands::Stats(cmd) => {
334            command_log(1, format!("stats json={}", cmd.json));
335            let s = stats_summary(
336                &load_ck()?.list_notes(true, true, None)?,
337                &load_ck()?.list_tags()?,
338            );
339            let untagged = s.total_notes.saturating_sub(s.tagged_notes);
340            if cmd.json {
341                println!(
342                    "{}",
343                    serde_json::to_string_pretty(&serde_json::json!({
344                        "totalNotes": s.total_notes,
345                        "pinnedNotes": s.pinned_notes,
346                        "taggedNotes": s.tagged_notes,
347                        "untaggedNotes": untagged,
348                        "archivedNotes": s.archived_notes,
349                        "trashedNotes": s.trashed_notes,
350                        "uniqueTags": s.unique_tags,
351                        "totalWords": s.total_words,
352                        "notesWithTodos": s.notes_with_todos,
353                        "oldestModified": s.oldest_modified,
354                        "newestModified": s.newest_modified,
355                        "topTags": s.top_tags.iter().map(|(t, c)| serde_json::json!({"tag": t, "count": c})).collect::<Vec<_>>(),
356                    }))?
357                );
358            } else {
359                println!("Notes: {}", s.total_notes);
360                println!("Pinned: {}", s.pinned_notes);
361                println!("Tagged: {}", s.tagged_notes);
362                println!("Untagged: {untagged}");
363                println!("Archived: {}", s.archived_notes);
364                println!("Trashed: {}", s.trashed_notes);
365                println!("Tags: {}", s.unique_tags);
366                println!("Words: {}", s.total_words);
367                println!("Notes with TODOs: {}", s.notes_with_todos);
368                if let Some(oldest) = s.oldest_modified {
369                    println!("Oldest modified: {oldest}");
370                }
371                if let Some(newest) = s.newest_modified {
372                    println!("Newest modified: {newest}");
373                }
374                if !s.top_tags.is_empty() {
375                    println!("\nTop tags:");
376                    for (tag, count) in s.top_tags {
377                        println!("  #{tag}: {count}");
378                    }
379                }
380            }
381        }
382        Commands::Health(cmd) => {
383            command_log(1, format!("health json={}", cmd.json));
384            let s = health_summary(&load_ck()?.list_notes(true, true, None)?);
385            if cmd.json {
386                println!(
387                    "{}",
388                    serde_json::to_string_pretty(&serde_json::json!({
389                        "totalNotes": s.total_notes,
390                        "duplicateGroups": s.duplicate_groups,
391                        "duplicateNotes": s.duplicate_notes,
392                        "emptyNotes": s.empty_notes.iter().map(|n| serde_json::json!({"id": n.identifier, "title": n.title})).collect::<Vec<_>>(),
393                        "untaggedNotes": s.untagged_notes,
394                        "oldTrashedNotes": s.old_trashed_notes.iter().map(|n| serde_json::json!({"id": n.identifier, "title": n.title})).collect::<Vec<_>>(),
395                        "largeNotes": s.large_notes.iter().map(|n| serde_json::json!({"id": n.identifier, "title": n.title, "sizeBytes": n.size_bytes})).collect::<Vec<_>>(),
396                        "conflictNotes": s.conflict_notes.iter().map(|n| serde_json::json!({"id": n.identifier, "title": n.title})).collect::<Vec<_>>(),
397                    }))?
398                );
399            } else {
400                println!("Bear health report\n");
401                println!(
402                    "{} duplicate title group(s) covering {} note(s)",
403                    s.duplicate_groups, s.duplicate_notes
404                );
405                println!("{} empty note(s)", s.empty_notes.len());
406                println!("{} untagged note(s)", s.untagged_notes);
407                println!("{} old trashed note(s)", s.old_trashed_notes.len());
408                println!("{} large note(s)", s.large_notes.len());
409                println!("{} conflict-looking note(s)", s.conflict_notes.len());
410                println!("\n{} active note(s) checked", s.total_notes);
411            }
412        }
413        Commands::Untagged(cmd) => {
414            command_log(1, format!("untagged search={:?}", cmd.search));
415            for note in load_ck()?.list_notes(false, false, None)? {
416                if note.string_list_field("tagsStrings").is_empty()
417                    && note_matches_optional_search(&note, cmd.search.as_deref())
418                {
419                    println!(
420                        "{}\t{}",
421                        note.record_name,
422                        note.str_field("title").unwrap_or("")
423                    );
424                }
425            }
426        }
427        Commands::Todo(cmd) => {
428            command_log(1, format!("todo search={:?}", cmd.search));
429            for note in load_ck()?.list_notes(false, false, None)? {
430                if note.str_field("textADP").unwrap_or("").contains("- [ ]")
431                    && note_matches_optional_search(&note, cmd.search.as_deref())
432                {
433                    println!(
434                        "{}\t{}",
435                        note.record_name,
436                        note.str_field("title").unwrap_or("")
437                    );
438                }
439            }
440        }
441        Commands::Today(cmd) => {
442            command_log(1, format!("today search={:?}", cmd.search));
443            let start = parse_cloudkit_date_filter("today")?;
444            verbose::eprintln(2, format!("[runner] today threshold={start}"));
445            for note in load_ck()?.list_notes(false, false, None)? {
446                if note
447                    .i64_field("sf_modificationDate")
448                    .is_some_and(|v| v >= start)
449                    && note_matches_optional_search(&note, cmd.search.as_deref())
450                {
451                    println!(
452                        "{}\t{}",
453                        note.record_name,
454                        note.str_field("title").unwrap_or("")
455                    );
456                }
457            }
458        }
459        Commands::Locked(cmd) => {
460            command_log(1, format!("locked search={:?}", cmd.search));
461            for note in load_ck()?.list_notes(false, true, None)? {
462                if note.bool_field("locked").unwrap_or(false)
463                    && note_matches_optional_search(&note, cmd.search.as_deref())
464                {
465                    println!(
466                        "{}\t{}",
467                        note.record_name,
468                        note.str_field("title").unwrap_or("")
469                    );
470                }
471            }
472        }
473
474        Commands::Create(cmd) => {
475            command_log(1, format!("create tags={:?}", cmd.tag));
476            let text = read_text(cmd.text)?;
477            let ck = load_ck()?;
478            verbose::eprintln(
479                2,
480                format!(
481                    "[runner] create title={:?} body_len={}",
482                    extract_title(&text),
483                    text.len()
484                ),
485            );
486            let record = ck.create_note(&text, vec![], cmd.tag)?;
487            let title = extract_title(&text);
488            println!("Created: {} ({})", title, record.record_name);
489        }
490
491        Commands::AddText(cmd) => {
492            command_log(
493                1,
494                format!(
495                    "add-text mode={:?} id={:?} title={:?} header={:?}",
496                    cmd.mode, cmd.id, cmd.title, cmd.header
497                ),
498            );
499            let ck = load_ck()?;
500            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.title.as_deref(), &ck)?;
501            let new_text = read_text(cmd.text)?;
502
503            // Fetch current content
504            let note = ck.fetch_note(&record_name)?;
505            let current = note.str_field("textADP").unwrap_or("").to_string();
506            verbose::eprintln(
507                2,
508                format!(
509                    "[runner] add-text target={} current_len={} new_fragment_len={}",
510                    record_name,
511                    current.len(),
512                    new_text.len()
513                ),
514            );
515
516            let updated = match cmd.mode {
517                AddTextMode::ReplaceAll => new_text,
518                AddTextMode::Prepend => {
519                    if let Some(header) = cmd.header {
520                        insert_after_header(&current, &header, &new_text)
521                    } else {
522                        format!("{new_text}\n{current}")
523                    }
524                }
525                AddTextMode::Append => {
526                    if let Some(header) = cmd.header {
527                        insert_after_header(&current, &header, &new_text)
528                    } else {
529                        format!("{current}\n{new_text}")
530                    }
531                }
532            };
533
534            ck.update_note_text(&record_name, &updated)?;
535        }
536
537        Commands::AddFile(cmd) => {
538            command_log(
539                1,
540                format!(
541                    "add-file file={} id={:?} title={:?} mode={:?}",
542                    cmd.file.display(),
543                    cmd.id,
544                    cmd.title,
545                    cmd.mode
546                ),
547            );
548            let ck = load_ck()?;
549            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.title.as_deref(), &ck)?;
550            let filename = cmd
551                .filename
552                .or_else(|| {
553                    cmd.file
554                        .file_name()
555                        .map(|n| n.to_string_lossy().into_owned())
556                })
557                .ok_or_else(|| anyhow!("--filename required when file path has no name"))?;
558            let data = std::fs::read(&cmd.file)?;
559            verbose::eprintln(
560                2,
561                format!(
562                    "[runner] add-file target={} filename={} bytes={}",
563                    record_name,
564                    filename,
565                    data.len()
566                ),
567            );
568            let position = match cmd.mode {
569                AddFileMode::Append => AttachPosition::Append,
570                AddFileMode::Prepend => AttachPosition::Prepend,
571            };
572            ck.attach_file(&record_name, &filename, &data, position)?;
573            println!("Attached {filename} to {record_name}");
574        }
575
576        Commands::Trash(cmd) => {
577            command_log(1, format!("trash id={:?} title={:?}", cmd.id, cmd.search));
578            let ck = load_ck()?;
579            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.search.as_deref(), &ck)?;
580            load_ck()?.trash_note(&record_name)?;
581            println!("Trashed {record_name}");
582        }
583
584        Commands::Delete(cmd) => {
585            command_log(1, format!("delete id={:?} title={:?}", cmd.id, cmd.search));
586            let ck = load_ck()?;
587            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.search.as_deref(), &ck)?;
588            ck.delete_note(&record_name)?;
589            println!("Deleted {record_name}");
590        }
591
592        Commands::Archive(cmd) => {
593            command_log(1, format!("archive id={:?} title={:?}", cmd.id, cmd.search));
594            let ck = load_ck()?;
595            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.search.as_deref(), &ck)?;
596            load_ck()?.archive_note(&record_name)?;
597            println!("Archived {record_name}");
598        }
599
600        Commands::RenameTag(cmd) => {
601            command_log(
602                1,
603                format!("rename-tag old={} new={}", cmd.name, cmd.new_name),
604            );
605            let ck = load_ck()?;
606            let mut updated = 0usize;
607
608            for note in ck.list_notes(false, false, None)? {
609                if !note
610                    .string_list_field("tagsStrings")
611                    .iter()
612                    .any(|tag| tag == &cmd.name)
613                {
614                    continue;
615                }
616
617                let full_note = ck.fetch_note(&note.record_name)?;
618                verbose::eprintln(
619                    2,
620                    format!(
621                        "[runner] rename-tag rewriting note={}",
622                        full_note.record_name
623                    ),
624                );
625                let old_text = full_note.str_field("textADP").unwrap_or("");
626                let new_text = replace_tag_in_text(old_text, &cmd.name, &cmd.new_name);
627                let tag_names = rename_tag_names(&full_note, &cmd.name, &cmd.new_name);
628                let tag_uuids = ck.resolve_tag_record_names(&tag_names, true)?;
629                ck.update_note(
630                    &full_note.record_name,
631                    &new_text,
632                    Some(tag_uuids),
633                    Some(tag_names),
634                )?;
635                updated += 1;
636            }
637
638            if let Some(old_tag) = ck.find_tag_record_name(&cmd.name)? {
639                verbose::eprintln(
640                    2,
641                    format!(
642                        "[runner] rename-tag deleting old tag record={}",
643                        old_tag.record_name
644                    ),
645                );
646                ck.delete_tag(&old_tag.record_name)?;
647            }
648            println!(
649                "Renamed tag '{}' → '{}' in {} note(s)",
650                cmd.name, cmd.new_name, updated
651            );
652        }
653
654        Commands::DeleteTag(cmd) => {
655            command_log(1, format!("delete-tag name={}", cmd.name));
656            let ck = load_ck()?;
657            let mut updated = 0usize;
658
659            for note in ck.list_notes(false, false, None)? {
660                if !note
661                    .string_list_field("tagsStrings")
662                    .iter()
663                    .any(|tag| tag == &cmd.name)
664                {
665                    continue;
666                }
667
668                let full_note = ck.fetch_note(&note.record_name)?;
669                verbose::eprintln(
670                    2,
671                    format!(
672                        "[runner] delete-tag rewriting note={}",
673                        full_note.record_name
674                    ),
675                );
676                let old_text = full_note.str_field("textADP").unwrap_or("");
677                let new_text = remove_tag_from_text(old_text, &cmd.name);
678                let tag_names = remove_tag_names(&full_note, &cmd.name);
679                let tag_uuids = ck.resolve_tag_record_names(&tag_names, false)?;
680                ck.update_note(
681                    &full_note.record_name,
682                    &new_text,
683                    Some(tag_uuids),
684                    Some(tag_names),
685                )?;
686                updated += 1;
687            }
688
689            if let Some(tag) = ck.find_tag_record_name(&cmd.name)? {
690                verbose::eprintln(
691                    2,
692                    format!(
693                        "[runner] delete-tag deleting tag record={}",
694                        tag.record_name
695                    ),
696                );
697                ck.delete_tag(&tag.record_name)?;
698            }
699            println!("Deleted tag '{}' from {} note(s)", cmd.name, updated);
700        }
701    }
702
703    Ok(())
704}
705
706// ── Helpers ───────────────────────────────────────────────────────────────────
707
708fn load_ck() -> Result<CloudKitClient> {
709    verbose::eprintln(2, "[runner] loading CloudKit auth config");
710    let auth = AuthConfig::load()?;
711    CloudKitClient::new(auth)
712}
713
714fn resolve_note_id(id: Option<&str>, title: Option<&str>, ck: &CloudKitClient) -> Result<String> {
715    if let Some(id) = id {
716        verbose::eprintln(
717            2,
718            format!("[runner] resolved note directly from --id: {id}"),
719        );
720        return Ok(id.to_string());
721    }
722    if let Some(title) = title {
723        let note = resolve_note_by_title(title, ck)?;
724        verbose::eprintln(
725            1,
726            format!(
727                "[runner] resolved note title {:?} -> {}",
728                title, note.record_name
729            ),
730        );
731        return Ok(note.record_name);
732    }
733    bail!("provide --id or --title to identify the note")
734}
735
736fn resolve_note(
737    id: Option<&str>,
738    title: Option<&str>,
739    include_trashed: bool,
740    include_archived: bool,
741    ck: &CloudKitClient,
742) -> Result<CkRecord> {
743    if let Some(id) = id {
744        verbose::eprintln(2, format!("[runner] fetching note by id={id}"));
745        return ck.fetch_note(id);
746    }
747    if let Some(title) = title {
748        verbose::eprintln(
749            2,
750            format!(
751                "[runner] resolving note by title={title:?} include_trashed={} include_archived={}",
752                include_trashed, include_archived
753            ),
754        );
755        return resolve_note_by_title_with_flags(title, include_trashed, include_archived, ck);
756    }
757    bail!("provide --id or --title")
758}
759
760fn resolve_note_by_title(title: &str, ck: &CloudKitClient) -> Result<CkRecord> {
761    resolve_note_by_title_with_flags(title, false, true, ck)
762}
763
764fn resolve_note_by_title_with_flags(
765    title: &str,
766    include_trashed: bool,
767    include_archived: bool,
768    ck: &CloudKitClient,
769) -> Result<CkRecord> {
770    let matches = ck
771        .list_notes(include_trashed, include_archived, None)?
772        .into_iter()
773        .filter(|note| note.str_field("title") == Some(title))
774        .collect::<Vec<_>>();
775    verbose::eprintln(
776        1,
777        format!(
778            "[runner] title lookup {:?} matched {} note(s)",
779            title,
780            matches.len()
781        ),
782    );
783    matches
784        .into_iter()
785        .max_by_key(|note| note.i64_field("sf_modificationDate").unwrap_or(0))
786        .ok_or_else(|| anyhow!("note not found"))
787}
788
789fn command_log(level: u8, message: impl AsRef<str>) {
790    verbose::eprintln(level, format!("[runner] {}", message.as_ref()));
791}
792
793fn read_text(arg: Option<String>) -> Result<String> {
794    match arg {
795        Some(t) => Ok(t),
796        None => {
797            use std::io::Read;
798            let mut buf = String::new();
799            std::io::stdin().read_to_string(&mut buf)?;
800            Ok(buf)
801        }
802    }
803}
804
805/// Insert `new_text` after the first line that starts with `## <header>`.
806/// Falls back to appending if the header is not found.
807fn insert_after_header(content: &str, header: &str, new_text: &str) -> String {
808    let needle = format!("## {header}");
809    let mut result = String::with_capacity(content.len() + new_text.len() + 2);
810    let mut inserted = false;
811
812    for line in content.lines() {
813        result.push_str(line);
814        result.push('\n');
815        if !inserted && line.starts_with(&needle) {
816            result.push_str(new_text);
817            result.push('\n');
818            inserted = true;
819        }
820    }
821
822    if !inserted {
823        result.push_str(new_text);
824        result.push('\n');
825    }
826    result
827}
828
829fn split_csv(input: &str) -> Vec<String> {
830    input
831        .split(',')
832        .map(str::trim)
833        .filter(|s| !s.is_empty())
834        .map(ToOwned::to_owned)
835        .collect()
836}
837
838fn tag_marker(name: &str) -> String {
839    if name.contains(' ') {
840        format!("#{name}#")
841    } else {
842        format!("#{name}")
843    }
844}
845
846fn replace_tag_in_text(text: &str, old_name: &str, new_name: &str) -> String {
847    rewrite_tags(text, |tag| {
848        if tag == old_name {
849            Some(Some(new_name.to_string()))
850        } else {
851            None
852        }
853    })
854}
855
856fn remove_tag_from_text(text: &str, name: &str) -> String {
857    rewrite_tags(text, |tag| if tag == name { Some(None) } else { None })
858}
859
860fn rename_tag_names(note: &CkRecord, old_name: &str, new_name: &str) -> Vec<String> {
861    note.string_list_field("tagsStrings")
862        .into_iter()
863        .map(|tag| {
864            if tag == old_name {
865                new_name.to_string()
866            } else {
867                tag
868            }
869        })
870        .fold(Vec::new(), dedup_push)
871}
872
873fn remove_tag_names(note: &CkRecord, name: &str) -> Vec<String> {
874    note.string_list_field("tagsStrings")
875        .into_iter()
876        .filter(|tag| tag != name)
877        .collect()
878}
879
880fn dedup_push(mut values: Vec<String>, value: String) -> Vec<String> {
881    if !values.iter().any(|existing| existing == &value) {
882        values.push(value);
883    }
884    values
885}
886
887fn rewrite_tags<F>(text: &str, mut rewrite: F) -> String
888where
889    F: FnMut(&str) -> Option<Option<String>>,
890{
891    text.lines()
892        .filter_map(|line| {
893            let mut out = String::with_capacity(line.len());
894            let mut i = 0;
895
896            while i < line.len() {
897                let remainder = &line[i..];
898                if !remainder.starts_with('#') {
899                    let ch = remainder.chars().next().unwrap();
900                    out.push(ch);
901                    i += ch.len_utf8();
902                    continue;
903                }
904
905                if let Some((raw, name)) = parse_tag_at(remainder) {
906                    match rewrite(name) {
907                        Some(Some(replacement)) => out.push_str(&tag_marker(&replacement)),
908                        Some(None) => {}
909                        None => out.push_str(raw),
910                    }
911                    i += raw.len();
912                } else {
913                    out.push('#');
914                    i += 1;
915                }
916            }
917
918            let out = out.trim_end().to_string();
919            let trimmed = out.trim();
920            if trimmed.is_empty() && line.trim_start().starts_with('#') {
921                None
922            } else {
923                Some(out)
924            }
925        })
926        .collect::<Vec<_>>()
927        .join("\n")
928}
929
930fn parse_tag_at(input: &str) -> Option<(&str, &str)> {
931    let bytes = input.as_bytes();
932    if bytes.first().copied() != Some(b'#') {
933        return None;
934    }
935    if bytes
936        .get(1)
937        .copied()
938        .is_some_and(|b| b == b' ' || b == b'#')
939    {
940        return None;
941    }
942
943    if let Some(close_offset) = input[1..].find('#') {
944        let close = close_offset + 1;
945        let candidate = &input[1..close];
946        if candidate.contains(' ') && candidate == candidate.trim() && !candidate.is_empty() {
947            return Some((&input[..=close], candidate));
948        }
949    }
950
951    let end = input.find(char::is_whitespace).unwrap_or(input.len());
952    let candidate = &input[1..end];
953    if candidate.is_empty() {
954        None
955    } else {
956        Some((&input[..end], candidate))
957    }
958}
959
960fn note_matches_optional_search(note: &CkRecord, search: Option<&str>) -> bool {
961    let Some(search) = search.map(str::trim).filter(|s| !s.is_empty()) else {
962        return true;
963    };
964    let needle = search.to_lowercase();
965    note.str_field("title")
966        .unwrap_or("")
967        .to_lowercase()
968        .contains(&needle)
969        || note
970            .str_field("textADP")
971            .unwrap_or("")
972            .to_lowercase()
973            .contains(&needle)
974}
975
976fn search_notes(
977    notes: &[CkRecord],
978    term: Option<&str>,
979    tag: Option<&str>,
980    since: Option<i64>,
981    before: Option<i64>,
982) -> Vec<SearchResult> {
983    let term = term.unwrap_or_default().trim().to_lowercase();
984    let tag_filter = tag.map(str::trim).filter(|s| !s.is_empty());
985    let mut results = Vec::new();
986
987    for note in notes {
988        let modified_at = note.i64_field("sf_modificationDate");
989        if let Some(since) = since {
990            if modified_at.is_some_and(|v| v < since) {
991                continue;
992            }
993        }
994        if let Some(before) = before {
995            if modified_at.is_some_and(|v| v >= before) {
996                continue;
997            }
998        }
999
1000        let tags = note.string_list_field("tagsStrings");
1001        if let Some(tag_filter) = tag_filter {
1002            if !tags.iter().any(|candidate| candidate == tag_filter) {
1003                continue;
1004            }
1005        }
1006
1007        let title = note.str_field("title").unwrap_or("").to_string();
1008        let text = note.str_field("textADP").unwrap_or("").to_string();
1009        let title_lower = title.to_lowercase();
1010        let text_lower = text.to_lowercase();
1011        let title_match = !term.is_empty() && title_lower.contains(&term);
1012        let body_match = !term.is_empty() && text_lower.contains(&term);
1013        let tag_match = !term.is_empty() && tags.iter().any(|t| t.to_lowercase().contains(&term));
1014
1015        if !term.is_empty() && !title_match && !body_match && !tag_match {
1016            continue;
1017        }
1018
1019        let rank = if title_match {
1020            0
1021        } else if tag_match {
1022            1
1023        } else {
1024            2
1025        };
1026        results.push(SearchResult {
1027            identifier: note.record_name.clone(),
1028            title,
1029            snippet: if body_match {
1030                Some(make_snippet(&text, &text_lower, &term))
1031            } else {
1032                None
1033            },
1034            modified_at,
1035            rank,
1036        });
1037    }
1038
1039    results.sort_by(|left, right| {
1040        left.rank
1041            .cmp(&right.rank)
1042            .then_with(|| right.modified_at.cmp(&left.modified_at))
1043            .then_with(|| left.title.to_lowercase().cmp(&right.title.to_lowercase()))
1044            .then_with(|| left.identifier.cmp(&right.identifier))
1045    });
1046    results
1047}
1048
1049fn exportable_notes(notes: &[CkRecord], tag: Option<&str>) -> Vec<ExportNote> {
1050    let filter = tag.map(str::trim).filter(|s| !s.is_empty());
1051    let mut out = Vec::new();
1052    for note in notes {
1053        let tags = note.string_list_field("tagsStrings");
1054        if let Some(filter) = filter {
1055            if !tags.iter().any(|tag| tag == filter) {
1056                continue;
1057            }
1058        }
1059        out.push(ExportNote {
1060            identifier: note.record_name.clone(),
1061            title: note.str_field("title").unwrap_or("").to_string(),
1062            text: note.str_field("textADP").unwrap_or("").to_string(),
1063            pinned: note.bool_field("pinned").unwrap_or(false),
1064            created_at: note.i64_field("sf_creationDate"),
1065            modified_at: note.i64_field("sf_modificationDate"),
1066            tags,
1067        });
1068    }
1069    out
1070}
1071
1072fn duplicate_groups(notes: &[CkRecord]) -> Vec<DuplicateGroup> {
1073    let mut groups = std::collections::BTreeMap::<String, Vec<DuplicateNote>>::new();
1074    for note in notes {
1075        let title = note.str_field("title").unwrap_or("").trim().to_string();
1076        if title.is_empty() {
1077            continue;
1078        }
1079        groups.entry(title).or_default().push(DuplicateNote {
1080            identifier: note.record_name.clone(),
1081            modified_at: note.i64_field("sf_modificationDate").map(|v| v.to_string()),
1082        });
1083    }
1084    groups
1085        .into_iter()
1086        .filter_map(|(title, notes)| (notes.len() > 1).then_some(DuplicateGroup { title, notes }))
1087        .collect()
1088}
1089
1090fn stats_summary(notes: &[CkRecord], tags: &[CkRecord]) -> StatsSummary {
1091    let mut total_notes = 0usize;
1092    let mut pinned_notes = 0usize;
1093    let mut tagged_notes = 0usize;
1094    let mut archived_notes = 0usize;
1095    let mut trashed_notes = 0usize;
1096    let mut total_words = 0usize;
1097    let mut notes_with_todos = 0usize;
1098    let mut oldest_modified = None;
1099    let mut newest_modified = None;
1100    let mut tag_counts = std::collections::BTreeMap::<String, usize>::new();
1101
1102    for note in notes {
1103        if note.bool_field("trashed").unwrap_or(false) {
1104            trashed_notes += 1;
1105            continue;
1106        }
1107        total_notes += 1;
1108        if note.bool_field("pinned").unwrap_or(false) {
1109            pinned_notes += 1;
1110        }
1111        if note.bool_field("archived").unwrap_or(false) {
1112            archived_notes += 1;
1113        }
1114        let text = note.str_field("textADP").unwrap_or("");
1115        if text.contains("- [ ]") {
1116            notes_with_todos += 1;
1117        }
1118        total_words += text.split_whitespace().filter(|s| !s.is_empty()).count();
1119        let note_tags = note.string_list_field("tagsStrings");
1120        if !note_tags.is_empty() {
1121            tagged_notes += 1;
1122        }
1123        for tag in note_tags {
1124            *tag_counts.entry(tag).or_default() += 1;
1125        }
1126        if let Some(modified_at) = note.i64_field("sf_modificationDate") {
1127            oldest_modified =
1128                Some(oldest_modified.map_or(modified_at, |cur: i64| cur.min(modified_at)));
1129            newest_modified =
1130                Some(newest_modified.map_or(modified_at, |cur: i64| cur.max(modified_at)));
1131        }
1132    }
1133
1134    let mut top_tags = tag_counts.into_iter().collect::<Vec<_>>();
1135    top_tags.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1136    top_tags.truncate(10);
1137
1138    StatsSummary {
1139        total_notes,
1140        pinned_notes,
1141        tagged_notes,
1142        archived_notes,
1143        trashed_notes,
1144        unique_tags: tags.len(),
1145        total_words,
1146        notes_with_todos,
1147        oldest_modified,
1148        newest_modified,
1149        top_tags,
1150    }
1151}
1152
1153fn health_summary(notes: &[CkRecord]) -> HealthSummary {
1154    const LARGE_NOTE_THRESHOLD_BYTES: usize = 100_000;
1155    let duplicate_groups = duplicate_groups(notes);
1156    let old_trashed_cutoff = now_ms() - Duration::days(30).num_milliseconds();
1157
1158    let mut total_notes = 0usize;
1159    let mut empty_notes = Vec::new();
1160    let mut untagged_notes = 0usize;
1161    let mut old_trashed_notes = Vec::new();
1162    let mut large_notes = Vec::new();
1163    let mut conflict_notes = Vec::new();
1164
1165    for note in notes {
1166        let identifier = note.record_name.clone();
1167        let title = display_title(note);
1168        let text = note.str_field("textADP").unwrap_or("");
1169        let trashed = note.bool_field("trashed").unwrap_or(false);
1170
1171        if trashed {
1172            if note
1173                .i64_field("sf_modificationDate")
1174                .is_some_and(|v| v < old_trashed_cutoff)
1175            {
1176                old_trashed_notes.push(HealthNoteIssue { identifier, title });
1177            }
1178            continue;
1179        }
1180
1181        total_notes += 1;
1182        if text.trim().is_empty() {
1183            empty_notes.push(HealthNoteIssue {
1184                identifier: note.record_name.clone(),
1185                title: title.clone(),
1186            });
1187        }
1188        if note.string_list_field("tagsStrings").is_empty() {
1189            untagged_notes += 1;
1190        }
1191        if text.len() >= LARGE_NOTE_THRESHOLD_BYTES {
1192            large_notes.push(LargeNoteIssue {
1193                identifier: note.record_name.clone(),
1194                title: title.clone(),
1195                size_bytes: text.len(),
1196            });
1197        }
1198        if note
1199            .str_field("conflictUniqueIdentifier")
1200            .is_some_and(|v| !v.is_empty())
1201        {
1202            conflict_notes.push(HealthNoteIssue {
1203                identifier: note.record_name.clone(),
1204                title,
1205            });
1206        }
1207    }
1208
1209    let duplicate_note_count = duplicate_groups.iter().map(|g| g.notes.len()).sum();
1210    HealthSummary {
1211        total_notes,
1212        duplicate_groups: duplicate_groups.len(),
1213        duplicate_notes: duplicate_note_count,
1214        empty_notes,
1215        untagged_notes,
1216        old_trashed_notes,
1217        large_notes,
1218        conflict_notes,
1219    }
1220}
1221
1222fn display_title(note: &CkRecord) -> String {
1223    let title = note.str_field("title").unwrap_or("").trim();
1224    if title.is_empty() {
1225        "(untitled)".to_string()
1226    } else {
1227        title.to_string()
1228    }
1229}
1230
1231fn parse_cloudkit_date_filter(input: &str) -> Result<i64> {
1232    let seconds = parse_bear_date_filter(input)?;
1233    Ok((seconds + 978_307_200) * 1000)
1234}
1235
1236fn make_snippet(text: &str, text_lower: &str, term: &str) -> String {
1237    if term.is_empty() {
1238        return text.lines().next().unwrap_or("").trim().to_string();
1239    }
1240    if let Some(pos) = text_lower.find(term) {
1241        let start = pos.saturating_sub(40);
1242        let end = (pos + term.len() + 60).min(text.len());
1243        return text[start..end].replace('\n', " ").trim().to_string();
1244    }
1245    text.lines().next().unwrap_or("").trim().to_string()
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use crate::cloudkit::models::{CkField, CkRecord};
1252
1253    #[test]
1254    fn replace_tag_in_text_updates_plain_and_spaced_tags() {
1255        assert_eq!(
1256            replace_tag_in_text("# tag\n#old #old name#\nbody", "old", "new"),
1257            "# tag\n#new #old name#\nbody"
1258        );
1259        assert_eq!(
1260            replace_tag_in_text("#old name# and #old", "old name", "new name"),
1261            "#new name# and #old"
1262        );
1263    }
1264
1265    #[test]
1266    fn remove_tag_from_text_removes_standalone_and_inline_tags() {
1267        assert_eq!(
1268            remove_tag_from_text("# Title\n#keep #drop\nbody #drop", "drop"),
1269            "# Title\n#keep\nbody"
1270        );
1271    }
1272
1273    #[test]
1274    fn rename_tag_names_rewrites_and_dedups() {
1275        let mut note = CkRecord {
1276            record_name: "NOTE".into(),
1277            record_type: "SFNote".into(),
1278            zone_id: None,
1279            fields: std::collections::HashMap::new(),
1280            plugin_fields: std::collections::HashMap::new(),
1281            record_change_tag: None,
1282            created: None,
1283            modified: None,
1284            deleted: false,
1285            server_error_code: None,
1286            reason: None,
1287        };
1288        note.fields.insert(
1289            "tagsStrings".into(),
1290            CkField::string_list(vec!["old".into(), "keep".into(), "old".into()]),
1291        );
1292
1293        let names = rename_tag_names(&note, "old", "new");
1294        assert_eq!(names, vec!["new", "keep"]);
1295    }
1296}