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