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;
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        // Apple CloudKit decodes '+' as space — must percent-encode it explicitly.
33        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        // Surface per-record errors
69        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    /// Upload a file to CloudKit asset storage. Returns the receipt to embed in a record field.
218    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        // Phase 1: request a signed upload URL
226        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        // Phase 2: upload raw bytes to the signed URL
242        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    /// Create a brand-new note. Returns the created record.
263    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(&note_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    /// Update a note's text. Fetches the current record first to obtain the recordChangeTag
322    /// and existing vector clock, then writes back the updated content.
323    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    /// Attach a file to a note. Uploads the asset, creates the file record, and
374    /// updates the note's markdown — all in one atomic `records/modify` call.
375    pub fn attach_file(
376        &self,
377        note_record_name: &str,
378        filename: &str,
379        data: &[u8],
380        position: AttachPosition,
381    ) -> Result<()> {
382        // Determine record type and mime type from extension
383        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        // Upload asset (2-phase)
400        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        // Fetch current note to get change tag and existing content
405        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        // Build updated note text with file embedded
414        let current_text = note.str_field("textADP").unwrap_or("").to_string();
415        let embed = if is_image {
416            format!("![{filename}]({filename})<!-- {{\"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                // Insert after the first heading line if present
424                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        // Update files list on the note
436        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        // Build file record fields
456        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        // Build updated note fields
487        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        // Single atomic modify call: file record + note update
511        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    /// Move a note to trash (sets trashed=1, trashedDate=now, increments vector clock).
542    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    /// Archive a note.
581    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
632/// First `# Heading` or first non-empty line.
633pub 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
646/// First body line (skipping the title line).
647pub 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}