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