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(¬e)?);
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(¬es)?;
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, ¬es, 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(¬e, 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(¬e, 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(¬e, 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(¬e, 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 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(¤t, &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(¤t, &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(¬e.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(¬e.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
706fn 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
805fn 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(¬e, "old", "new");
1294 assert_eq!(names, vec!["new", "keep"]);
1295 }
1296}