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};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13struct SearchResult {
14    identifier: String,
15    title: String,
16    snippet: Option<String>,
17    modified_at: Option<i64>,
18    rank: u8,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22struct DuplicateNote {
23    identifier: String,
24    modified_at: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28struct DuplicateGroup {
29    title: String,
30    notes: Vec<DuplicateNote>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34struct StatsSummary {
35    total_notes: usize,
36    pinned_notes: usize,
37    tagged_notes: usize,
38    archived_notes: usize,
39    trashed_notes: usize,
40    unique_tags: usize,
41    total_words: usize,
42    notes_with_todos: usize,
43    oldest_modified: Option<i64>,
44    newest_modified: Option<i64>,
45    top_tags: Vec<(String, usize)>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49struct HealthNoteIssue {
50    identifier: String,
51    title: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55struct LargeNoteIssue {
56    identifier: String,
57    title: String,
58    size_bytes: usize,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62struct HealthSummary {
63    total_notes: usize,
64    duplicate_groups: usize,
65    duplicate_notes: usize,
66    empty_notes: Vec<HealthNoteIssue>,
67    untagged_notes: usize,
68    old_trashed_notes: Vec<HealthNoteIssue>,
69    large_notes: Vec<LargeNoteIssue>,
70    conflict_notes: Vec<HealthNoteIssue>,
71}
72
73pub fn run() -> Result<()> {
74    let cli = Cli::parse();
75
76    match cli.command {
77        Commands::Auth(cmd) => {
78            let token = match cmd.token {
79                Some(t) => t,
80                None => crate::cloudkit::auth_server::acquire_token()?,
81            };
82            AuthConfig {
83                ck_web_auth_token: token,
84            }
85            .save()?;
86            println!("CloudKit auth token saved.");
87        }
88
89        // ── Read commands (CloudKit) ──────────────────────────────────────────
90        Commands::OpenNote(cmd) => {
91            let ck = load_ck()?;
92            let note = resolve_note(
93                cmd.id.as_deref(),
94                cmd.title.as_deref(),
95                !cmd.exclude_trashed,
96                true,
97                &ck,
98            )?;
99            println!("{}", note.str_field("textADP").unwrap_or(""));
100        }
101        Commands::Tags => {
102            for tag in load_ck()?.list_tags()? {
103                if let Some(name) = tag.str_field("name") {
104                    println!("{name}");
105                }
106            }
107        }
108        Commands::OpenTag(cmd) => {
109            let names = split_csv(&cmd.name);
110            for note in load_ck()?.list_notes(false, false, None)? {
111                let note_tags = note.string_list_field("tagsStrings");
112                if names
113                    .iter()
114                    .any(|name| note_tags.iter().any(|tag| tag == name))
115                {
116                    println!(
117                        "{}\t{}",
118                        note.record_name,
119                        note.str_field("title").unwrap_or("")
120                    );
121                }
122            }
123        }
124        Commands::Search(cmd) => {
125            let since = cmd
126                .since
127                .as_deref()
128                .map(parse_cloudkit_date_filter)
129                .transpose()?;
130            let before = cmd
131                .before
132                .as_deref()
133                .map(parse_cloudkit_date_filter)
134                .transpose()?;
135            let results = search_notes(
136                &load_ck()?.list_notes(false, false, None)?,
137                cmd.term.as_deref(),
138                cmd.tag.as_deref(),
139                since,
140                before,
141            );
142
143            if cmd.json {
144                let output = serde_json::json!({
145                    "results": results.iter().map(|note| serde_json::json!({
146                        "id": note.identifier,
147                        "title": note.title,
148                        "snippet": note.snippet,
149                        "modified": note.modified_at,
150                        "rank": note.rank,
151                    })).collect::<Vec<_>>()
152                });
153                println!("{}", serde_json::to_string_pretty(&output)?);
154            } else {
155                for note in results {
156                    println!("{}\t{}", note.identifier, note.title);
157                    if let Some(snippet) = note.snippet {
158                        println!("  {snippet}");
159                    }
160                }
161            }
162        }
163        Commands::Notes(cmd) => {
164            let notes = load_ck()?.list_notes(cmd.trashed, cmd.archived, cmd.limit)?;
165
166            if cmd.json {
167                println!(
168                    "{}",
169                    serde_json::to_string_pretty(&serde_json::json!({
170                        "notes": notes.iter().map(|note| serde_json::json!({
171                            "recordName": note.record_name,
172                            "id": note.str_field("uniqueIdentifier"),
173                            "title": note.str_field("title"),
174                            "subtitle": note.str_field("subtitleADP"),
175                            "created": note.i64_field("sf_creationDate"),
176                            "modified": note.i64_field("sf_modificationDate"),
177                            "trashed": note.i64_field("trashed").unwrap_or(0) != 0,
178                            "archived": note.i64_field("archived").unwrap_or(0) != 0,
179                            "pinned": note.i64_field("pinned").unwrap_or(0) != 0,
180                            "tags": note.fields.get("tagsStrings").and_then(|f| f.value.as_array()).map(|arr|
181                                arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>()
182                            ).unwrap_or_default(),
183                        })).collect::<Vec<_>>()
184                    }))?
185                );
186            } else {
187                for note in notes {
188                    let title = note.str_field("title").unwrap_or("");
189                    println!("{}\t{}", note.record_name, title);
190                }
191            }
192        }
193        Commands::Export(cmd) => {
194            let notes = exportable_notes(
195                &load_ck()?.list_notes(false, false, None)?,
196                cmd.tag.as_deref(),
197            );
198            let written = export_notes(&cmd.output, &notes, cmd.frontmatter, cmd.by_tag)?;
199            println!(
200                "Exported {} note(s) to {}",
201                written.len(),
202                cmd.output.display()
203            );
204        }
205        Commands::Duplicates(cmd) => {
206            let groups = duplicate_groups(&load_ck()?.list_notes(false, true, None)?);
207            if cmd.json {
208                let total = groups.iter().map(|g| g.notes.len()).sum::<usize>();
209                println!(
210                    "{}",
211                    serde_json::to_string_pretty(&serde_json::json!({
212                        "duplicateGroups": groups.len(),
213                        "totalDuplicateNotes": total,
214                        "groups": groups.iter().map(|g| serde_json::json!({
215                            "title": g.title,
216                            "count": g.notes.len(),
217                            "notes": g.notes.iter().map(|n| serde_json::json!({
218                                "id": n.identifier,
219                                "modified": n.modified_at,
220                            })).collect::<Vec<_>>()
221                        })).collect::<Vec<_>>()
222                    }))?
223                );
224            } else if groups.is_empty() {
225                println!("No duplicate titles found.");
226            } else {
227                for g in groups {
228                    println!("\"{}\" ({} copies)", g.title, g.notes.len());
229                    for n in g.notes {
230                        match n.modified_at {
231                            Some(m) => println!("  {}\t{m}", n.identifier),
232                            None => println!("  {}", n.identifier),
233                        }
234                    }
235                }
236            }
237        }
238        Commands::Stats(cmd) => {
239            let s = stats_summary(
240                &load_ck()?.list_notes(true, true, None)?,
241                &load_ck()?.list_tags()?,
242            );
243            let untagged = s.total_notes.saturating_sub(s.tagged_notes);
244            if cmd.json {
245                println!(
246                    "{}",
247                    serde_json::to_string_pretty(&serde_json::json!({
248                        "totalNotes": s.total_notes,
249                        "pinnedNotes": s.pinned_notes,
250                        "taggedNotes": s.tagged_notes,
251                        "untaggedNotes": untagged,
252                        "archivedNotes": s.archived_notes,
253                        "trashedNotes": s.trashed_notes,
254                        "uniqueTags": s.unique_tags,
255                        "totalWords": s.total_words,
256                        "notesWithTodos": s.notes_with_todos,
257                        "oldestModified": s.oldest_modified,
258                        "newestModified": s.newest_modified,
259                        "topTags": s.top_tags.iter().map(|(t, c)| serde_json::json!({"tag": t, "count": c})).collect::<Vec<_>>(),
260                    }))?
261                );
262            } else {
263                println!("Notes: {}", s.total_notes);
264                println!("Pinned: {}", s.pinned_notes);
265                println!("Tagged: {}", s.tagged_notes);
266                println!("Untagged: {untagged}");
267                println!("Archived: {}", s.archived_notes);
268                println!("Trashed: {}", s.trashed_notes);
269                println!("Tags: {}", s.unique_tags);
270                println!("Words: {}", s.total_words);
271                println!("Notes with TODOs: {}", s.notes_with_todos);
272                if let Some(oldest) = s.oldest_modified {
273                    println!("Oldest modified: {oldest}");
274                }
275                if let Some(newest) = s.newest_modified {
276                    println!("Newest modified: {newest}");
277                }
278                if !s.top_tags.is_empty() {
279                    println!("\nTop tags:");
280                    for (tag, count) in s.top_tags {
281                        println!("  #{tag}: {count}");
282                    }
283                }
284            }
285        }
286        Commands::Health(cmd) => {
287            let s = health_summary(&load_ck()?.list_notes(true, true, None)?);
288            if cmd.json {
289                println!(
290                    "{}",
291                    serde_json::to_string_pretty(&serde_json::json!({
292                        "totalNotes": s.total_notes,
293                        "duplicateGroups": s.duplicate_groups,
294                        "duplicateNotes": s.duplicate_notes,
295                        "emptyNotes": s.empty_notes.iter().map(|n| serde_json::json!({"id": n.identifier, "title": n.title})).collect::<Vec<_>>(),
296                        "untaggedNotes": s.untagged_notes,
297                        "oldTrashedNotes": s.old_trashed_notes.iter().map(|n| serde_json::json!({"id": n.identifier, "title": n.title})).collect::<Vec<_>>(),
298                        "largeNotes": s.large_notes.iter().map(|n| serde_json::json!({"id": n.identifier, "title": n.title, "sizeBytes": n.size_bytes})).collect::<Vec<_>>(),
299                        "conflictNotes": s.conflict_notes.iter().map(|n| serde_json::json!({"id": n.identifier, "title": n.title})).collect::<Vec<_>>(),
300                    }))?
301                );
302            } else {
303                println!("Bear health report\n");
304                println!(
305                    "{} duplicate title group(s) covering {} note(s)",
306                    s.duplicate_groups, s.duplicate_notes
307                );
308                println!("{} empty note(s)", s.empty_notes.len());
309                println!("{} untagged note(s)", s.untagged_notes);
310                println!("{} old trashed note(s)", s.old_trashed_notes.len());
311                println!("{} large note(s)", s.large_notes.len());
312                println!("{} conflict-looking note(s)", s.conflict_notes.len());
313                println!("\n{} active note(s) checked", s.total_notes);
314            }
315        }
316        Commands::Untagged(cmd) => {
317            for note in load_ck()?.list_notes(false, false, None)? {
318                if note.string_list_field("tagsStrings").is_empty()
319                    && note_matches_optional_search(&note, cmd.search.as_deref())
320                {
321                    println!(
322                        "{}\t{}",
323                        note.record_name,
324                        note.str_field("title").unwrap_or("")
325                    );
326                }
327            }
328        }
329        Commands::Todo(cmd) => {
330            for note in load_ck()?.list_notes(false, false, None)? {
331                if note.str_field("textADP").unwrap_or("").contains("- [ ]")
332                    && note_matches_optional_search(&note, cmd.search.as_deref())
333                {
334                    println!(
335                        "{}\t{}",
336                        note.record_name,
337                        note.str_field("title").unwrap_or("")
338                    );
339                }
340            }
341        }
342        Commands::Today(cmd) => {
343            let start = parse_cloudkit_date_filter("today")?;
344            for note in load_ck()?.list_notes(false, false, None)? {
345                if note
346                    .i64_field("sf_modificationDate")
347                    .is_some_and(|v| v >= start)
348                    && note_matches_optional_search(&note, cmd.search.as_deref())
349                {
350                    println!(
351                        "{}\t{}",
352                        note.record_name,
353                        note.str_field("title").unwrap_or("")
354                    );
355                }
356            }
357        }
358        Commands::Locked(cmd) => {
359            for note in load_ck()?.list_notes(false, true, None)? {
360                if note.bool_field("locked").unwrap_or(false)
361                    && note_matches_optional_search(&note, cmd.search.as_deref())
362                {
363                    println!(
364                        "{}\t{}",
365                        note.record_name,
366                        note.str_field("title").unwrap_or("")
367                    );
368                }
369            }
370        }
371
372        // ── Write commands (CloudKit) ─────────────────────────────────────────
373        Commands::Create(cmd) => {
374            let text = read_text(cmd.text)?;
375            let ck = load_ck()?;
376            let record = ck.create_note(&text, vec![], cmd.tag)?;
377            let title = extract_title(&text);
378            println!("Created: {} ({})", title, record.record_name);
379        }
380
381        Commands::AddText(cmd) => {
382            let ck = load_ck()?;
383            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.title.as_deref(), &ck)?;
384            let new_text = read_text(cmd.text)?;
385
386            // Fetch current content
387            let note = ck.fetch_note(&record_name)?;
388            let current = note.str_field("textADP").unwrap_or("").to_string();
389
390            let updated = match cmd.mode {
391                AddTextMode::ReplaceAll => new_text,
392                AddTextMode::Prepend => {
393                    if let Some(header) = cmd.header {
394                        insert_after_header(&current, &header, &new_text)
395                    } else {
396                        format!("{new_text}\n{current}")
397                    }
398                }
399                AddTextMode::Append => {
400                    if let Some(header) = cmd.header {
401                        insert_after_header(&current, &header, &new_text)
402                    } else {
403                        format!("{current}\n{new_text}")
404                    }
405                }
406            };
407
408            ck.update_note_text(&record_name, &updated)?;
409        }
410
411        Commands::AddFile(cmd) => {
412            let ck = load_ck()?;
413            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.title.as_deref(), &ck)?;
414            let filename = cmd
415                .filename
416                .or_else(|| {
417                    cmd.file
418                        .file_name()
419                        .map(|n| n.to_string_lossy().into_owned())
420                })
421                .ok_or_else(|| anyhow!("--filename required when file path has no name"))?;
422            let data = std::fs::read(&cmd.file)?;
423            let position = match cmd.mode {
424                AddFileMode::Append => AttachPosition::Append,
425                AddFileMode::Prepend => AttachPosition::Prepend,
426            };
427            ck.attach_file(&record_name, &filename, &data, position)?;
428            println!("Attached {filename} to {record_name}");
429        }
430
431        Commands::Trash(cmd) => {
432            let ck = load_ck()?;
433            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.search.as_deref(), &ck)?;
434            load_ck()?.trash_note(&record_name)?;
435            println!("Trashed {record_name}");
436        }
437
438        Commands::Archive(cmd) => {
439            let ck = load_ck()?;
440            let record_name = resolve_note_id(cmd.id.as_deref(), cmd.search.as_deref(), &ck)?;
441            load_ck()?.archive_note(&record_name)?;
442            println!("Archived {record_name}");
443        }
444
445        Commands::RenameTag(cmd) => {
446            let ck = load_ck()?;
447            let tag_uuid = resolve_tag_id(&cmd.name, &ck)?;
448            // Fetch the tag record and update its name field
449            let record = ck.fetch_note(&tag_uuid)?; // SFNoteTag uses same lookup
450            let change_tag = record
451                .record_change_tag
452                .clone()
453                .ok_or_else(|| anyhow!("tag record has no recordChangeTag"))?;
454            let mut fields = crate::cloudkit::models::Fields::new();
455            fields.insert(
456                "name".into(),
457                crate::cloudkit::models::CkField::string(&cmd.new_name),
458            );
459            fields.insert(
460                "sf_modificationDate".into(),
461                crate::cloudkit::models::CkField::timestamp(crate::cloudkit::client::now_ms()),
462            );
463            ck.modify(vec![crate::cloudkit::models::ModifyOperation {
464                operation_type: "update".into(),
465                record: crate::cloudkit::models::CkRecord {
466                    record_name: tag_uuid,
467                    record_type: "SFNoteTag".into(),
468                    fields,
469                    record_change_tag: Some(change_tag),
470                    deleted: false,
471                    server_error_code: None,
472                    reason: None,
473                },
474            }])?;
475            println!("Renamed tag '{}' → '{}'", cmd.name, cmd.new_name);
476        }
477
478        Commands::DeleteTag(cmd) => {
479            let ck = load_ck()?;
480            let tag_uuid = resolve_tag_id(&cmd.name, &ck)?;
481            let record = ck.fetch_note(&tag_uuid)?;
482            let change_tag = record
483                .record_change_tag
484                .clone()
485                .ok_or_else(|| anyhow!("tag record has no recordChangeTag"))?;
486            ck.modify(vec![crate::cloudkit::models::ModifyOperation {
487                operation_type: "delete".into(),
488                record: crate::cloudkit::models::CkRecord {
489                    record_name: tag_uuid,
490                    record_type: "SFNoteTag".into(),
491                    fields: std::collections::HashMap::new(),
492                    record_change_tag: Some(change_tag),
493                    deleted: true,
494                    server_error_code: None,
495                    reason: None,
496                },
497            }])?;
498            println!("Deleted tag '{}'", cmd.name);
499        }
500    }
501
502    Ok(())
503}
504
505// ── Helpers ───────────────────────────────────────────────────────────────────
506
507fn load_ck() -> Result<CloudKitClient> {
508    let auth = AuthConfig::load()?;
509    CloudKitClient::new(auth)
510}
511
512fn resolve_note_id(id: Option<&str>, title: Option<&str>, ck: &CloudKitClient) -> Result<String> {
513    if let Some(id) = id {
514        return Ok(id.to_string());
515    }
516    if let Some(title) = title {
517        return resolve_note_by_title(title, ck).map(|note| note.record_name);
518    }
519    bail!("provide --id or --title to identify the note")
520}
521
522fn resolve_note(
523    id: Option<&str>,
524    title: Option<&str>,
525    include_trashed: bool,
526    include_archived: bool,
527    ck: &CloudKitClient,
528) -> Result<CkRecord> {
529    if let Some(id) = id {
530        return ck.fetch_note(id);
531    }
532    if let Some(title) = title {
533        return resolve_note_by_title_with_flags(title, include_trashed, include_archived, ck);
534    }
535    bail!("provide --id or --title")
536}
537
538fn resolve_note_by_title(title: &str, ck: &CloudKitClient) -> Result<CkRecord> {
539    resolve_note_by_title_with_flags(title, false, true, ck)
540}
541
542fn resolve_note_by_title_with_flags(
543    title: &str,
544    include_trashed: bool,
545    include_archived: bool,
546    ck: &CloudKitClient,
547) -> Result<CkRecord> {
548    ck.list_notes(include_trashed, include_archived, None)?
549        .into_iter()
550        .filter(|note| note.str_field("title") == Some(title))
551        .max_by_key(|note| note.i64_field("sf_modificationDate").unwrap_or(0))
552        .ok_or_else(|| anyhow!("note not found"))
553}
554
555fn resolve_tag_id(name: &str, ck: &CloudKitClient) -> Result<String> {
556    ck.list_tags()?
557        .into_iter()
558        .find(|tag| tag.str_field("name") == Some(name))
559        .map(|tag| tag.record_name)
560        .ok_or_else(|| anyhow!("tag not found: {name}"))
561}
562
563fn read_text(arg: Option<String>) -> Result<String> {
564    match arg {
565        Some(t) => Ok(t),
566        None => {
567            use std::io::Read;
568            let mut buf = String::new();
569            std::io::stdin().read_to_string(&mut buf)?;
570            Ok(buf)
571        }
572    }
573}
574
575/// Insert `new_text` after the first line that starts with `## <header>`.
576/// Falls back to appending if the header is not found.
577fn insert_after_header(content: &str, header: &str, new_text: &str) -> String {
578    let needle = format!("## {header}");
579    let mut result = String::with_capacity(content.len() + new_text.len() + 2);
580    let mut inserted = false;
581
582    for line in content.lines() {
583        result.push_str(line);
584        result.push('\n');
585        if !inserted && line.starts_with(&needle) {
586            result.push_str(new_text);
587            result.push('\n');
588            inserted = true;
589        }
590    }
591
592    if !inserted {
593        result.push_str(new_text);
594        result.push('\n');
595    }
596    result
597}
598
599fn split_csv(input: &str) -> Vec<String> {
600    input
601        .split(',')
602        .map(str::trim)
603        .filter(|s| !s.is_empty())
604        .map(ToOwned::to_owned)
605        .collect()
606}
607
608fn note_matches_optional_search(note: &CkRecord, search: Option<&str>) -> bool {
609    let Some(search) = search.map(str::trim).filter(|s| !s.is_empty()) else {
610        return true;
611    };
612    let needle = search.to_lowercase();
613    note.str_field("title")
614        .unwrap_or("")
615        .to_lowercase()
616        .contains(&needle)
617        || note
618            .str_field("textADP")
619            .unwrap_or("")
620            .to_lowercase()
621            .contains(&needle)
622}
623
624fn search_notes(
625    notes: &[CkRecord],
626    term: Option<&str>,
627    tag: Option<&str>,
628    since: Option<i64>,
629    before: Option<i64>,
630) -> Vec<SearchResult> {
631    let term = term.unwrap_or_default().trim().to_lowercase();
632    let tag_filter = tag.map(str::trim).filter(|s| !s.is_empty());
633    let mut results = Vec::new();
634
635    for note in notes {
636        let modified_at = note.i64_field("sf_modificationDate");
637        if let Some(since) = since {
638            if modified_at.is_some_and(|v| v < since) {
639                continue;
640            }
641        }
642        if let Some(before) = before {
643            if modified_at.is_some_and(|v| v >= before) {
644                continue;
645            }
646        }
647
648        let tags = note.string_list_field("tagsStrings");
649        if let Some(tag_filter) = tag_filter {
650            if !tags.iter().any(|candidate| candidate == tag_filter) {
651                continue;
652            }
653        }
654
655        let title = note.str_field("title").unwrap_or("").to_string();
656        let text = note.str_field("textADP").unwrap_or("").to_string();
657        let title_lower = title.to_lowercase();
658        let text_lower = text.to_lowercase();
659        let title_match = !term.is_empty() && title_lower.contains(&term);
660        let body_match = !term.is_empty() && text_lower.contains(&term);
661        let tag_match = !term.is_empty() && tags.iter().any(|t| t.to_lowercase().contains(&term));
662
663        if !term.is_empty() && !title_match && !body_match && !tag_match {
664            continue;
665        }
666
667        let rank = if title_match {
668            0
669        } else if tag_match {
670            1
671        } else {
672            2
673        };
674        results.push(SearchResult {
675            identifier: note.record_name.clone(),
676            title,
677            snippet: if body_match {
678                Some(make_snippet(&text, &text_lower, &term))
679            } else {
680                None
681            },
682            modified_at,
683            rank,
684        });
685    }
686
687    results.sort_by(|left, right| {
688        left.rank
689            .cmp(&right.rank)
690            .then_with(|| right.modified_at.cmp(&left.modified_at))
691            .then_with(|| left.title.to_lowercase().cmp(&right.title.to_lowercase()))
692            .then_with(|| left.identifier.cmp(&right.identifier))
693    });
694    results
695}
696
697fn exportable_notes(notes: &[CkRecord], tag: Option<&str>) -> Vec<ExportNote> {
698    let filter = tag.map(str::trim).filter(|s| !s.is_empty());
699    let mut out = Vec::new();
700    for note in notes {
701        let tags = note.string_list_field("tagsStrings");
702        if let Some(filter) = filter {
703            if !tags.iter().any(|tag| tag == filter) {
704                continue;
705            }
706        }
707        out.push(ExportNote {
708            identifier: note.record_name.clone(),
709            title: note.str_field("title").unwrap_or("").to_string(),
710            text: note.str_field("textADP").unwrap_or("").to_string(),
711            pinned: note.bool_field("pinned").unwrap_or(false),
712            created_at: note.i64_field("sf_creationDate"),
713            modified_at: note.i64_field("sf_modificationDate"),
714            tags,
715        });
716    }
717    out
718}
719
720fn duplicate_groups(notes: &[CkRecord]) -> Vec<DuplicateGroup> {
721    let mut groups = std::collections::BTreeMap::<String, Vec<DuplicateNote>>::new();
722    for note in notes {
723        let title = note.str_field("title").unwrap_or("").trim().to_string();
724        if title.is_empty() {
725            continue;
726        }
727        groups.entry(title).or_default().push(DuplicateNote {
728            identifier: note.record_name.clone(),
729            modified_at: note.i64_field("sf_modificationDate").map(|v| v.to_string()),
730        });
731    }
732    groups
733        .into_iter()
734        .filter_map(|(title, notes)| (notes.len() > 1).then_some(DuplicateGroup { title, notes }))
735        .collect()
736}
737
738fn stats_summary(notes: &[CkRecord], tags: &[CkRecord]) -> StatsSummary {
739    let mut total_notes = 0usize;
740    let mut pinned_notes = 0usize;
741    let mut tagged_notes = 0usize;
742    let mut archived_notes = 0usize;
743    let mut trashed_notes = 0usize;
744    let mut total_words = 0usize;
745    let mut notes_with_todos = 0usize;
746    let mut oldest_modified = None;
747    let mut newest_modified = None;
748    let mut tag_counts = std::collections::BTreeMap::<String, usize>::new();
749
750    for note in notes {
751        if note.bool_field("trashed").unwrap_or(false) {
752            trashed_notes += 1;
753            continue;
754        }
755        total_notes += 1;
756        if note.bool_field("pinned").unwrap_or(false) {
757            pinned_notes += 1;
758        }
759        if note.bool_field("archived").unwrap_or(false) {
760            archived_notes += 1;
761        }
762        let text = note.str_field("textADP").unwrap_or("");
763        if text.contains("- [ ]") {
764            notes_with_todos += 1;
765        }
766        total_words += text.split_whitespace().filter(|s| !s.is_empty()).count();
767        let note_tags = note.string_list_field("tagsStrings");
768        if !note_tags.is_empty() {
769            tagged_notes += 1;
770        }
771        for tag in note_tags {
772            *tag_counts.entry(tag).or_default() += 1;
773        }
774        if let Some(modified_at) = note.i64_field("sf_modificationDate") {
775            oldest_modified =
776                Some(oldest_modified.map_or(modified_at, |cur: i64| cur.min(modified_at)));
777            newest_modified =
778                Some(newest_modified.map_or(modified_at, |cur: i64| cur.max(modified_at)));
779        }
780    }
781
782    let mut top_tags = tag_counts.into_iter().collect::<Vec<_>>();
783    top_tags.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
784    top_tags.truncate(10);
785
786    StatsSummary {
787        total_notes,
788        pinned_notes,
789        tagged_notes,
790        archived_notes,
791        trashed_notes,
792        unique_tags: tags.len(),
793        total_words,
794        notes_with_todos,
795        oldest_modified,
796        newest_modified,
797        top_tags,
798    }
799}
800
801fn health_summary(notes: &[CkRecord]) -> HealthSummary {
802    const LARGE_NOTE_THRESHOLD_BYTES: usize = 100_000;
803    let duplicate_groups = duplicate_groups(notes);
804    let old_trashed_cutoff = now_ms() - Duration::days(30).num_milliseconds();
805
806    let mut total_notes = 0usize;
807    let mut empty_notes = Vec::new();
808    let mut untagged_notes = 0usize;
809    let mut old_trashed_notes = Vec::new();
810    let mut large_notes = Vec::new();
811    let mut conflict_notes = Vec::new();
812
813    for note in notes {
814        let identifier = note.record_name.clone();
815        let title = display_title(note);
816        let text = note.str_field("textADP").unwrap_or("");
817        let trashed = note.bool_field("trashed").unwrap_or(false);
818
819        if trashed {
820            if note
821                .i64_field("sf_modificationDate")
822                .is_some_and(|v| v < old_trashed_cutoff)
823            {
824                old_trashed_notes.push(HealthNoteIssue { identifier, title });
825            }
826            continue;
827        }
828
829        total_notes += 1;
830        if text.trim().is_empty() {
831            empty_notes.push(HealthNoteIssue {
832                identifier: note.record_name.clone(),
833                title: title.clone(),
834            });
835        }
836        if note.string_list_field("tagsStrings").is_empty() {
837            untagged_notes += 1;
838        }
839        if text.len() >= LARGE_NOTE_THRESHOLD_BYTES {
840            large_notes.push(LargeNoteIssue {
841                identifier: note.record_name.clone(),
842                title: title.clone(),
843                size_bytes: text.len(),
844            });
845        }
846        if note
847            .str_field("conflictUniqueIdentifier")
848            .is_some_and(|v| !v.is_empty())
849        {
850            conflict_notes.push(HealthNoteIssue {
851                identifier: note.record_name.clone(),
852                title,
853            });
854        }
855    }
856
857    let duplicate_note_count = duplicate_groups.iter().map(|g| g.notes.len()).sum();
858    HealthSummary {
859        total_notes,
860        duplicate_groups: duplicate_groups.len(),
861        duplicate_notes: duplicate_note_count,
862        empty_notes,
863        untagged_notes,
864        old_trashed_notes,
865        large_notes,
866        conflict_notes,
867    }
868}
869
870fn display_title(note: &CkRecord) -> String {
871    let title = note.str_field("title").unwrap_or("").trim();
872    if title.is_empty() {
873        "(untitled)".to_string()
874    } else {
875        title.to_string()
876    }
877}
878
879fn parse_cloudkit_date_filter(input: &str) -> Result<i64> {
880    let seconds = parse_bear_date_filter(input)?;
881    Ok((seconds + 978_307_200) * 1000)
882}
883
884fn make_snippet(text: &str, text_lower: &str, term: &str) -> String {
885    if term.is_empty() {
886        return text.lines().next().unwrap_or("").trim().to_string();
887    }
888    if let Some(pos) = text_lower.find(term) {
889        let start = pos.saturating_sub(40);
890        let end = (pos + term.len() + 60).min(text.len());
891        return text[start..end].replace('\n', " ").trim().to_string();
892    }
893    text.lines().next().unwrap_or("").trim().to_string()
894}