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, &vector_clock::local_device_id())?;
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("subtitleADP".into(), CkField::string_encrypted(&subtitle));
279 fields.insert("textADP".into(), CkField::string_encrypted(text));
280 fields.insert("tags".into(), CkField::string_list(tag_uuids));
281 fields.insert("tagsStrings".into(), CkField::string_list(tag_names));
282 fields.insert("files".into(), CkField::string_list(vec![]));
283 fields.insert("linkedBy".into(), CkField::string_list(vec![]));
284 fields.insert("linkingTo".into(), CkField::string_list(vec![]));
285 fields.insert("pinnedInTagsStrings".into(), CkField::string_list(vec![]));
286 fields.insert("vectorClock".into(), CkField::bytes(&clock));
287 fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
288 fields.insert("version".into(), CkField::int64(3));
289 fields.insert("encrypted".into(), CkField::int64(0));
290 fields.insert("locked".into(), CkField::int64(0));
291 fields.insert("trashed".into(), CkField::int64(0));
292 fields.insert("archived".into(), CkField::int64(0));
293 fields.insert("pinned".into(), CkField::int64(0));
294 fields.insert("hasImages".into(), CkField::int64(0));
295 fields.insert("hasFiles".into(), CkField::int64(0));
296 fields.insert("hasSourceCode".into(), CkField::int64(0));
297 fields.insert("todoCompleted".into(), CkField::int64(0));
298 fields.insert("todoIncompleted".into(), CkField::int64(0));
299 fields.insert("sf_creationDate".into(), CkField::timestamp(now_ms));
300 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
301
302 let op = ModifyOperation {
303 operation_type: "create".into(),
304 record: CkRecord {
305 record_name: note_uuid.clone(),
306 record_type: "SFNote".into(),
307 fields,
308 record_change_tag: None,
309 deleted: false,
310 server_error_code: None,
311 reason: None,
312 },
313 };
314 let records = self.modify(vec![op])?;
315 records
316 .into_iter()
317 .next()
318 .ok_or_else(|| anyhow!("no record returned from create"))
319 }
320
321 pub fn update_note_text(&self, record_name: &str, new_text: &str) -> Result<CkRecord> {
324 let current = self.fetch_note(record_name)?;
325 let change_tag = current
326 .record_change_tag
327 .clone()
328 .ok_or_else(|| anyhow!("note {record_name} has no recordChangeTag"))?;
329 let existing_clock = current.str_field("vectorClock");
330 let clock = vector_clock::increment(existing_clock, &vector_clock::local_device_id())?;
331
332 let title = extract_title(new_text);
333 let subtitle = extract_subtitle(new_text);
334 let todo_counts = count_todos(new_text);
335 let now_ms = now_ms();
336
337 let mut fields: Fields = HashMap::new();
338 fields.insert("textADP".into(), CkField::string_encrypted(new_text));
339 fields.insert("text".into(), CkField::string_null());
340 fields.insert("title".into(), CkField::string(&title));
341 fields.insert("subtitleADP".into(), CkField::string_encrypted(&subtitle));
342 fields.insert("subtitle".into(), CkField::string_null());
343 fields.insert("vectorClock".into(), CkField::bytes(&clock));
344 fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
345 fields.insert("version".into(), CkField::int64(3));
346 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms));
347 fields.insert("todoCompleted".into(), CkField::int64(todo_counts.0));
348 fields.insert("todoIncompleted".into(), CkField::int64(todo_counts.1));
349 fields.insert(
350 "uniqueIdentifier".into(),
351 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
352 );
353
354 let op = ModifyOperation {
355 operation_type: "update".into(),
356 record: CkRecord {
357 record_name: record_name.to_string(),
358 record_type: "SFNote".into(),
359 fields,
360 record_change_tag: Some(change_tag),
361 deleted: false,
362 server_error_code: None,
363 reason: None,
364 },
365 };
366 let records = self.modify(vec![op])?;
367 records
368 .into_iter()
369 .next()
370 .ok_or_else(|| anyhow!("no record returned from update"))
371 }
372
373 pub fn attach_file(
376 &self,
377 note_record_name: &str,
378 filename: &str,
379 data: &[u8],
380 position: AttachPosition,
381 ) -> Result<()> {
382 let ext = std::path::Path::new(filename)
384 .extension()
385 .and_then(|e| e.to_str())
386 .unwrap_or("")
387 .to_lowercase();
388 let is_image = matches!(
389 ext.as_str(),
390 "jpg" | "jpeg" | "png" | "gif" | "webp" | "heic" | "tiff"
391 );
392 let record_type = if is_image {
393 "SFNoteImage"
394 } else {
395 "SFNoteGenericFile"
396 };
397 let mime_type = mime_for_ext(&ext);
398
399 let file_record_uuid = Uuid::new_v4().to_string().to_uppercase();
401 let receipt = self.upload_asset(&file_record_uuid, record_type, data, &mime_type)?;
402 let file_size = receipt.size;
403
404 let note = self.fetch_note(note_record_name)?;
406 let change_tag = note
407 .record_change_tag
408 .clone()
409 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
410 let existing_clock = note.str_field("vectorClock");
411 let clock = vector_clock::increment(existing_clock, &vector_clock::local_device_id())?;
412
413 let current_text = note.str_field("textADP").unwrap_or("").to_string();
415 let embed = if is_image {
416 format!("<!-- {{\"preview\":\"true\",\"embed\":\"true\"}} -->")
417 } else {
418 format!("[{filename}]({filename})<!-- {{\"preview\":\"true\",\"embed\":\"true\"}} -->")
419 };
420 let new_text = match position {
421 AttachPosition::Append => format!("{current_text}\n{embed}"),
422 AttachPosition::Prepend => {
423 let mut lines = current_text.lines();
425 let first = lines.next().unwrap_or("").to_string();
426 let rest: String = lines.collect::<Vec<_>>().join("\n");
427 if first.starts_with('#') {
428 format!("{first}\n{embed}\n{rest}")
429 } else {
430 format!("{embed}\n{current_text}")
431 }
432 }
433 };
434
435 let mut files_list: Vec<String> = note
437 .fields
438 .get("files")
439 .and_then(|f| f.value.as_array())
440 .map(|arr| {
441 arr.iter()
442 .filter_map(|v| v.as_str().map(str::to_string))
443 .collect()
444 })
445 .unwrap_or_default();
446 files_list.push(file_record_uuid.clone());
447
448 let has_images = note.i64_field("hasImages").unwrap_or(0) + if is_image { 1 } else { 0 };
449 let has_files = note.i64_field("hasFiles").unwrap_or(0) + if is_image { 0 } else { 1 };
450 let now_ms = now_ms();
451 let title = extract_title(&new_text);
452 let subtitle = extract_subtitle(&new_text);
453 let todo_counts = count_todos(&new_text);
454
455 let mut file_fields: Fields = std::collections::HashMap::new();
457 file_fields.insert(
458 "uniqueIdentifier".into(),
459 CkField::string(&file_record_uuid),
460 );
461 file_fields.insert("filenameADP".into(), CkField::string_encrypted(filename));
462 file_fields.insert("normalizedFileExtension".into(), CkField::string(&ext));
463 file_fields.insert("fileSize".into(), CkField::int64(file_size));
464 file_fields.insert("file".into(), CkField::asset_id(receipt));
465 file_fields.insert(
466 "noteUniqueIdentifier".into(),
467 CkField::string(
468 note.str_field("uniqueIdentifier")
469 .unwrap_or(note_record_name),
470 ),
471 );
472 file_fields.insert("index".into(), CkField::int64(0));
473 file_fields.insert("unused".into(), CkField::int64(0));
474 file_fields.insert("uploaded".into(), CkField::int64(1));
475 file_fields.insert("uploadedDate".into(), CkField::timestamp(now_ms));
476 file_fields.insert("insertionDate".into(), CkField::timestamp(now_ms));
477 file_fields.insert("encrypted".into(), CkField::int64(0));
478 file_fields.insert(
479 "animated".into(),
480 CkField::int64(if ext == "gif" { 1 } else { 0 }),
481 );
482 file_fields.insert("version".into(), CkField::int64(3));
483 file_fields.insert("sf_creationDate".into(), CkField::timestamp(now_ms));
484 file_fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
485
486 let mut note_fields: Fields = std::collections::HashMap::new();
488 note_fields.insert("textADP".into(), CkField::string_encrypted(&new_text));
489 note_fields.insert("text".into(), CkField::string_null());
490 note_fields.insert("title".into(), CkField::string(&title));
491 note_fields.insert("subtitleADP".into(), CkField::string_encrypted(&subtitle));
492 note_fields.insert("subtitle".into(), CkField::string_null());
493 note_fields.insert("files".into(), CkField::string_list(files_list));
494 note_fields.insert("hasImages".into(), CkField::int64(has_images));
495 note_fields.insert("hasFiles".into(), CkField::int64(has_files));
496 note_fields.insert("vectorClock".into(), CkField::bytes(&clock));
497 note_fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
498 note_fields.insert("version".into(), CkField::int64(3));
499 note_fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 2));
500 note_fields.insert("todoCompleted".into(), CkField::int64(todo_counts.0));
501 note_fields.insert("todoIncompleted".into(), CkField::int64(todo_counts.1));
502 note_fields.insert(
503 "uniqueIdentifier".into(),
504 CkField::string(
505 note.str_field("uniqueIdentifier")
506 .unwrap_or(note_record_name),
507 ),
508 );
509
510 self.modify(vec![
512 ModifyOperation {
513 operation_type: "create".into(),
514 record: CkRecord {
515 record_name: file_record_uuid,
516 record_type: record_type.to_string(),
517 fields: file_fields,
518 record_change_tag: None,
519 deleted: false,
520 server_error_code: None,
521 reason: None,
522 },
523 },
524 ModifyOperation {
525 operation_type: "update".into(),
526 record: CkRecord {
527 record_name: note_record_name.to_string(),
528 record_type: "SFNote".into(),
529 fields: note_fields,
530 record_change_tag: Some(change_tag),
531 deleted: false,
532 server_error_code: None,
533 reason: None,
534 },
535 },
536 ])?;
537
538 Ok(())
539 }
540
541 pub fn trash_note(&self, record_name: &str) -> Result<()> {
543 let current = self.fetch_note(record_name)?;
544 let change_tag = current
545 .record_change_tag
546 .clone()
547 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
548 let clock = vector_clock::increment(
549 current.str_field("vectorClock"),
550 &vector_clock::local_device_id(),
551 )?;
552 let now_ms = now_ms();
553
554 let mut fields: Fields = HashMap::new();
555 fields.insert("trashed".into(), CkField::int64(1));
556 fields.insert("trashedDate".into(), CkField::timestamp(now_ms));
557 fields.insert("vectorClock".into(), CkField::bytes(&clock));
558 fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
559 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
560 fields.insert(
561 "uniqueIdentifier".into(),
562 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
563 );
564
565 self.modify(vec![ModifyOperation {
566 operation_type: "update".into(),
567 record: CkRecord {
568 record_name: record_name.to_string(),
569 record_type: "SFNote".into(),
570 fields,
571 record_change_tag: Some(change_tag),
572 deleted: false,
573 server_error_code: None,
574 reason: None,
575 },
576 }])?;
577 Ok(())
578 }
579
580 pub fn archive_note(&self, record_name: &str) -> Result<()> {
582 let current = self.fetch_note(record_name)?;
583 let change_tag = current
584 .record_change_tag
585 .clone()
586 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
587 let clock = vector_clock::increment(
588 current.str_field("vectorClock"),
589 &vector_clock::local_device_id(),
590 )?;
591 let now_ms = now_ms();
592
593 let mut fields: Fields = HashMap::new();
594 fields.insert("archived".into(), CkField::int64(1));
595 fields.insert("archivedDate".into(), CkField::timestamp(now_ms));
596 fields.insert("vectorClock".into(), CkField::bytes(&clock));
597 fields.insert("lastEditingDevice".into(), CkField::string(DEVICE_NAME));
598 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
599 fields.insert(
600 "uniqueIdentifier".into(),
601 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
602 );
603
604 self.modify(vec![ModifyOperation {
605 operation_type: "update".into(),
606 record: CkRecord {
607 record_name: record_name.to_string(),
608 record_type: "SFNote".into(),
609 fields,
610 record_change_tag: Some(change_tag),
611 deleted: false,
612 server_error_code: None,
613 reason: None,
614 },
615 }])?;
616 Ok(())
617 }
618}
619
620pub enum AttachPosition {
621 Append,
622 Prepend,
623}
624
625pub fn now_ms() -> i64 {
626 SystemTime::now()
627 .duration_since(UNIX_EPOCH)
628 .map(|d| d.as_millis() as i64)
629 .unwrap_or(0)
630}
631
632pub fn extract_title(text: &str) -> String {
634 for line in text.lines() {
635 let t = line.trim();
636 if let Some(stripped) = t.strip_prefix("# ") {
637 return stripped.to_string();
638 }
639 if !t.is_empty() {
640 return t.to_string();
641 }
642 }
643 String::new()
644}
645
646pub fn extract_subtitle(text: &str) -> String {
648 let mut past_title = false;
649 for line in text.lines() {
650 let t = line.trim();
651 if !past_title {
652 past_title = !t.is_empty();
653 continue;
654 }
655 if !t.is_empty() {
656 return t.to_string();
657 }
658 }
659 String::new()
660}
661
662fn count_todos(text: &str) -> (i64, i64) {
663 let mut done = 0i64;
664 let mut todo = 0i64;
665 for line in text.lines() {
666 let t = line.trim();
667 if t.starts_with("- [x]") || t.starts_with("- [X]") {
668 done += 1;
669 } else if t.starts_with("- [ ]") {
670 todo += 1;
671 }
672 }
673 (done, todo)
674}
675
676fn mime_for_ext(ext: &str) -> String {
677 match ext {
678 "jpg" | "jpeg" => "image/jpeg",
679 "png" => "image/png",
680 "gif" => "image/gif",
681 "webp" => "image/webp",
682 "heic" => "image/heic",
683 "tiff" | "tif" => "image/tiff",
684 "pdf" => "application/pdf",
685 "txt" => "text/plain",
686 _ => "application/octet-stream",
687 }
688 .to_string()
689}