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;
11use crate::verbose;
12
13pub const API_TOKEN: &str = "ce59f955ec47e744f720aa1d2816a4e985e472d8b859b6c7a47b81fd36646307";
14const BASE_URL: &str =
15 "https://api.apple-cloudkit.com/database/1/iCloud.net.shinyfrog.bear/production/private";
16pub struct CloudKitClient {
17 http: Client,
18 auth: AuthConfig,
19}
20
21impl CloudKitClient {
22 pub fn new(auth: AuthConfig) -> Result<Self> {
23 let http = Client::builder()
24 .user_agent("bear-cli/0.3.0")
25 .build()
26 .context("failed to build HTTP client")?;
27 Ok(Self { http, auth })
28 }
29
30 fn device_name(&self) -> &'static str {
31 "Bear CLI"
32 }
33
34 fn vector_clock_device(&self) -> &'static str {
35 "Bear CLI"
36 }
37
38 fn url(&self, path: &str) -> String {
39 let token = self.auth.ck_web_auth_token.replace('+', "%2B");
40 let api_token = API_TOKEN.replace('+', "%2B");
41 format!("{BASE_URL}{path}?ckWebAuthToken={token}&ckAPIToken={api_token}")
42 }
43
44 fn post<Req, Res>(&self, path: &str, body: &Req) -> Result<Res>
45 where
46 Req: serde::Serialize,
47 Res: serde::de::DeserializeOwned,
48 {
49 let url = self.url(path);
50 if verbose::enabled(1) {
51 verbose::eprintln(1, format!("[cloudkit] POST {path}"));
52 }
53 if verbose::enabled(2) {
54 let body_json = serde_json::to_string_pretty(body)
55 .unwrap_or_else(|_| "<failed to serialize request body>".to_string());
56 verbose::eprintln(2, format!("[cloudkit] url: {}", redact_cloudkit_url(&url)));
57 verbose::eprintln(2, format!("[cloudkit] request body:\n{body_json}"));
58 }
59 let resp = self
60 .http
61 .post(url)
62 .header("Content-Type", "application/json")
63 .json(body)
64 .send()
65 .with_context(|| format!("HTTP POST {path} failed"))?;
66
67 let status = resp.status();
68 if verbose::enabled(1) {
69 verbose::eprintln(1, format!("[cloudkit] {path} -> {status}"));
70 }
71 if !status.is_success() {
72 let body = resp.text().unwrap_or_default();
73 if verbose::enabled(2) {
74 verbose::eprintln(2, format!("[cloudkit] error body:\n{body}"));
75 }
76 bail!("CloudKit {path} returned {status}: {body}");
77 }
78 let text = resp
79 .text()
80 .with_context(|| format!("failed reading response from {path}"))?;
81 if verbose::enabled(2) {
82 verbose::eprintln(2, format!("[cloudkit] response body:\n{text}"));
83 }
84 serde_json::from_str::<Res>(&text)
85 .with_context(|| format!("failed to parse response from {path}"))
86 }
87
88 pub fn modify(&self, ops: Vec<ModifyOperation>) -> Result<Vec<CkRecord>> {
89 self.modify_in_zone(ZoneId::notes(), ops)
90 }
91
92 pub fn modify_in_zone(
93 &self,
94 zone_id: ZoneId,
95 ops: Vec<ModifyOperation>,
96 ) -> Result<Vec<CkRecord>> {
97 if verbose::enabled(1) {
98 let summary = ops
99 .iter()
100 .map(|op| format!("{}:{}", op.operation_type, op.record_type))
101 .collect::<Vec<_>>()
102 .join(", ");
103 verbose::eprintln(
104 1,
105 format!(
106 "[cloudkit] modify zone={} ops={} [{}]",
107 zone_id.zone_name,
108 ops.len(),
109 summary
110 ),
111 );
112 }
113 let req = ModifyRequest {
114 operations: ops,
115 zone_id,
116 };
117 let resp: ModifyResponse = self.post("/records/modify", &req)?;
118
119 for rec in &resp.records {
121 if let Some(code) = &rec.server_error_code {
122 bail!(
123 "CloudKit record error on {}: {} — {}",
124 rec.record_name,
125 code,
126 rec.reason.as_deref().unwrap_or("")
127 );
128 }
129 }
130 Ok(resp.records)
131 }
132
133 pub fn query(&self, req: QueryRequest) -> Result<QueryResponse> {
134 self.post("/records/query", &req)
135 }
136
137 pub fn list_notes(
138 &self,
139 include_trashed: bool,
140 include_archived: bool,
141 limit: Option<usize>,
142 ) -> Result<Vec<CkRecord>> {
143 verbose::eprintln(
144 1,
145 format!(
146 "[cloudkit] list_notes include_trashed={} include_archived={} limit={limit:?}",
147 include_trashed, include_archived
148 ),
149 );
150 self.list_notes_in_zone(ZoneId::notes(), include_trashed, include_archived, limit)
151 }
152
153 pub fn list_phantom_notes(&self, limit: Option<usize>) -> Result<Vec<CkRecord>> {
154 Ok(self
155 .list_notes_in_zone(ZoneId::default_zone(), true, true, limit)?
156 .into_iter()
157 .filter(|record| {
158 record
159 .zone_id
160 .as_ref()
161 .is_some_and(|zone| zone.zone_name == "_defaultZone")
162 })
163 .collect())
164 }
165
166 fn list_notes_in_zone(
167 &self,
168 zone_id: ZoneId,
169 include_trashed: bool,
170 include_archived: bool,
171 limit: Option<usize>,
172 ) -> Result<Vec<CkRecord>> {
173 let mut filters = Vec::new();
174 if !include_trashed {
175 filters.push(CkFilter {
176 field_name: "trashed".into(),
177 comparator: "EQUALS".into(),
178 field_value: CkFilterValue {
179 value: 0.into(),
180 kind: "INT64".into(),
181 },
182 });
183 }
184 if !include_archived {
185 filters.push(CkFilter {
186 field_name: "archived".into(),
187 comparator: "EQUALS".into(),
188 field_value: CkFilterValue {
189 value: 0.into(),
190 kind: "INT64".into(),
191 },
192 });
193 }
194
195 let mut records = Vec::new();
196 let mut continuation_marker = None;
197 let mut page = 0usize;
198
199 loop {
200 page += 1;
201 let remaining = limit.map(|n| n.saturating_sub(records.len()));
202 if matches!(remaining, Some(0)) {
203 break;
204 }
205 verbose::eprintln(
206 2,
207 format!(
208 "[cloudkit] list_notes page={} zone={} remaining={remaining:?}",
209 page, zone_id.zone_name
210 ),
211 );
212
213 let req = QueryRequest {
214 zone_id: zone_id.clone(),
215 query: CkQuery {
216 record_type: "SFNote".into(),
217 filter_by: filters.clone(),
218 sort_by: vec![CkSort {
219 field_name: "sf_modificationDate".into(),
220 ascending: false,
221 }],
222 },
223 results_limit: Some(remaining.unwrap_or(200).min(200)),
224 desired_keys: Some(vec![
225 "uniqueIdentifier".into(),
226 "title".into(),
227 "textADP".into(),
228 "subtitleADP".into(),
229 "sf_creationDate".into(),
230 "sf_modificationDate".into(),
231 "trashed".into(),
232 "archived".into(),
233 "pinned".into(),
234 "locked".into(),
235 "encrypted".into(),
236 "todoCompleted".into(),
237 "todoIncompleted".into(),
238 "tagsStrings".into(),
239 "conflictUniqueIdentifier".into(),
240 ]),
241 continuation_marker,
242 };
243
244 let resp = self.query(req)?;
245 verbose::eprintln(
246 1,
247 format!(
248 "[cloudkit] list_notes page={} returned {} record(s)",
249 page,
250 resp.records.len()
251 ),
252 );
253 records.extend(resp.records);
254 continuation_marker = resp.continuation_marker;
255
256 if continuation_marker.is_none() {
257 break;
258 }
259 }
260
261 Ok(records)
262 }
263
264 pub fn delete_phantom_notes(&self, records: &[CkRecord]) -> Result<Vec<CkRecord>> {
265 let ops = records
266 .iter()
267 .map(|record| {
268 let change_tag = record.record_change_tag.clone().ok_or_else(|| {
269 anyhow!("phantom note {} has no recordChangeTag", record.record_name)
270 })?;
271 Ok(ModifyOperation {
272 operation_type: "delete".into(),
273 record_type: "SFNote".into(),
274 record: CkRecord {
275 record_name: record.record_name.clone(),
276 record_type: "SFNote".into(),
277 zone_id: None,
278 fields: HashMap::new(),
279 plugin_fields: HashMap::new(),
280 record_change_tag: Some(change_tag),
281 created: record.created.clone(),
282 modified: record.modified.clone(),
283 deleted: true,
284 server_error_code: None,
285 reason: None,
286 },
287 })
288 })
289 .collect::<Result<Vec<_>>>()?;
290 self.modify_in_zone(ZoneId::default_zone(), ops)
291 }
292
293 pub fn list_tags(&self) -> Result<Vec<CkRecord>> {
294 verbose::eprintln(1, "[cloudkit] list_tags");
295 let mut records = Vec::new();
296 let mut marker = None;
297 let mut page = 0usize;
298
299 loop {
300 page += 1;
301 let resp = self.query(QueryRequest {
302 zone_id: ZoneId::default(),
303 query: CkQuery {
304 record_type: "SFNoteTag".into(),
305 filter_by: vec![],
306 sort_by: vec![CkSort {
307 field_name: "title".into(),
308 ascending: true,
309 }],
310 },
311 results_limit: Some(500),
312 desired_keys: Some(vec!["title".into(), "sf_modificationDate".into()]),
313 continuation_marker: marker,
314 })?;
315 verbose::eprintln(
316 1,
317 format!(
318 "[cloudkit] list_tags page={} returned {} record(s)",
319 page,
320 resp.records.len()
321 ),
322 );
323 records.extend(resp.records);
324 marker = resp.continuation_marker;
325 if marker.is_none() {
326 break;
327 }
328 }
329
330 Ok(records)
331 }
332
333 pub fn lookup(&self, record_names: &[&str]) -> Result<Vec<CkRecord>> {
334 let req = LookupRequest {
335 records: record_names
336 .iter()
337 .map(|name| LookupRecord {
338 record_name: (*name).to_string(),
339 })
340 .collect(),
341 zone_id: ZoneId::default(),
342 };
343 let resp: LookupResponse = self.post("/records/lookup", &req)?;
344 Ok(resp.records)
345 }
346
347 pub fn fetch_note(&self, record_name: &str) -> Result<CkRecord> {
349 verbose::eprintln(1, format!("[cloudkit] fetch_note record={record_name}"));
350 self.lookup(&[record_name])?
351 .into_iter()
352 .next()
353 .ok_or_else(|| anyhow!("note not found: {record_name}"))
354 }
355
356 pub fn fetch_note_by_title(
357 &self,
358 title: &str,
359 include_trashed: bool,
360 include_archived: bool,
361 ) -> Result<CkRecord> {
362 verbose::eprintln(
363 1,
364 format!(
365 "[cloudkit] fetch_note_by_title title={title:?} include_trashed={} include_archived={}",
366 include_trashed, include_archived
367 ),
368 );
369 let mut filter_by = vec![CkFilter {
370 field_name: "title".into(),
371 comparator: "EQUALS".into(),
372 field_value: CkFilterValue {
373 value: title.to_string().into(),
374 kind: "STRING".into(),
375 },
376 }];
377 if !include_trashed {
378 filter_by.push(CkFilter {
379 field_name: "trashed".into(),
380 comparator: "EQUALS".into(),
381 field_value: CkFilterValue {
382 value: 0.into(),
383 kind: "INT64".into(),
384 },
385 });
386 }
387 if !include_archived {
388 filter_by.push(CkFilter {
389 field_name: "archived".into(),
390 comparator: "EQUALS".into(),
391 field_value: CkFilterValue {
392 value: 0.into(),
393 kind: "INT64".into(),
394 },
395 });
396 }
397
398 let resp = self.query(QueryRequest {
399 zone_id: ZoneId::notes(),
400 query: CkQuery {
401 record_type: "SFNote".into(),
402 filter_by,
403 sort_by: vec![CkSort {
404 field_name: "sf_modificationDate".into(),
405 ascending: false,
406 }],
407 },
408 results_limit: Some(1),
409 desired_keys: None,
410 continuation_marker: None,
411 })?;
412
413 resp.records
414 .into_iter()
415 .next()
416 .ok_or_else(|| anyhow!("note not found: {title}"))
417 }
418
419 pub fn fetch_tag(&self, record_name: &str) -> Result<CkRecord> {
421 verbose::eprintln(1, format!("[cloudkit] fetch_tag record={record_name}"));
422 let resp = self.query(QueryRequest {
423 zone_id: ZoneId::default(),
424 query: CkQuery {
425 record_type: "SFNoteTag".into(),
426 filter_by: vec![CkFilter {
427 field_name: "uniqueIdentifier".into(),
428 comparator: "EQUALS".into(),
429 field_value: CkFilterValue {
430 value: record_name.to_string().into(),
431 kind: "STRING".into(),
432 },
433 }],
434 sort_by: vec![],
435 },
436 results_limit: Some(1),
437 desired_keys: None,
438 continuation_marker: None,
439 })?;
440 resp.records
441 .into_iter()
442 .next()
443 .ok_or_else(|| anyhow!("tag not found: {record_name}"))
444 }
445
446 pub fn upload_asset(
448 &self,
449 record_name: &str,
450 record_type: &str,
451 data: &[u8],
452 mime_type: &str,
453 ) -> Result<AssetReceipt> {
454 verbose::eprintln(
455 1,
456 format!(
457 "[cloudkit] upload_asset record={} type={} bytes={} mime={}",
458 record_name,
459 record_type,
460 data.len(),
461 mime_type
462 ),
463 );
464 let req = AssetUploadRequest {
466 zone_id: ZoneId::default(),
467 tokens: vec![AssetToken {
468 record_type: record_type.to_string(),
469 record_name: record_name.to_string(),
470 field_name: "file".to_string(),
471 }],
472 };
473 let resp: AssetUploadResponse = self.post("/assets/upload", &req)?;
474 let token = resp
475 .tokens
476 .into_iter()
477 .next()
478 .ok_or_else(|| anyhow!("no upload token returned"))?;
479
480 let upload_resp = self
482 .http
483 .post(&token.url)
484 .header("Content-Type", mime_type)
485 .body(data.to_vec())
486 .send()
487 .context("asset upload POST failed")?;
488
489 let status = upload_resp.status();
490 verbose::eprintln(1, format!("[cloudkit] asset upload -> {status}"));
491 if !status.is_success() {
492 let body = upload_resp.text().unwrap_or_default();
493 bail!("asset upload returned {status}: {body}");
494 }
495
496 let result: AssetUploadResult = upload_resp
497 .json()
498 .context("failed to parse upload receipt")?;
499 Ok(result.single_file)
500 }
501
502 pub fn create_note(
504 &self,
505 text: &str,
506 mut tag_uuids: Vec<String>,
507 tag_names: Vec<String>,
508 ) -> Result<CkRecord> {
509 let title = extract_title(text);
510 verbose::eprintln(
511 1,
512 format!(
513 "[cloudkit] create_note title={:?} tag_names={:?}",
514 title, tag_names
515 ),
516 );
517 let device_name = self.device_name();
518 let now_ms = now_ms();
519 let note_uuid = Uuid::new_v4().to_string().to_uppercase();
520 let subtitle = extract_subtitle(text);
521 let clock = vector_clock::increment(None, self.vector_clock_device())?;
522 if !tag_names.is_empty() && tag_uuids.len() != tag_names.len() {
523 tag_uuids = self.resolve_tag_record_names(&tag_names, true)?;
524 }
525
526 let mut fields: Fields = HashMap::new();
527 fields.insert("uniqueIdentifier".into(), CkField::string(¬e_uuid));
528 fields.insert("title".into(), CkField::string(&title));
529 fields.insert("subtitle".into(), CkField::string_null());
530 fields.insert("subtitleADP".into(), CkField::string_encrypted(&subtitle));
531 fields.insert("textADP".into(), CkField::string_encrypted(text));
532 fields.insert("text".into(), CkField::string_null());
533 fields.insert("tags".into(), CkField::string_list(tag_uuids));
534 fields.insert("tagsStrings".into(), CkField::string_list(tag_names));
535 fields.insert("files".into(), CkField::string_list(vec![]));
536 fields.insert("linkedBy".into(), CkField::string_list(vec![]));
537 fields.insert("linkingTo".into(), CkField::string_list(vec![]));
538 fields.insert("pinnedInTagsStrings".into(), CkField::string_list_null());
539 fields.insert("vectorClock".into(), CkField::bytes(&clock));
540 fields.insert("lastEditingDevice".into(), CkField::string(device_name));
541 fields.insert("version".into(), CkField::int64(3));
542 fields.insert("encrypted".into(), CkField::int64(0));
543 fields.insert("locked".into(), CkField::int64(0));
544 fields.insert("trashed".into(), CkField::int64(0));
545 fields.insert("archived".into(), CkField::int64(0));
546 fields.insert("pinned".into(), CkField::int64(0));
547 fields.insert("hasImages".into(), CkField::int64(0));
548 fields.insert("hasFiles".into(), CkField::int64(0));
549 fields.insert("hasSourceCode".into(), CkField::int64(0));
550 fields.insert("todoCompleted".into(), CkField::int64(0));
551 fields.insert("todoIncompleted".into(), CkField::int64(0));
552 fields.insert("sf_creationDate".into(), CkField::timestamp(now_ms));
553 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
554 fields.insert("trashedDate".into(), CkField::timestamp_null());
555 fields.insert("pinnedDate".into(), CkField::timestamp_null());
556 fields.insert("archivedDate".into(), CkField::timestamp_null());
557 fields.insert("lockedDate".into(), CkField::timestamp_null());
558 fields.insert("conflictUniqueIdentifier".into(), CkField::string_null());
559 fields.insert(
560 "conflictUniqueIdentifierDate".into(),
561 CkField::timestamp_null(),
562 );
563 fields.insert("encryptedData".into(), CkField::string_null());
564
565 let op = ModifyOperation {
566 operation_type: "create".into(),
567 record_type: "SFNote".into(),
568 record: CkRecord {
569 record_name: note_uuid.clone(),
570 record_type: "SFNote".into(),
571 zone_id: None,
572 fields,
573 plugin_fields: HashMap::new(),
574 record_change_tag: None,
575 created: None,
576 modified: None,
577 deleted: false,
578 server_error_code: None,
579 reason: None,
580 },
581 };
582 let records = self.modify(vec![op])?;
583 records
584 .into_iter()
585 .next()
586 .ok_or_else(|| anyhow!("no record returned from create"))
587 }
588
589 pub fn ensure_tag(&self, title: &str) -> Result<String> {
590 verbose::eprintln(1, format!("[cloudkit] ensure_tag title={title:?}"));
591 if let Some(existing) = self.find_tag_record_name(title)? {
592 verbose::eprintln(
593 2,
594 format!(
595 "[cloudkit] ensure_tag reusing record={}",
596 existing.record_name
597 ),
598 );
599 return Ok(existing.record_name);
600 }
601
602 let now_ms = now_ms();
603 let tag_uuid = Uuid::new_v4().to_string().to_uppercase();
604 let mut fields: Fields = HashMap::new();
605 fields.insert("tagcon".into(), CkField::string_null());
606 fields.insert("pinnedDate".into(), CkField::timestamp_null());
607 fields.insert("pinned".into(), CkField::int64(0));
608 fields.insert("pinnedNotes".into(), CkField::string_list_null());
609 fields.insert("title".into(), CkField::string(title));
610 fields.insert("notesCount".into(), CkField::int64(1));
611 fields.insert("tagconDate".into(), CkField::timestamp_null());
612 fields.insert("pinnedNotesDate".into(), CkField::timestamp_null());
613 fields.insert(
614 "isRoot".into(),
615 CkField::int64(if title.contains('/') { 0 } else { 1 }),
616 );
617 fields.insert("sortingDate".into(), CkField::timestamp_null());
618 fields.insert("sorting".into(), CkField::int64(0));
619 fields.insert("version".into(), CkField::int64(3));
620 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms));
621 fields.insert("uniqueIdentifier".into(), CkField::string(&tag_uuid));
622
623 self.modify(vec![ModifyOperation {
624 operation_type: "create".into(),
625 record_type: "SFNoteTag".into(),
626 record: CkRecord {
627 record_name: tag_uuid.clone(),
628 record_type: "SFNoteTag".into(),
629 zone_id: None,
630 fields,
631 plugin_fields: HashMap::new(),
632 record_change_tag: None,
633 created: None,
634 modified: None,
635 deleted: false,
636 server_error_code: None,
637 reason: None,
638 },
639 }])?;
640 verbose::eprintln(
641 1,
642 format!("[cloudkit] ensure_tag created record={tag_uuid}"),
643 );
644
645 Ok(tag_uuid)
646 }
647
648 pub fn find_tag_record_name(&self, title: &str) -> Result<Option<CkRecord>> {
649 Ok(self
650 .list_tags()?
651 .into_iter()
652 .find(|tag| tag.str_field("title") == Some(title)))
653 }
654
655 pub fn resolve_tag_record_names(
656 &self,
657 tag_names: &[String],
658 create_missing: bool,
659 ) -> Result<Vec<String>> {
660 verbose::eprintln(
661 2,
662 format!(
663 "[cloudkit] resolve_tag_record_names names={tag_names:?} create_missing={create_missing}"
664 ),
665 );
666 let mut uuids = Vec::with_capacity(tag_names.len());
667 for tag_name in tag_names {
668 let tag_uuid = match self.find_tag_record_name(tag_name)? {
669 Some(existing) => existing.record_name,
670 None if create_missing => self.ensure_tag(tag_name)?,
671 None => continue,
672 };
673 uuids.push(tag_uuid);
674 }
675 Ok(uuids)
676 }
677
678 pub fn update_note_text(&self, record_name: &str, new_text: &str) -> Result<CkRecord> {
681 self.update_note(record_name, new_text, None, None)
682 }
683
684 pub fn update_note(
685 &self,
686 record_name: &str,
687 new_text: &str,
688 tag_uuids: Option<Vec<String>>,
689 tag_names: Option<Vec<String>>,
690 ) -> Result<CkRecord> {
691 verbose::eprintln(
692 1,
693 format!(
694 "[cloudkit] update_note record={} len={} tags_supplied={} names_supplied={}",
695 record_name,
696 new_text.len(),
697 tag_uuids.as_ref().map(|v| v.len()).unwrap_or(0),
698 tag_names.as_ref().map(|v| v.len()).unwrap_or(0)
699 ),
700 );
701 let device_name = self.device_name();
702 let current = self.fetch_note(record_name)?;
703 let change_tag = current
704 .record_change_tag
705 .clone()
706 .ok_or_else(|| anyhow!("note {record_name} has no recordChangeTag"))?;
707 let existing_clock = current.str_field("vectorClock");
708 let clock = vector_clock::increment(existing_clock, self.vector_clock_device())?;
709
710 let title = extract_title(new_text);
711 let subtitle = extract_subtitle(new_text);
712 let todo_counts = count_todos(new_text);
713 let now_ms = now_ms();
714
715 let mut fields: Fields = HashMap::new();
716 fields.insert("textADP".into(), CkField::string_encrypted(new_text));
717 fields.insert("text".into(), CkField::string_null());
718 fields.insert("title".into(), CkField::string(&title));
719 fields.insert("subtitleADP".into(), CkField::string_encrypted(&subtitle));
720 fields.insert("subtitle".into(), CkField::string_null());
721 fields.insert("vectorClock".into(), CkField::bytes(&clock));
722 fields.insert("lastEditingDevice".into(), CkField::string(device_name));
723 fields.insert("version".into(), CkField::int64(3));
724 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms));
725 fields.insert("todoCompleted".into(), CkField::int64(todo_counts.0));
726 fields.insert("todoIncompleted".into(), CkField::int64(todo_counts.1));
727 fields.insert(
728 "uniqueIdentifier".into(),
729 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
730 );
731 if let Some(tag_uuids) = tag_uuids {
732 fields.insert("tags".into(), CkField::string_list(tag_uuids));
733 }
734 if let Some(tag_names) = tag_names {
735 fields.insert("tagsStrings".into(), CkField::string_list(tag_names));
736 }
737
738 let op = ModifyOperation {
739 operation_type: "update".into(),
740 record_type: "SFNote".into(),
741 record: CkRecord {
742 record_name: record_name.to_string(),
743 record_type: "SFNote".into(),
744 zone_id: None,
745 fields,
746 plugin_fields: HashMap::new(),
747 record_change_tag: Some(change_tag),
748 created: current.created.clone(),
749 modified: current.modified.clone(),
750 deleted: false,
751 server_error_code: None,
752 reason: None,
753 },
754 };
755 let records = self.modify(vec![op])?;
756 records
757 .into_iter()
758 .next()
759 .ok_or_else(|| anyhow!("no record returned from update"))
760 }
761
762 pub fn attach_file(
765 &self,
766 note_record_name: &str,
767 filename: &str,
768 data: &[u8],
769 position: AttachPosition,
770 ) -> Result<()> {
771 verbose::eprintln(
772 1,
773 format!(
774 "[cloudkit] attach_file note={} filename={} bytes={} position={}",
775 note_record_name,
776 filename,
777 data.len(),
778 match position {
779 AttachPosition::Append => "append",
780 AttachPosition::Prepend => "prepend",
781 }
782 ),
783 );
784 let device_name = self.device_name();
785 let ext = std::path::Path::new(filename)
787 .extension()
788 .and_then(|e| e.to_str())
789 .unwrap_or("")
790 .to_lowercase();
791 let is_image = matches!(
792 ext.as_str(),
793 "jpg" | "jpeg" | "png" | "gif" | "webp" | "heic" | "tiff"
794 );
795 let record_type = if is_image {
796 "SFNoteImage"
797 } else {
798 "SFNoteGenericFile"
799 };
800 let mime_type = mime_for_ext(&ext);
801
802 let file_record_uuid = Uuid::new_v4().to_string().to_uppercase();
804 let receipt = self.upload_asset(&file_record_uuid, record_type, data, &mime_type)?;
805
806 let note = self.fetch_note(note_record_name)?;
808 let change_tag = note
809 .record_change_tag
810 .clone()
811 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
812 let existing_clock = note.str_field("vectorClock");
813 let clock = vector_clock::increment(existing_clock, self.vector_clock_device())?;
814
815 let current_text = note.str_field("textADP").unwrap_or("").to_string();
817 let encoded_name = encode_markdown_path(filename);
818 let embed = if is_image {
819 format!(
820 "<!-- {{\"preview\":\"true\",\"embed\":\"true\"}} -->"
821 )
822 } else {
823 format!(
824 "[{encoded_name}]({encoded_name})<!-- {{\"preview\":\"true\",\"embed\":\"true\"}} -->"
825 )
826 };
827 let new_text = match position {
828 AttachPosition::Append => {
829 if current_text.ends_with('\n') {
830 format!("{current_text}\n{embed}")
831 } else {
832 format!("{current_text}\n\n{embed}")
833 }
834 }
835 AttachPosition::Prepend => {
836 let mut lines = current_text.lines().map(str::to_string).collect::<Vec<_>>();
837 if lines.len() > 1 {
838 lines.insert(1, String::new());
839 lines.insert(2, embed);
840 } else {
841 lines.push(String::new());
842 lines.push(embed);
843 }
844 lines.join("\n")
845 }
846 };
847
848 let mut files_list: Vec<String> = note
850 .fields
851 .get("files")
852 .and_then(|f| f.value.as_array())
853 .map(|arr| {
854 arr.iter()
855 .filter_map(|v| v.as_str().map(str::to_string))
856 .collect()
857 })
858 .unwrap_or_default();
859 files_list.push(file_record_uuid.clone());
860
861 let has_images = if is_image {
862 1
863 } else {
864 note.i64_field("hasImages").unwrap_or(0)
865 };
866 let has_files = if is_image {
867 note.i64_field("hasFiles").unwrap_or(0)
868 } else {
869 1
870 };
871 let now_ms = now_ms();
872 let title = extract_title(&new_text);
873
874 let mut file_fields: Fields = std::collections::HashMap::new();
876 file_fields.insert(
877 "uniqueIdentifier".into(),
878 CkField::string(&file_record_uuid),
879 );
880 file_fields.insert("filenameADP".into(), CkField::string_encrypted(filename));
881 file_fields.insert("normalizedFileExtension".into(), CkField::string(&ext));
882 file_fields.insert("fileSize".into(), CkField::int64(data.len() as i64));
883 file_fields.insert("file".into(), CkField::asset_id(receipt));
884 file_fields.insert(
885 "noteUniqueIdentifier".into(),
886 CkField::string(
887 note.str_field("uniqueIdentifier")
888 .unwrap_or(note_record_name),
889 ),
890 );
891 file_fields.insert("index".into(), CkField::int64(0));
892 file_fields.insert("unused".into(), CkField::int64(0));
893 file_fields.insert("uploaded".into(), CkField::int64(1));
894 file_fields.insert("uploadedDate".into(), CkField::timestamp(now_ms));
895 file_fields.insert("insertionDate".into(), CkField::timestamp(now_ms));
896 file_fields.insert("encrypted".into(), CkField::int64(0));
897 if is_image {
898 file_fields.insert(
899 "animated".into(),
900 CkField::int64(if ext == "gif" { 1 } else { 0 }),
901 );
902 }
903 file_fields.insert("version".into(), CkField::int64(3));
904 file_fields.insert("sf_creationDate".into(), CkField::timestamp(now_ms));
905 file_fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms));
906
907 let mut note_fields: Fields = std::collections::HashMap::new();
909 note_fields.insert("textADP".into(), CkField::string_encrypted(&new_text));
910 note_fields.insert("text".into(), CkField::string_null());
911 note_fields.insert("title".into(), CkField::string(&title));
912 note_fields.insert("files".into(), CkField::string_list(files_list));
913 note_fields.insert("hasImages".into(), CkField::int64(has_images));
914 note_fields.insert("hasFiles".into(), CkField::int64(has_files));
915 note_fields.insert("vectorClock".into(), CkField::bytes(&clock));
916 note_fields.insert("lastEditingDevice".into(), CkField::string(device_name));
917 note_fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms));
918
919 self.modify(vec![
921 ModifyOperation {
922 operation_type: "create".into(),
923 record_type: record_type.to_string(),
924 record: CkRecord {
925 record_name: file_record_uuid,
926 record_type: record_type.to_string(),
927 zone_id: None,
928 fields: file_fields,
929 plugin_fields: HashMap::new(),
930 record_change_tag: None,
931 created: None,
932 modified: None,
933 deleted: false,
934 server_error_code: None,
935 reason: None,
936 },
937 },
938 ModifyOperation {
939 operation_type: "update".into(),
940 record_type: "SFNote".into(),
941 record: CkRecord {
942 record_name: note_record_name.to_string(),
943 record_type: "SFNote".into(),
944 zone_id: None,
945 fields: note_fields,
946 plugin_fields: HashMap::new(),
947 record_change_tag: Some(change_tag),
948 created: note.created.clone(),
949 modified: note.modified.clone(),
950 deleted: false,
951 server_error_code: None,
952 reason: None,
953 },
954 },
955 ])?;
956
957 Ok(())
958 }
959
960 pub fn trash_note(&self, record_name: &str) -> Result<()> {
962 verbose::eprintln(1, format!("[cloudkit] trash_note record={record_name}"));
963 let device_name = self.device_name();
964 let current = self.fetch_note(record_name)?;
965 let change_tag = current
966 .record_change_tag
967 .clone()
968 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
969 let clock =
970 vector_clock::increment(current.str_field("vectorClock"), self.vector_clock_device())?;
971 let now_ms = now_ms();
972
973 let mut fields: Fields = HashMap::new();
974 fields.insert("trashed".into(), CkField::int64(1));
975 fields.insert("trashedDate".into(), CkField::timestamp(now_ms));
976 fields.insert("vectorClock".into(), CkField::bytes(&clock));
977 fields.insert("lastEditingDevice".into(), CkField::string(device_name));
978 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
979 fields.insert(
980 "uniqueIdentifier".into(),
981 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
982 );
983
984 self.modify(vec![ModifyOperation {
985 operation_type: "update".into(),
986 record_type: "SFNote".into(),
987 record: CkRecord {
988 record_name: record_name.to_string(),
989 record_type: "SFNote".into(),
990 zone_id: None,
991 fields,
992 plugin_fields: HashMap::new(),
993 record_change_tag: Some(change_tag),
994 created: current.created.clone(),
995 modified: current.modified.clone(),
996 deleted: false,
997 server_error_code: None,
998 reason: None,
999 },
1000 }])?;
1001 Ok(())
1002 }
1003
1004 pub fn archive_note(&self, record_name: &str) -> Result<()> {
1006 verbose::eprintln(1, format!("[cloudkit] archive_note record={record_name}"));
1007 let device_name = self.device_name();
1008 let current = self.fetch_note(record_name)?;
1009 let change_tag = current
1010 .record_change_tag
1011 .clone()
1012 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
1013 let clock =
1014 vector_clock::increment(current.str_field("vectorClock"), self.vector_clock_device())?;
1015 let now_ms = now_ms();
1016
1017 let mut fields: Fields = HashMap::new();
1018 fields.insert("archived".into(), CkField::int64(1));
1019 fields.insert("archivedDate".into(), CkField::timestamp(now_ms));
1020 fields.insert("vectorClock".into(), CkField::bytes(&clock));
1021 fields.insert("lastEditingDevice".into(), CkField::string(device_name));
1022 fields.insert("sf_modificationDate".into(), CkField::timestamp(now_ms + 1));
1023 fields.insert(
1024 "uniqueIdentifier".into(),
1025 CkField::string(current.str_field("uniqueIdentifier").unwrap_or(record_name)),
1026 );
1027
1028 self.modify(vec![ModifyOperation {
1029 operation_type: "update".into(),
1030 record_type: "SFNote".into(),
1031 record: CkRecord {
1032 record_name: record_name.to_string(),
1033 record_type: "SFNote".into(),
1034 zone_id: None,
1035 fields,
1036 plugin_fields: HashMap::new(),
1037 record_change_tag: Some(change_tag),
1038 created: current.created.clone(),
1039 modified: current.modified.clone(),
1040 deleted: false,
1041 server_error_code: None,
1042 reason: None,
1043 },
1044 }])?;
1045 Ok(())
1046 }
1047
1048 pub fn delete_note(&self, record_name: &str) -> Result<()> {
1049 verbose::eprintln(1, format!("[cloudkit] delete_note record={record_name}"));
1050 let current = self.fetch_note(record_name)?;
1051 let change_tag = current
1052 .record_change_tag
1053 .clone()
1054 .ok_or_else(|| anyhow!("note has no recordChangeTag"))?;
1055
1056 self.modify(vec![ModifyOperation {
1057 operation_type: "delete".into(),
1058 record_type: "SFNote".into(),
1059 record: CkRecord {
1060 record_name: record_name.to_string(),
1061 record_type: "SFNote".into(),
1062 zone_id: None,
1063 fields: HashMap::new(),
1064 plugin_fields: HashMap::new(),
1065 record_change_tag: Some(change_tag),
1066 created: current.created.clone(),
1067 modified: current.modified.clone(),
1068 deleted: true,
1069 server_error_code: None,
1070 reason: None,
1071 },
1072 }])?;
1073 Ok(())
1074 }
1075
1076 pub fn delete_tag(&self, record_name: &str) -> Result<()> {
1077 verbose::eprintln(1, format!("[cloudkit] delete_tag record={record_name}"));
1078 let current = self.fetch_tag(record_name)?;
1079 let change_tag = current
1080 .record_change_tag
1081 .clone()
1082 .ok_or_else(|| anyhow!("tag has no recordChangeTag"))?;
1083
1084 self.modify(vec![ModifyOperation {
1085 operation_type: "delete".into(),
1086 record_type: "SFNoteTag".into(),
1087 record: CkRecord {
1088 record_name: record_name.to_string(),
1089 record_type: "SFNoteTag".into(),
1090 zone_id: None,
1091 fields: HashMap::new(),
1092 plugin_fields: HashMap::new(),
1093 record_change_tag: Some(change_tag),
1094 created: current.created.clone(),
1095 modified: current.modified.clone(),
1096 deleted: true,
1097 server_error_code: None,
1098 reason: None,
1099 },
1100 }])?;
1101 Ok(())
1102 }
1103}
1104
1105pub enum AttachPosition {
1106 Append,
1107 Prepend,
1108}
1109
1110pub fn now_ms() -> i64 {
1111 SystemTime::now()
1112 .duration_since(UNIX_EPOCH)
1113 .map(|d| d.as_millis() as i64)
1114 .unwrap_or(0)
1115}
1116
1117pub fn extract_title(text: &str) -> String {
1119 for line in text.lines() {
1120 let t = line.trim();
1121 if let Some(stripped) = t.strip_prefix("# ") {
1122 return stripped.to_string();
1123 }
1124 if !t.is_empty() {
1125 return t.to_string();
1126 }
1127 }
1128 String::new()
1129}
1130
1131pub fn extract_subtitle(text: &str) -> String {
1133 let mut past_title = false;
1134 for line in text.lines() {
1135 let t = line.trim();
1136 if !past_title {
1137 past_title = !t.is_empty();
1138 continue;
1139 }
1140 if !t.is_empty() {
1141 return t.to_string();
1142 }
1143 }
1144 String::new()
1145}
1146
1147fn count_todos(text: &str) -> (i64, i64) {
1148 let mut done = 0i64;
1149 let mut todo = 0i64;
1150 for line in text.lines() {
1151 let t = line.trim();
1152 if t.starts_with("- [x]") || t.starts_with("- [X]") {
1153 done += 1;
1154 } else if t.starts_with("- [ ]") {
1155 todo += 1;
1156 }
1157 }
1158 (done, todo)
1159}
1160
1161fn mime_for_ext(ext: &str) -> String {
1162 match ext {
1163 "jpg" | "jpeg" => "image/jpeg",
1164 "png" => "image/png",
1165 "gif" => "image/gif",
1166 "webp" => "image/webp",
1167 "heic" => "image/heic",
1168 "tiff" | "tif" => "image/tiff",
1169 "pdf" => "application/pdf",
1170 "txt" => "text/plain",
1171 _ => "application/octet-stream",
1172 }
1173 .to_string()
1174}
1175
1176fn encode_markdown_path(value: &str) -> String {
1177 value.replace(' ', "%20")
1178}
1179
1180fn redact_cloudkit_url(url: &str) -> String {
1181 let mut redacted = url.to_string();
1182 for key in ["ckWebAuthToken=", "ckAPIToken="] {
1183 redacted = redact_query_value(&redacted, key);
1184 }
1185 redacted
1186}
1187
1188fn redact_query_value(url: &str, key: &str) -> String {
1189 let Some(start) = url.find(key) else {
1190 return url.to_string();
1191 };
1192 let value_start = start + key.len();
1193 let value_end = url[value_start..]
1194 .find('&')
1195 .map(|offset| value_start + offset)
1196 .unwrap_or(url.len());
1197 let value = &url[value_start..value_end];
1198 let replacement = redact_secret(value);
1199
1200 let mut out = String::with_capacity(url.len());
1201 out.push_str(&url[..value_start]);
1202 out.push_str(&replacement);
1203 out.push_str(&url[value_end..]);
1204 out
1205}
1206
1207fn redact_secret(value: &str) -> String {
1208 if value.len() <= 8 {
1209 "***".to_string()
1210 } else {
1211 format!("{}...{}", &value[..4], &value[value.len() - 4..])
1212 }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use super::*;
1218
1219 #[test]
1220 fn url_encodes_plus_in_cloudkit_tokens() {
1221 let client = CloudKitClient::new(AuthConfig {
1222 ck_web_auth_token: "abc+123/xyz".into(),
1223 })
1224 .unwrap();
1225
1226 let url = client.url("/records/query");
1227 assert!(url.contains("ckWebAuthToken=abc%2B123/xyz"));
1228 assert!(!url.contains("ckWebAuthToken=abc+123/xyz"));
1229 }
1230}