Skip to main content

bear_cli/cloudkit/
client.rs

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        // Surface per-record errors
120        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    /// Fetch a single SFNote by its uniqueIdentifier (which equals its CloudKit recordName).
348    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    /// Fetch a single SFNoteTag by its recordName.
420    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    /// Upload a file to CloudKit asset storage. Returns the receipt to embed in a record field.
447    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        // Phase 1: request a signed upload URL
465        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        // Phase 2: upload raw bytes to the signed URL
481        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    /// Create a brand-new note. Returns the created record.
503    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(&note_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    /// Update a note's text. Fetches the current record first to obtain the recordChangeTag
679    /// and existing vector clock, then writes back the updated content.
680    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    /// Attach a file to a note. Uploads the asset, creates the file record, and
763    /// updates the note's markdown — all in one atomic `records/modify` call.
764    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        // Determine record type and mime type from extension
786        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        // Upload asset (2-phase)
803        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        // Fetch current note to get change tag and existing content
807        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        // Build updated note text with file embedded
816        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                "![{filename}]({encoded_name})<!-- {{\"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        // Update files list on the note
849        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        // Build file record fields
875        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        // Build updated note fields
908        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        // Single atomic modify call: file record + note update
920        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    /// Move a note to trash (sets trashed=1, trashedDate=now, increments vector clock).
961    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    /// Archive a note.
1005    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
1117/// First `# Heading` or first non-empty line.
1118pub 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
1131/// First body line (skipping the title line).
1132pub 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}