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 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, ¬es, 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(¬e, 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(¬e, 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(¬e, 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(¬e, 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 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 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(¤t, &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(¤t, &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 let record = ck.fetch_note(&tag_uuid)?; 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
505fn 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
575fn 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}