Skip to main content

text_document/
document.rs

1//! TextDocument implementation.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use anyhow::Result;
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD as BASE64;
10
11use crate::{ResourceType, TextDirection, WrapMode};
12use frontend::commands::{
13    document_commands, document_inspection_commands, document_io_commands,
14    document_search_commands, resource_commands, undo_redo_commands,
15};
16
17use crate::convert::{self, to_i64, to_usize};
18use crate::cursor::TextCursor;
19use crate::events::{self, DocumentEvent, Subscription};
20use crate::inner::TextDocumentInner;
21use crate::operation::{DocxExportResult, HtmlImportResult, MarkdownImportResult, Operation};
22use crate::{BlockFormat, BlockInfo, DocumentStats, FindMatch, FindOptions};
23
24/// A rich text document.
25///
26/// Owns the backend (database, event hub, undo/redo manager) and provides
27/// document-level operations. All cursor-based editing goes through
28/// [`TextCursor`], obtained via [`cursor()`](TextDocument::cursor) or
29/// [`cursor_at()`](TextDocument::cursor_at).
30///
31/// Internally uses `Arc<Mutex<...>>` so that multiple [`TextCursor`]s can
32/// coexist and edit concurrently. Cloning a `TextDocument` creates a new
33/// handle to the **same** underlying document (like Qt's implicit sharing).
34#[derive(Clone)]
35pub struct TextDocument {
36    pub(crate) inner: Arc<Mutex<TextDocumentInner>>,
37}
38
39impl TextDocument {
40    // ── Construction ──────────────────────────────────────────
41
42    /// Create a new, empty document.
43    ///
44    /// # Panics
45    ///
46    /// Panics if the database context cannot be created (e.g. filesystem error).
47    /// Use [`TextDocument::try_new`] for a fallible alternative.
48    pub fn new() -> Self {
49        Self::try_new().expect("failed to initialize document")
50    }
51
52    /// Create a new, empty document, returning an error on failure.
53    pub fn try_new() -> Result<Self> {
54        let ctx = frontend::AppContext::new();
55        let doc_inner = TextDocumentInner::initialize(ctx)?;
56        let inner = Arc::new(Mutex::new(doc_inner));
57
58        // Bridge backend long-operation events to public DocumentEvent.
59        Self::subscribe_long_operation_events(&inner);
60
61        Ok(Self { inner })
62    }
63
64    /// Subscribe to backend long-operation events and bridge them to DocumentEvent.
65    fn subscribe_long_operation_events(inner: &Arc<Mutex<TextDocumentInner>>) {
66        use frontend::common::event::{LongOperationEvent as LOE, Origin};
67
68        let weak = Arc::downgrade(inner);
69        {
70            let locked = inner.lock();
71            // Progress
72            let w = weak.clone();
73            locked
74                .event_client
75                .subscribe(Origin::LongOperation(LOE::Progress), move |event| {
76                    if let Some(inner) = w.upgrade() {
77                        let (op_id, percent, message) = parse_progress_data(&event.data);
78                        let mut inner = inner.lock();
79                        inner.queue_event(DocumentEvent::LongOperationProgress {
80                            operation_id: op_id,
81                            percent,
82                            message,
83                        });
84                    }
85                });
86
87            // Completed
88            let w = weak.clone();
89            locked
90                .event_client
91                .subscribe(Origin::LongOperation(LOE::Completed), move |event| {
92                    if let Some(inner) = w.upgrade() {
93                        let op_id = parse_id_data(&event.data);
94                        let mut inner = inner.lock();
95                        inner.queue_event(DocumentEvent::DocumentReset);
96                        inner.check_block_count_changed();
97                        inner.queue_event(DocumentEvent::LongOperationFinished {
98                            operation_id: op_id,
99                            success: true,
100                            error: None,
101                        });
102                    }
103                });
104
105            // Cancelled
106            let w = weak.clone();
107            locked
108                .event_client
109                .subscribe(Origin::LongOperation(LOE::Cancelled), move |event| {
110                    if let Some(inner) = w.upgrade() {
111                        let op_id = parse_id_data(&event.data);
112                        let mut inner = inner.lock();
113                        inner.queue_event(DocumentEvent::LongOperationFinished {
114                            operation_id: op_id,
115                            success: false,
116                            error: Some("cancelled".into()),
117                        });
118                    }
119                });
120
121            // Failed
122            locked
123                .event_client
124                .subscribe(Origin::LongOperation(LOE::Failed), move |event| {
125                    if let Some(inner) = weak.upgrade() {
126                        let (op_id, error) = parse_failed_data(&event.data);
127                        let mut inner = inner.lock();
128                        inner.queue_event(DocumentEvent::LongOperationFinished {
129                            operation_id: op_id,
130                            success: false,
131                            error: Some(error),
132                        });
133                    }
134                });
135        }
136    }
137
138    // ── Whole-document content ────────────────────────────────
139
140    /// Replace the entire document with plain text. Clears undo history.
141    pub fn set_plain_text(&self, text: &str) -> Result<()> {
142        let queued = {
143            let mut inner = self.inner.lock();
144            let dto = frontend::document_io::ImportPlainTextDto {
145                plain_text: text.into(),
146            };
147            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
148            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
149            inner.invalidate_text_cache();
150            inner.queue_event(DocumentEvent::DocumentReset);
151            inner.check_block_count_changed();
152            inner.queue_event(DocumentEvent::UndoRedoChanged {
153                can_undo: false,
154                can_redo: false,
155            });
156            inner.take_queued_events()
157        };
158        crate::inner::dispatch_queued_events(queued);
159        Ok(())
160    }
161
162    /// Export the entire document as plain text.
163    pub fn to_plain_text(&self) -> Result<String> {
164        let mut inner = self.inner.lock();
165        Ok(inner.plain_text()?.to_string())
166    }
167
168    /// Replace the entire document with Markdown. Clears undo history.
169    ///
170    /// This is a **long operation**. Returns a typed [`Operation`] handle.
171    pub fn set_markdown(&self, markdown: &str) -> Result<Operation<MarkdownImportResult>> {
172        let mut inner = self.inner.lock();
173        inner.invalidate_text_cache();
174        let dto = frontend::document_io::ImportMarkdownDto {
175            markdown_text: markdown.into(),
176        };
177        let op_id = document_io_commands::import_markdown(&inner.ctx, &dto)?;
178        Ok(Operation::new(
179            op_id,
180            &inner.ctx,
181            Box::new(|ctx, id| {
182                document_io_commands::get_import_markdown_result(ctx, id)
183                    .ok()
184                    .flatten()
185                    .map(|r| {
186                        Ok(MarkdownImportResult {
187                            block_count: to_usize(r.block_count),
188                        })
189                    })
190            }),
191        ))
192    }
193
194    /// Export the entire document as Markdown.
195    pub fn to_markdown(&self) -> Result<String> {
196        let inner = self.inner.lock();
197        let dto = document_io_commands::export_markdown(&inner.ctx)?;
198        Ok(dto.markdown_text)
199    }
200
201    /// Replace the entire document with HTML. Clears undo history.
202    ///
203    /// This is a **long operation**. Returns a typed [`Operation`] handle.
204    pub fn set_html(&self, html: &str) -> Result<Operation<HtmlImportResult>> {
205        let mut inner = self.inner.lock();
206        inner.invalidate_text_cache();
207        let dto = frontend::document_io::ImportHtmlDto {
208            html_text: html.into(),
209        };
210        let op_id = document_io_commands::import_html(&inner.ctx, &dto)?;
211        Ok(Operation::new(
212            op_id,
213            &inner.ctx,
214            Box::new(|ctx, id| {
215                document_io_commands::get_import_html_result(ctx, id)
216                    .ok()
217                    .flatten()
218                    .map(|r| {
219                        Ok(HtmlImportResult {
220                            block_count: to_usize(r.block_count),
221                        })
222                    })
223            }),
224        ))
225    }
226
227    /// Export the entire document as HTML.
228    pub fn to_html(&self) -> Result<String> {
229        let inner = self.inner.lock();
230        let dto = document_io_commands::export_html(&inner.ctx)?;
231        Ok(dto.html_text)
232    }
233
234    /// Export the entire document as LaTeX.
235    pub fn to_latex(&self, document_class: &str, include_preamble: bool) -> Result<String> {
236        let inner = self.inner.lock();
237        let dto = frontend::document_io::ExportLatexDto {
238            document_class: document_class.into(),
239            include_preamble,
240        };
241        let result = document_io_commands::export_latex(&inner.ctx, &dto)?;
242        Ok(result.latex_text)
243    }
244
245    /// Export the entire document as DOCX to a file path.
246    ///
247    /// This is a **long operation**. Returns a typed [`Operation`] handle.
248    pub fn to_docx(&self, output_path: &str) -> Result<Operation<DocxExportResult>> {
249        let inner = self.inner.lock();
250        let dto = frontend::document_io::ExportDocxDto {
251            output_path: output_path.into(),
252        };
253        let op_id = document_io_commands::export_docx(&inner.ctx, &dto)?;
254        Ok(Operation::new(
255            op_id,
256            &inner.ctx,
257            Box::new(|ctx, id| {
258                document_io_commands::get_export_docx_result(ctx, id)
259                    .ok()
260                    .flatten()
261                    .map(|r| {
262                        Ok(DocxExportResult {
263                            file_path: r.file_path,
264                            paragraph_count: to_usize(r.paragraph_count),
265                        })
266                    })
267            }),
268        ))
269    }
270
271    /// Clear all document content and reset to an empty state.
272    pub fn clear(&self) -> Result<()> {
273        let queued = {
274            let mut inner = self.inner.lock();
275            let dto = frontend::document_io::ImportPlainTextDto {
276                plain_text: String::new(),
277            };
278            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
279            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
280            inner.invalidate_text_cache();
281            inner.queue_event(DocumentEvent::DocumentReset);
282            inner.check_block_count_changed();
283            inner.queue_event(DocumentEvent::UndoRedoChanged {
284                can_undo: false,
285                can_redo: false,
286            });
287            inner.take_queued_events()
288        };
289        crate::inner::dispatch_queued_events(queued);
290        Ok(())
291    }
292
293    // ── Cursor factory ───────────────────────────────────────
294
295    /// Create a cursor at position 0.
296    pub fn cursor(&self) -> TextCursor {
297        self.cursor_at(0)
298    }
299
300    /// Create a cursor at the given position.
301    pub fn cursor_at(&self, position: usize) -> TextCursor {
302        let data = {
303            let mut inner = self.inner.lock();
304            inner.register_cursor(position)
305        };
306        TextCursor {
307            doc: self.inner.clone(),
308            data,
309        }
310    }
311
312    // ── Document queries ─────────────────────────────────────
313
314    /// Get document statistics. O(1) — reads cached values.
315    pub fn stats(&self) -> DocumentStats {
316        let inner = self.inner.lock();
317        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
318            .expect("get_document_stats should not fail");
319        DocumentStats::from(&dto)
320    }
321
322    /// Get the total character count. O(1) — reads cached value.
323    pub fn character_count(&self) -> usize {
324        let inner = self.inner.lock();
325        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
326            .expect("get_document_stats should not fail");
327        to_usize(dto.character_count)
328    }
329
330    /// Get the number of blocks (paragraphs). O(1) — reads cached value.
331    pub fn block_count(&self) -> usize {
332        let inner = self.inner.lock();
333        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
334            .expect("get_document_stats should not fail");
335        to_usize(dto.block_count)
336    }
337
338    /// Returns true if the document has no text content.
339    pub fn is_empty(&self) -> bool {
340        self.character_count() == 0
341    }
342
343    /// Get text at a position for a given length.
344    pub fn text_at(&self, position: usize, length: usize) -> Result<String> {
345        let inner = self.inner.lock();
346        let dto = frontend::document_inspection::GetTextAtPositionDto {
347            position: to_i64(position),
348            length: to_i64(length),
349        };
350        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
351        Ok(result.text)
352    }
353
354    /// Get info about the block at a position. O(log n).
355    pub fn block_at(&self, position: usize) -> Result<BlockInfo> {
356        let inner = self.inner.lock();
357        let dto = frontend::document_inspection::GetBlockAtPositionDto {
358            position: to_i64(position),
359        };
360        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
361        Ok(BlockInfo::from(&result))
362    }
363
364    /// Get the block format at a position.
365    pub fn block_format_at(&self, position: usize) -> Result<BlockFormat> {
366        let inner = self.inner.lock();
367        let dto = frontend::document_inspection::GetBlockAtPositionDto {
368            position: to_i64(position),
369        };
370        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
371        let block_id = block_info.block_id;
372        let block_id = block_id as u64;
373        let block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
374            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
375        Ok(BlockFormat::from(&block_dto))
376    }
377
378    // ── Search ───────────────────────────────────────────────
379
380    /// Find the next (or previous) occurrence. Returns `None` if not found.
381    pub fn find(
382        &self,
383        query: &str,
384        from: usize,
385        options: &FindOptions,
386    ) -> Result<Option<FindMatch>> {
387        let inner = self.inner.lock();
388        let dto = options.to_find_text_dto(query, from);
389        let result = document_search_commands::find_text(&inner.ctx, &dto)?;
390        Ok(convert::find_result_to_match(&result))
391    }
392
393    /// Find all occurrences.
394    pub fn find_all(&self, query: &str, options: &FindOptions) -> Result<Vec<FindMatch>> {
395        let inner = self.inner.lock();
396        let dto = options.to_find_all_dto(query);
397        let result = document_search_commands::find_all(&inner.ctx, &dto)?;
398        Ok(convert::find_all_to_matches(&result))
399    }
400
401    /// Replace occurrences. Returns the number of replacements. Undoable.
402    pub fn replace_text(
403        &self,
404        query: &str,
405        replacement: &str,
406        replace_all: bool,
407        options: &FindOptions,
408    ) -> Result<usize> {
409        let (count, queued) = {
410            let mut inner = self.inner.lock();
411            let dto = options.to_replace_dto(query, replacement, replace_all);
412            let result =
413                document_search_commands::replace_text(&inner.ctx, Some(inner.stack_id), &dto)?;
414            let count = to_usize(result.replacements_count);
415            inner.invalidate_text_cache();
416            if count > 0 {
417                inner.modified = true;
418                inner.queue_event(DocumentEvent::ContentsChanged {
419                    position: 0,
420                    chars_removed: 0,
421                    chars_added: 0,
422                    blocks_affected: 0,
423                });
424                inner.check_block_count_changed();
425                let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
426                let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
427                inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
428            }
429            (count, inner.take_queued_events())
430        };
431        crate::inner::dispatch_queued_events(queued);
432        Ok(count)
433    }
434
435    // ── Resources ────────────────────────────────────────────
436
437    /// Add a resource (image, stylesheet) to the document.
438    pub fn add_resource(
439        &self,
440        resource_type: ResourceType,
441        name: &str,
442        mime_type: &str,
443        data: &[u8],
444    ) -> Result<()> {
445        let mut inner = self.inner.lock();
446        let dto = frontend::resource::dtos::CreateResourceDto {
447            created_at: Default::default(),
448            updated_at: Default::default(),
449            resource_type,
450            name: name.into(),
451            url: String::new(),
452            mime_type: mime_type.into(),
453            data_base64: BASE64.encode(data),
454        };
455        let created = resource_commands::create_resource(
456            &inner.ctx,
457            Some(inner.stack_id),
458            &dto,
459            inner.document_id,
460            -1,
461        )?;
462        inner.resource_cache.insert(name.to_string(), created.id);
463        Ok(())
464    }
465
466    /// Get a resource by name. Returns `None` if not found.
467    ///
468    /// Uses an internal cache to avoid scanning all resources on repeated lookups.
469    pub fn resource(&self, name: &str) -> Result<Option<Vec<u8>>> {
470        let mut inner = self.inner.lock();
471
472        // Fast path: check the name → ID cache.
473        if let Some(&id) = inner.resource_cache.get(name) {
474            if let Some(r) = resource_commands::get_resource(&inner.ctx, &id)? {
475                let bytes = BASE64.decode(&r.data_base64)?;
476                return Ok(Some(bytes));
477            }
478            // ID was stale — fall through to full scan.
479            inner.resource_cache.remove(name);
480        }
481
482        // Slow path: linear scan, then populate cache for the match.
483        let all = resource_commands::get_all_resource(&inner.ctx)?;
484        for r in &all {
485            if r.name == name {
486                inner.resource_cache.insert(name.to_string(), r.id);
487                let bytes = BASE64.decode(&r.data_base64)?;
488                return Ok(Some(bytes));
489            }
490        }
491        Ok(None)
492    }
493
494    // ── Undo / Redo ──────────────────────────────────────────
495
496    /// Undo the last operation.
497    pub fn undo(&self) -> Result<()> {
498        let queued = {
499            let mut inner = self.inner.lock();
500            let result = undo_redo_commands::undo(&inner.ctx, Some(inner.stack_id));
501            inner.invalidate_text_cache();
502            result?;
503            inner.check_block_count_changed();
504            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
505            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
506            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
507            inner.take_queued_events()
508        };
509        crate::inner::dispatch_queued_events(queued);
510        Ok(())
511    }
512
513    /// Redo the last undone operation.
514    pub fn redo(&self) -> Result<()> {
515        let queued = {
516            let mut inner = self.inner.lock();
517            let result = undo_redo_commands::redo(&inner.ctx, Some(inner.stack_id));
518            inner.invalidate_text_cache();
519            result?;
520            inner.check_block_count_changed();
521            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
522            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
523            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
524            inner.take_queued_events()
525        };
526        crate::inner::dispatch_queued_events(queued);
527        Ok(())
528    }
529
530    /// Returns true if there are operations that can be undone.
531    pub fn can_undo(&self) -> bool {
532        let inner = self.inner.lock();
533        undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id))
534    }
535
536    /// Returns true if there are operations that can be redone.
537    pub fn can_redo(&self) -> bool {
538        let inner = self.inner.lock();
539        undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id))
540    }
541
542    /// Clear all undo/redo history.
543    pub fn clear_undo_redo(&self) {
544        let inner = self.inner.lock();
545        undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
546    }
547
548    // ── Modified state ───────────────────────────────────────
549
550    /// Returns true if the document has been modified since creation or last reset.
551    pub fn is_modified(&self) -> bool {
552        self.inner.lock().modified
553    }
554
555    /// Set or clear the modified flag.
556    pub fn set_modified(&self, modified: bool) {
557        let queued = {
558            let mut inner = self.inner.lock();
559            if inner.modified != modified {
560                inner.modified = modified;
561                inner.queue_event(DocumentEvent::ModificationChanged(modified));
562            }
563            inner.take_queued_events()
564        };
565        crate::inner::dispatch_queued_events(queued);
566    }
567
568    // ── Document properties ──────────────────────────────────
569
570    /// Get the document title.
571    pub fn title(&self) -> String {
572        let inner = self.inner.lock();
573        document_commands::get_document(&inner.ctx, &inner.document_id)
574            .ok()
575            .flatten()
576            .map(|d| d.title)
577            .unwrap_or_default()
578    }
579
580    /// Set the document title.
581    pub fn set_title(&self, title: &str) -> Result<()> {
582        let inner = self.inner.lock();
583        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
584            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
585        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
586        update.title = title.into();
587        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
588        Ok(())
589    }
590
591    /// Get the text direction.
592    pub fn text_direction(&self) -> TextDirection {
593        let inner = self.inner.lock();
594        document_commands::get_document(&inner.ctx, &inner.document_id)
595            .ok()
596            .flatten()
597            .map(|d| d.text_direction)
598            .unwrap_or(TextDirection::LeftToRight)
599    }
600
601    /// Set the text direction.
602    pub fn set_text_direction(&self, direction: TextDirection) -> Result<()> {
603        let inner = self.inner.lock();
604        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
605            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
606        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
607        update.text_direction = direction;
608        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
609        Ok(())
610    }
611
612    /// Get the default wrap mode.
613    pub fn default_wrap_mode(&self) -> WrapMode {
614        let inner = self.inner.lock();
615        document_commands::get_document(&inner.ctx, &inner.document_id)
616            .ok()
617            .flatten()
618            .map(|d| d.default_wrap_mode)
619            .unwrap_or(WrapMode::WordWrap)
620    }
621
622    /// Set the default wrap mode.
623    pub fn set_default_wrap_mode(&self, mode: WrapMode) -> Result<()> {
624        let inner = self.inner.lock();
625        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
626            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
627        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
628        update.default_wrap_mode = mode;
629        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
630        Ok(())
631    }
632
633    // ── Event subscription ───────────────────────────────────
634
635    /// Subscribe to document events via callback.
636    ///
637    /// Callbacks are invoked **outside** the document lock (after the editing
638    /// operation completes and the lock is released). It is safe to call
639    /// `TextDocument` or `TextCursor` methods from within the callback without
640    /// risk of deadlock. However, keep callbacks lightweight — they run
641    /// synchronously on the calling thread and block the caller until they
642    /// return.
643    ///
644    /// Drop the returned [`Subscription`] to unsubscribe.
645    ///
646    /// # Breaking change (v0.0.6)
647    ///
648    /// The callback bound changed from `Send` to `Send + Sync` in v0.0.6
649    /// to support `Arc`-based dispatch. Callbacks that capture non-`Sync`
650    /// types (e.g., `Rc<T>`, `Cell<T>`) must be wrapped in a `Mutex`.
651    pub fn on_change<F>(&self, callback: F) -> Subscription
652    where
653        F: Fn(DocumentEvent) + Send + Sync + 'static,
654    {
655        let mut inner = self.inner.lock();
656        events::subscribe_inner(&mut inner, callback)
657    }
658
659    /// Return events accumulated since the last `poll_events()` call.
660    ///
661    /// This delivery path is independent of callback dispatch via
662    /// [`on_change`](Self::on_change) — using both simultaneously is safe
663    /// and each path sees every event exactly once.
664    pub fn poll_events(&self) -> Vec<DocumentEvent> {
665        let mut inner = self.inner.lock();
666        inner.drain_poll_events()
667    }
668}
669
670impl Default for TextDocument {
671    fn default() -> Self {
672        Self::new()
673    }
674}
675
676// ── Long-operation event data helpers ─────────────────────────
677
678/// Parse progress JSON: `{"id":"...", "percentage": 50.0, "message": "..."}`
679fn parse_progress_data(data: &Option<String>) -> (String, f64, String) {
680    let Some(json) = data else {
681        return (String::new(), 0.0, String::new());
682    };
683    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
684    let id = v["id"].as_str().unwrap_or_default().to_string();
685    let pct = v["percentage"].as_f64().unwrap_or(0.0);
686    let msg = v["message"].as_str().unwrap_or_default().to_string();
687    (id, pct, msg)
688}
689
690/// Parse completed/cancelled JSON: `{"id":"..."}`
691fn parse_id_data(data: &Option<String>) -> String {
692    let Some(json) = data else {
693        return String::new();
694    };
695    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
696    v["id"].as_str().unwrap_or_default().to_string()
697}
698
699/// Parse failed JSON: `{"id":"...", "error":"..."}`
700fn parse_failed_data(data: &Option<String>) -> (String, String) {
701    let Some(json) = data else {
702        return (String::new(), "unknown error".into());
703    };
704    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
705    let id = v["id"].as_str().unwrap_or_default().to_string();
706    let error = v["error"].as_str().unwrap_or("unknown error").to_string();
707    (id, error)
708}