1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use anyhow::{Context, Result, anyhow, bail};
5use reqwest::blocking::Client;
6use uuid::Uuid;
7
8use super::auth::AuthConfig;
9use super::models::*;
10use super::vector_clock;
11
12pub const API_TOKEN: &str = "ce59f955ec47e744f720aa1d2816a4e985e472d8b859b6c7a47b81fd36646307";
13const BASE_URL: &str =
14 "https://api.apple-cloudkit.com/database/1/iCloud.net.shinyfrog.bear/production/private";
15const DEVICE_NAME: &str = "Bear CLI";
16
17pub struct CloudKitClient {
18 http: Client,
19 auth: AuthConfig,
20}
21
22impl CloudKitClient {
23 pub fn new(auth: AuthConfig) -> Result<Self> {
24 let http = Client::builder()
25 .user_agent("bear-cli/0.3.0")
26 .build()
27 .context("failed to build HTTP client")?;
28 Ok(Self { http, auth })
29 }
30
31 fn url(&self, path: &str) -> String {
32 let token = self.auth.ck_web_auth_token.replace('+', "%2B");
34 let api = API_TOKEN.replace('+', "%2B");
35 format!("{BASE_URL}{path}?ckWebAuthToken={token}&ckAPIToken={api}")
36 }
37
38 fn post<Req, Res>(&self, path: &str, body: &Req) -> Result<Res>
39 where
40 Req: serde::Serialize,
41 Res: serde::de::DeserializeOwned,
42 {
43 let resp = self
44 .http
45 .post(self.url(path))
46 .header("Content-Type", "application/json")
47 .json(body)
48 .send()
49 .with_context(|| format!("HTTP POST {path} failed"))?;
50
51 let status = resp.status();
52 if !status.is_success() {
53 let body = resp.text().unwrap_or_default();
54 bail!("CloudKit {path} returned {status}: {body}");
55 }
56
57 resp.json::<Res>()
58 .with_context(|| format!("failed to parse response from {path}"))
59 }
60
61 pub fn modify(&self, ops: Vec<ModifyOperation>) -> Result<Vec<CkRecord>> {
62 let req = ModifyRequest {
63 operations: ops,
64 zone_id: ZoneId::default(),
65 };
66 let resp: ModifyResponse = self.post("/records/modify", &req)?;
67
68 for rec in &resp.records {
70 if let Some(code) = &rec.server_error_code {
71 bail!(
72 "CloudKit record error on {}: {} — {}",
73 rec.record_name,
74 code,
75 rec.reason.as_deref().unwrap_or("")
76 );
77 }
78 }
79 Ok(resp.records)
80 }
81
82 pub fn query(&self, req: QueryRequest) -> Result<QueryResponse> {
83 self.post("/records/query", &req)
84 }
85
86 pub fn list_notes(
87 &self,
88 include_trashed: bool,
89 include_archived: bool,
90 limit: Option<usize>,
91 ) -> Result<Vec<CkRecord>> {
92 let mut filters = Vec::new();
93 if !include_trashed {
94 filters.push(CkFilter {
95 field_name: "trashed".into(),
96 comparator: "EQUALS".into(),
97 field_value: CkFilterValue {
98 value: 0.into(),
99 kind: "INT64".into(),
100 },
101 });
102 }
103 if !include_archived {
104 filters.push(CkFilter {
105 field_name: "archived".into(),
106 comparator: "EQUALS".into(),
107 field_value: CkFilterValue {
108 value: 0.into(),
109 kind: "INT64".into(),
110 },
111 });
112 }
113
114 let mut records = Vec::new();
115 let mut continuation_marker = None;
116
117 loop {
118 let remaining = limit.map(|n| n.saturating_sub(records.len()));
119 if matches!(remaining, Some(0)) {
120 break;
121 }
122
123 let req = QueryRequest {
124 zone_id: ZoneId::default(),
125 query: CkQuery {
126 record_type: "SFNote".into(),
127 filter_by: filters.clone(),
128 sort_by: vec![CkSort {
129 field_name: "sf_modificationDate".into(),
130 ascending: false,
131 }],
132 },
133 results_limit: Some(remaining.unwrap_or(200).min(200)),
134 desired_keys: Some(vec![
135 "uniqueIdentifier".into(),
136 "title".into(),
137 "textADP".into(),
138 "subtitleADP".into(),
139 "sf_creationDate".into(),
140 "sf_modificationDate".into(),
141 "trashed".into(),
142 "archived".into(),
143 "pinned".into(),
144 "locked".into(),
145 "encrypted".into(),
146 "todoCompleted".into(),
147 "todoIncompleted".into(),
148 "tagsStrings".into(),
149 "conflictUniqueIdentifier".into(),
150 ]),
151 continuation_marker,
152 };
153
154 let resp = self.query(req)?;
155 records.extend(resp.records);
156 continuation_marker = resp.continuation_marker;
157
158 if continuation_marker.is_none() {
159 break;
160 }
161 }
162
163 Ok(records)
164 }
165
166 pub fn list_tags(&self) -> Result<Vec<CkRecord>> {
167 let mut records = Vec::new();
168 let mut marker = None;
169
170 loop {
171 let resp = self.query(QueryRequest {
172 zone_id: ZoneId::default(),
173 query: CkQuery {
174 record_type: "SFNoteTag".into(),
175 filter_by: vec![],
176 sort_by: vec![CkSort {
177 field_name: "name".into(),
178 ascending: true,
179 }],
180 },
181 results_limit: Some(500),
182 desired_keys: Some(vec!["name".into(), "sf_modificationDate".into()]),
183 continuation_marker: marker,
184 })?;
185 records.extend(resp.records);
186 marker = resp.continuation_marker;
187 if marker.is_none() {
188 break;
189 }
190 }
191
192 Ok(records)
193 }
194
195 pub fn lookup(&self, record_names: &[&str]) -> Result<Vec<CkRecord>> {
196 let req = LookupRequest {
197 records: record_names
198 .iter()
199 .map(|n| LookupRecord {
200 record_name: n.to_string(),
201 })
202 .collect(),
203 zone_id: ZoneId::default(),
204 };
205 let resp: LookupResponse = self.post("/records/lookup", &req)?;
206 Ok(resp.records)
207 }
208
209 pub fn fetch_note(&self, record_name: &str) -> Result<CkRecord> {
210 let records = self.lookup(&[record_name])?;
211 records
212 .into_iter()
213 .next()
214 .ok_or_else(|| anyhow!("note not found: {record_name}"))
215 }
216
217 pub fn upload_asset(
219 &self,
220 record_name: &str,
221 record_type: &str,
222 data: &[u8],
223 mime_type: &str,
224 ) -> Result<AssetReceipt> {
225 let req = AssetUploadRequest {
227 zone_id: ZoneId::default(),
228 tokens: vec![AssetToken {
229 record_type: record_type.to_string(),
230 record_name: record_name.to_string(),
231 field_name: "file".to_string(),
232 }],
233 };
234 let resp: AssetUploadResponse = self.post("/assets/upload", &req)?;
235 let token = resp
236 .tokens
237 .into_iter()
238 .next()
239 .ok_or_else(|| anyhow!("no upload token returned"))?;
240
241 let upload_resp = self
243 .http
244 .post(&token.url)
245 .header("Content-Type", mime_type)
246 .body(data.to_vec())
247 .send()
248 .context("asset upload POST failed")?;
249
250 let status = upload_resp.status();
251 if !status.is_success() {
252 let body = upload_resp.text().unwrap_or_default();
253 bail!("asset upload returned {status}: {body}");
254 }
255
256 let result: AssetUploadResult = upload_resp
257 .json()
258 .context("failed to parse upload receipt")?;
259 Ok(result.single_file)
260 }
261
262 pub fn create_note(
264 &self,
265 text: &str,
266 tag_uuids: Vec<String>,
267 tag_names: Vec<String>,
268 ) -> Result<CkRecord> {
269 let now_ms = now_ms();
270 let note_uuid = Uuid::new_v4().to_string().to_uppercase();
271 let title = extract_title(text);
272 let subtitle = extract_subtitle(text);
273 let clock = vector_clock::increment(None, DEVICE_NAME)?;
274
275 let mut fields: Fields = HashMap::new();
276 fields.insert("uniqueIdentifier".into(), CkField::string(¬e_uuid));
277 fields.insert("title".into(), CkField::string(&title));
278 fields.insert("subtitle".into(), CkField::string_null());
279 fields.insert("subtitleADP".into(), CkField::string_encrypted(&subtitle));
280 fields.insert("textADP".into(), CkField::string_encrypted(text));
281 fields.insert("text".into(), CkField::string_null());
282 fields.insert("tags".into(), CkField::string_list(tag_uuids));
283 fields.insert("tagsStrings".into(), CkField::string_list(tag_names));
284 fields.insert("files".into(), CkField::string_list(vec![]));
285 fields.insert("linkedBy".into(), CkField::string_list(vec![]));
286 fields.insert("linkingTo".into(), CkField::string_list(vec![]));
287 fields.insert("pinnedInTagsStrings".into(), CkField::string_list_null());
288 fields.insert("vectorClock".into(), CkField::bytes(&clock));
289 fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
290 fields.insert("version".into(), CkField::int64(3));
291 fields.insert("encrypted".into(), CkField::int64(0));
292 fields.insert("locked".into(), CkField::int64(0));
293 fields.insert("trashed".into(), CkField::int64(0));
294 fields.insert("archived".into(), CkField::int64(0));
295 fields.insert("pinned".into(), CkField::int64(0));
296 fields.insert("hasImages".into(), CkField::int64(0));
297 fields.insert("hasFiles".into(), CkField::int64(0));
298 fields.insert("hasSourceCode".into(), CkField::int64(0));
299 fields.insert("todoCompleted".into(), CkField::int64(0));
300 fields.insert("todoIncompleted".into(), CkField::int64(0));
301 fields.insert("sf_creationDate".into(), CkField::timestamp(now_ms));
302 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
303 fields.insert("trashedDate".into(), CkField::timestamp_null());
304 fields.insert("pinnedDate".into(), CkField::timestamp_null());
305 fields.insert("archivedDate".into(), CkField::timestamp_null());
306 fields.insert("lockedDate".into(), CkField::timestamp_null());
307 fields.insert("conflictUniqueIdentifier".into(), CkField::string_null());
308 fields.insert(
309 "conflictUniqueIdentifierDate".into(),
310 CkField::timestamp_null(),
311 );
312 fields.insert("encryptedData".into(), CkField::string_null());
313
314 let op = ModifyOperation {
315 operation_type: "create".into(),
316 record: CkRecord {
317 record_name: note_uuid.clone(),
318 record_type: "SFNote".into(),
319 fields,
320 record_change_tag: None,
321 deleted: false,
322 server_error_code: None,
323 reason: None,
324 },
325 };
326 let records = self.modify(vec![op])?;
327 records
328 .into_iter()
329 .next()
330 .ok_or_else(|| anyhow!("no record returned from create"))
331 }
332
333 pub fn update_note_text(&self, record_name: &str, new_text: &str) -> Result<CkRecord> {
336 let current = self.fetch_note(record_name)?;
337 let change_tag = current
338 .record_change_tag
339 .clone()
340 .ok_or_else(|| anyhow!("note {record_name} has no recordChangeTag"))?;
341 let existing_clock = current.str_field("vectorClock");
342 let clock = vector_clock::increment(existing_clock, DEVICE_NAME)?;
343
344 let title = extract_title(new_text);
345 let subtitle = extract_subtitle(new_text);
346 let todo_counts = count_todos(new_text);
347 let now_ms = now_ms();
348
349 let mut fields: Fields = HashMap::new();
350 fields.insert("textADP".into(), CkField::string_encrypted(new_text));
351 fields.insert("text".into(), CkField::string_null());
352 fields.insert("title".into(), CkField::string(&title));
353 fields.insert("subtitleADP".into(), CkField::string_encrypted(&subtitle));
354 fields.insert("subtitle".into(), CkField::string_null());
355 fields.insert("vectorClock".into(), CkField::bytes(&clock));
356 fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
357 fields.insert("version".into(), CkField::int64(3));
358 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms));
359 fields.insert("todoCompleted".into(), CkField::int64(todo_counts.0));
360 fields.insert("todoIncompleted".into(), CkField::int64(todo_counts.1));
361 fields.insert(
362 "uniqueIdentifier".into(),
363 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
364 );
365
366 let op = ModifyOperation {
367 operation_type: "update".into(),
368 record: CkRecord {
369 record_name: record_name.to_string(),
370 record_type: "SFNote".into(),
371 fields,
372 record_change_tag: Some(change_tag),
373 deleted: false,
374 server_error_code: None,
375 reason: None,
376 },
377 };
378 let records = self.modify(vec![op])?;
379 records
380 .into_iter()
381 .next()
382 .ok_or_else(|| anyhow!("no record returned from update"))
383 }
384
385 pub fn attach_file(
388 &self,
389 note_record_name: &str,
390 filename: &str,
391 data: &[u8],
392 position: AttachPosition,
393 ) -> Result<()> {
394 let ext = std::path::Path::new(filename)
396 .extension()
397 .and_then(|e| e.to_str())
398 .unwrap_or("")
399 .to_lowercase();
400 let is_image = matches!(
401 ext.as_str(),
402 "jpg" | "jpeg" | "png" | "gif" | "webp" | "heic" | "tiff"
403 );
404 let record_type = if is_image {
405 "SFNoteImage"
406 } else {
407 "SFNoteGenericFile"
408 };
409 let mime_type = mime_for_ext(&ext);
410
411 let file_record_uuid = Uuid::new_v4().to_string().to_uppercase();
413 let receipt = self.upload_asset(&file_record_uuid, record_type, data, &mime_type)?;
414 let file_size = receipt.size;
415
416 let note = self.fetch_note(note_record_name)?;
418 let change_tag = note
419 .record_change_tag
420 .clone()
421 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
422 let existing_clock = note.str_field("vectorClock");
423 let clock = vector_clock::increment(existing_clock, DEVICE_NAME)?;
424
425 let current_text = note.str_field("textADP").unwrap_or("").to_string();
427 let embed = if is_image {
428 format!("<!-- {{\"preview\":\"true\",\"embed\":\"true\"}} -->")
429 } else {
430 format!("[{filename}]({filename})<!-- {{\"preview\":\"true\",\"embed\":\"true\"}} -->")
431 };
432 let new_text = match position {
433 AttachPosition::Append => format!("{current_text}\n{embed}"),
434 AttachPosition::Prepend => {
435 let mut lines = current_text.lines();
437 let first = lines.next().unwrap_or("").to_string();
438 let rest: String = lines.collect::<Vec<_>>().join("\n");
439 if first.starts_with('#') {
440 format!("{first}\n{embed}\n{rest}")
441 } else {
442 format!("{embed}\n{current_text}")
443 }
444 }
445 };
446
447 let mut files_list: Vec<String> = note
449 .fields
450 .get("files")
451 .and_then(|f| f.value.as_array())
452 .map(|arr| {
453 arr.iter()
454 .filter_map(|v| v.as_str().map(str::to_string))
455 .collect()
456 })
457 .unwrap_or_default();
458 files_list.push(file_record_uuid.clone());
459
460 let has_images = note.i64_field("hasImages").unwrap_or(0) + if is_image { 1 } else { 0 };
461 let has_files = note.i64_field("hasFiles").unwrap_or(0) + if is_image { 0 } else { 1 };
462 let now_ms = now_ms();
463 let title = extract_title(&new_text);
464 let subtitle = extract_subtitle(&new_text);
465 let todo_counts = count_todos(&new_text);
466
467 let mut file_fields: Fields = std::collections::HashMap::new();
469 file_fields.insert(
470 "uniqueIdentifier".into(),
471 CkField::string(&file_record_uuid),
472 );
473 file_fields.insert("filenameADP".into(), CkField::string_encrypted(filename));
474 file_fields.insert("normalizedFileExtension".into(), CkField::string(&ext));
475 file_fields.insert("fileSize".into(), CkField::int64(file_size));
476 file_fields.insert("file".into(), CkField::asset_id(receipt));
477 file_fields.insert(
478 "noteUniqueIdentifier".into(),
479 CkField::string(
480 note.str_field("uniqueIdentifier")
481 .unwrap_or(note_record_name),
482 ),
483 );
484 file_fields.insert("index".into(), CkField::int64(0));
485 file_fields.insert("unused".into(), CkField::int64(0));
486 file_fields.insert("uploaded".into(), CkField::int64(1));
487 file_fields.insert("uploadedDate".into(), CkField::timestamp(now_ms));
488 file_fields.insert("insertionDate".into(), CkField::timestamp(now_ms));
489 file_fields.insert("encrypted".into(), CkField::int64(0));
490 file_fields.insert(
491 "animated".into(),
492 CkField::int64(if ext == "gif" { 1 } else { 0 }),
493 );
494 file_fields.insert("version".into(), CkField::int64(3));
495 file_fields.insert("sf_creationDate".into(), CkField::timestamp(now_ms));
496 file_fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
497
498 let mut note_fields: Fields = std::collections::HashMap::new();
500 note_fields.insert("textADP".into(), CkField::string_encrypted(&new_text));
501 note_fields.insert("text".into(), CkField::string_null());
502 note_fields.insert("title".into(), CkField::string(&title));
503 note_fields.insert("subtitleADP".into(), CkField::string_encrypted(&subtitle));
504 note_fields.insert("subtitle".into(), CkField::string_null());
505 note_fields.insert("files".into(), CkField::string_list(files_list));
506 note_fields.insert("hasImages".into(), CkField::int64(has_images));
507 note_fields.insert("hasFiles".into(), CkField::int64(has_files));
508 note_fields.insert("vectorClock".into(), CkField::bytes(&clock));
509 note_fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
510 note_fields.insert("version".into(), CkField::int64(3));
511 note_fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 2));
512 note_fields.insert("todoCompleted".into(), CkField::int64(todo_counts.0));
513 note_fields.insert("todoIncompleted".into(), CkField::int64(todo_counts.1));
514 note_fields.insert(
515 "uniqueIdentifier".into(),
516 CkField::string(
517 note.str_field("uniqueIdentifier")
518 .unwrap_or(note_record_name),
519 ),
520 );
521
522 self.modify(vec![
524 ModifyOperation {
525 operation_type: "create".into(),
526 record: CkRecord {
527 record_name: file_record_uuid,
528 record_type: record_type.to_string(),
529 fields: file_fields,
530 record_change_tag: None,
531 deleted: false,
532 server_error_code: None,
533 reason: None,
534 },
535 },
536 ModifyOperation {
537 operation_type: "update".into(),
538 record: CkRecord {
539 record_name: note_record_name.to_string(),
540 record_type: "SFNote".into(),
541 fields: note_fields,
542 record_change_tag: Some(change_tag),
543 deleted: false,
544 server_error_code: None,
545 reason: None,
546 },
547 },
548 ])?;
549
550 Ok(())
551 }
552
553 pub fn trash_note(&self, record_name: &str) -> Result<()> {
555 let current = self.fetch_note(record_name)?;
556 let change_tag = current
557 .record_change_tag
558 .clone()
559 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
560 let clock = vector_clock::increment(current.str_field("vectorClock"), DEVICE_NAME)?;
561 let now_ms = now_ms();
562
563 let mut fields: Fields = HashMap::new();
564 fields.insert("trashed".into(), CkField::int64(1));
565 fields.insert("trashedDate".into(), CkField::timestamp(now_ms));
566 fields.insert("vectorClock".into(), CkField::bytes(&clock));
567 fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
568 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
569 fields.insert(
570 "uniqueIdentifier".into(),
571 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
572 );
573
574 self.modify(vec![ModifyOperation {
575 operation_type: "update".into(),
576 record: CkRecord {
577 record_name: record_name.to_string(),
578 record_type: "SFNote".into(),
579 fields,
580 record_change_tag: Some(change_tag),
581 deleted: false,
582 server_error_code: None,
583 reason: None,
584 },
585 }])?;
586 Ok(())
587 }
588
589 pub fn archive_note(&self, record_name: &str) -> Result<()> {
591 let current = self.fetch_note(record_name)?;
592 let change_tag = current
593 .record_change_tag
594 .clone()
595 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
596 let clock = vector_clock::increment(current.str_field("vectorClock"), DEVICE_NAME)?;
597 let now_ms = now_ms();
598
599 let mut fields: Fields = HashMap::new();
600 fields.insert("archived".into(), CkField::int64(1));
601 fields.insert("archivedDate".into(), CkField::timestamp(now_ms));
602 fields.insert("vectorClock".into(), CkField::bytes(&clock));
603 fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
604 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
605 fields.insert(
606 "uniqueIdentifier".into(),
607 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
608 );
609
610 self.modify(vec![ModifyOperation {
611 operation_type: "update".into(),
612 record: CkRecord {
613 record_name: record_name.to_string(),
614 record_type: "SFNote".into(),
615 fields,
616 record_change_tag: Some(change_tag),
617 deleted: false,
618 server_error_code: None,
619 reason: None,
620 },
621 }])?;
622 Ok(())
623 }
624}
625
626pub enum AttachPosition {
627 Append,
628 Prepend,
629}
630
631pub fn now_ms() -> i64 {
632 SystemTime::now()
633 .duration_since(UNIX_EPOCH)
634 .map(|d| d.as_millis() as i64)
635 .unwrap_or(0)
636}
637
638pub fn extract_title(text: &str) -> String {
640 for line in text.lines() {
641 let t = line.trim();
642 if let Some(stripped) = t.strip_prefix("# ") {
643 return stripped.to_string();
644 }
645 if !t.is_empty() {
646 return t.to_string();
647 }
648 }
649 String::new()
650}
651
652pub fn extract_subtitle(text: &str) -> String {
654 let mut past_title = false;
655 for line in text.lines() {
656 let t = line.trim();
657 if !past_title {
658 past_title = !t.is_empty();
659 continue;
660 }
661 if !t.is_empty() {
662 return t.to_string();
663 }
664 }
665 String::new()
666}
667
668fn count_todos(text: &str) -> (i64, i64) {
669 let mut done = 0i64;
670 let mut todo = 0i64;
671 for line in text.lines() {
672 let t = line.trim();
673 if t.starts_with("- [x]") || t.starts_with("- [X]") {
674 done += 1;
675 } else if t.starts_with("- [ ]") {
676 todo += 1;
677 }
678 }
679 (done, todo)
680}
681
682fn mime_for_ext(ext: &str) -> String {
683 match ext {
684 "jpg" | "jpeg" => "image/jpeg",
685 "png" => "image/png",
686 "gif" => "image/gif",
687 "webp" => "image/webp",
688 "heic" => "image/heic",
689 "tiff" | "tif" => "image/tiff",
690 "pdf" => "application/pdf",
691 "txt" => "text/plain",
692 _ => "application/octet-stream",
693 }
694 .to_string()
695}