Skip to main content

duc/
serialize.rs

1//! Serializes an [`ExportedDataState`] into a `.duc` SQLite binary (byte vector).
2//!
3//! Flow: ExportedDataState → in-memory SQLite DB → raw bytes
4//!
5//! The schema is applied via [`crate::db::open_memory`] so every output is a
6//! valid `.duc` file that can be opened again with [`crate::parse::parse`].
7
8use rusqlite::{params, Connection, Transaction};
9use std::os::raw::c_char;
10
11use crate::db;
12use crate::types::*;
13
14#[derive(Debug)]
15pub enum SerializeError {
16    Db(db::DbError),
17    Sqlite(rusqlite::Error),
18    Io(String),
19}
20
21impl std::fmt::Display for SerializeError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            SerializeError::Db(e) => write!(f, "db: {e}"),
25            SerializeError::Sqlite(e) => write!(f, "sqlite: {e}"),
26            SerializeError::Io(e) => write!(f, "io: {e}"),
27        }
28    }
29}
30
31impl std::error::Error for SerializeError {}
32
33impl From<db::DbError> for SerializeError {
34    fn from(e: db::DbError) -> Self { SerializeError::Db(e) }
35}
36impl From<rusqlite::Error> for SerializeError {
37    fn from(e: rusqlite::Error) -> Self { SerializeError::Sqlite(e) }
38}
39impl From<std::io::Error> for SerializeError {
40    fn from(e: std::io::Error) -> Self { SerializeError::Io(e.to_string()) }
41}
42
43pub type SerializeResult<T> = Result<T, SerializeError>;
44
45const LARGE_EMBEDDED_FILES_THRESHOLD_BYTES: usize = 128 * 1024 * 1024;
46
47// ─── Public entry point ──────────────────────────────────────────────────────
48
49/// Serialize an [`ExportedDataState`] into a compressed `.duc` file (raw bytes).
50///
51/// Uses `page_size = 1024` and gzip (RFC 1952) compression for minimal output size.
52/// The result can be parsed back with [`crate::parse::parse`].
53pub fn serialize(state: &ExportedDataState) -> SerializeResult<Vec<u8>> {
54    let conn = db::open_memory_compact()?;
55    let mut inner = conn.into_inner();
56    let embedded_external_bytes = total_external_file_data_bytes(state);
57    let should_optimize_for_large_embeds = embedded_external_bytes >= LARGE_EMBEDDED_FILES_THRESHOLD_BYTES;
58
59    {
60        // Temporarily disable FK checks during bulk insert so that
61        // partially-consistent app state (e.g. elements referencing
62        // groups/layers that weren't exported) doesn't abort the write.
63        // FKs are re-enabled after the transaction so the resulting
64        // file is still a valid database that enforces FKs on open.
65        inner.execute_batch("PRAGMA foreign_keys = OFF;")?;
66
67        let tx = inner.transaction()?;
68        write_document(&tx, state)?;
69        write_charter(&tx, &state.charter)?;
70        write_issues(&tx, &state.issues)?;
71        write_global_state(&tx, &state.duc_global_state)?;
72        write_local_state(&tx, &state.duc_local_state)?;
73        write_dictionary(&tx, &state.dictionary)?;
74        write_stack_and_containers(&tx, state)?;
75        write_blocks(&tx, state)?;
76        write_elements(&tx, state)?;
77        write_external_files(&tx, &state.external_files, &state.external_files_data)?;
78        write_version_graph(&tx, &state.version_graph)?;
79        tx.commit()?;
80
81        inner.execute_batch("PRAGMA foreign_keys = ON;")?;
82    }
83
84    // VACUUM creates another full copy of the sqlite image. Skip it for very
85    // large embedded-file exports where peak memory matters more than a compact
86    // final database layout.
87    if !should_optimize_for_large_embeds {
88        inner.execute_batch("VACUUM;")?;
89    }
90
91    let raw = export_db_bytes(&inner)?;
92
93    // For very large embedded-file exports, returning the raw sqlite image
94    // avoids another full-memory deflate copy. The parser already accepts raw
95    // sqlite `.duc` payloads.
96    if should_optimize_for_large_embeds {
97        return Ok(raw);
98    }
99
100    compress_duc_bytes(&raw)
101}
102
103fn total_external_file_data_bytes(state: &ExportedDataState) -> usize {
104    state
105        .external_files_data
106        .as_ref()
107        .map(|data| data.values().map(|blob| blob.len()).sum())
108        .unwrap_or(0)
109}
110
111// ─── Database export ─────────────────────────────────────────────────────────
112
113fn export_db_bytes(conn: &Connection) -> SerializeResult<Vec<u8>> {
114    // Use SQLite's in-memory serializer instead of filesystem temp files.
115    // This works on native and wasm targets.
116    let schema = b"main\0";
117
118    let mut size: rusqlite::ffi::sqlite3_int64 = 0;
119    let ptr = unsafe {
120        rusqlite::ffi::sqlite3_serialize(
121            conn.handle(),
122            schema.as_ptr() as *const c_char,
123            &mut size,
124            0,
125        )
126    };
127
128    if ptr.is_null() || size < 0 {
129        return Err(SerializeError::Io(
130            "sqlite3_serialize failed to export database bytes".into(),
131        ));
132    }
133
134    let bytes = unsafe {
135        let slice = std::slice::from_raw_parts(ptr as *const u8, size as usize);
136        let out = slice.to_vec();
137        rusqlite::ffi::sqlite3_free(ptr as *mut std::ffi::c_void);
138        out
139    };
140
141    Ok(bytes)
142}
143
144/// Compress raw SQLite bytes using a gzip (RFC 1952) stream.
145fn compress_duc_bytes(raw: &[u8]) -> SerializeResult<Vec<u8>> {
146    use flate2::write::GzEncoder;
147    use flate2::Compression;
148    use std::io::Write;
149
150    let mut encoder = GzEncoder::new(Vec::with_capacity(raw.len() / 4), Compression::default());
151    encoder.write_all(raw)?;
152    Ok(encoder.finish()?)
153}
154
155// ─── duc_document ────────────────────────────────────────────────────────────
156
157fn write_document(tx: &Transaction, state: &ExportedDataState) -> SerializeResult<()> {
158    tx.execute(
159        "INSERT OR REPLACE INTO duc_document (id, version, source, data_type, thumbnail)
160         VALUES (?1, ?2, ?3, ?4, ?5)",
161        params![
162            state.id.as_deref().unwrap_or(""),
163            state.version,
164            state.source,
165            state.data_type,
166            state.thumbnail.as_deref(),
167        ],
168    )?;
169    Ok(())
170}
171
172fn charter_phase_to_str(phase: DucCharterPhase) -> &'static str {
173    match phase {
174        DucCharterPhase::Intent => "intent",
175        DucCharterPhase::Review => "review",
176        DucCharterPhase::Delivery => "delivery",
177        DucCharterPhase::Closed => "closed",
178    }
179}
180
181fn issue_status_to_str(status: DucIssueStatus) -> &'static str {
182    match status {
183        DucIssueStatus::Open => "open",
184        DucIssueStatus::Closed => "closed",
185        DucIssueStatus::Dismissed => "dismissed",
186    }
187}
188
189fn write_charter(tx: &Transaction, charter: &Option<DucCharter>) -> SerializeResult<()> {
190    let Some(charter) = charter else { return Ok(()) };
191
192    tx.execute(
193        "INSERT OR REPLACE INTO duc_charter
194            (id, title, description, objective, phase, closed_reason, updated_at)
195         VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)",
196        params![
197            charter.title,
198            charter.description,
199            charter.objective,
200            charter_phase_to_str(charter.phase),
201            charter.closed_reason,
202            charter.updated_at,
203        ],
204    )?;
205
206    let mut req_stmt = tx.prepare_cached(
207        "INSERT INTO duc_charter_requirements (id, statement, must, sort_order)
208         VALUES (?1, ?2, ?3, ?4)"
209    )?;
210    let mut criteria_stmt = tx.prepare_cached(
211        "INSERT INTO duc_charter_requirement_acceptance_criteria (requirement_id, sort_order, criterion)
212         VALUES (?1, ?2, ?3)"
213    )?;
214    for (i, requirement) in charter.requirements.iter().enumerate() {
215        req_stmt.execute(params![requirement.id, requirement.statement, requirement.must as i32, i as i32])?;
216        if let Some(criteria) = &requirement.acceptance_criteria {
217            for (j, criterion) in criteria.iter().enumerate() {
218                criteria_stmt.execute(params![requirement.id, j as i32, criterion])?;
219            }
220        }
221    }
222
223    let mut constraint_stmt = tx.prepare_cached(
224        "INSERT INTO duc_charter_constraints (id, statement, hard, sort_order)
225         VALUES (?1, ?2, ?3, ?4)"
226    )?;
227    for (i, constraint) in charter.constraints.iter().enumerate() {
228        constraint_stmt.execute(params![constraint.id, constraint.statement, constraint.hard as i32, i as i32])?;
229    }
230
231    let mut decision_stmt = tx.prepare_cached(
232        "INSERT INTO duc_charter_decisions (id, accepted, decision, rationale, decided_at, sort_order)
233         VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
234    )?;
235    let mut decision_issue_stmt = tx.prepare_cached(
236        "INSERT INTO duc_charter_decision_issue_ids (decision_id, issue_id, sort_order)
237         VALUES (?1, ?2, ?3)"
238    )?;
239    for (i, decision) in charter.decisions.iter().enumerate() {
240        decision_stmt.execute(params![
241            decision.id,
242            decision.accepted as i32,
243            decision.decision,
244            decision.rationale,
245            decision.decided_at,
246            i as i32,
247        ])?;
248        if let Some(issue_ids) = &decision.issue_ids {
249            for (j, issue_id) in issue_ids.iter().enumerate() {
250                decision_issue_stmt.execute(params![decision.id, issue_id, j as i32])?;
251            }
252        }
253    }
254
255    if let Some(stakeholders) = &charter.stakeholders {
256        let mut stakeholder_stmt = tx.prepare_cached(
257            "INSERT INTO duc_charter_stakeholders (sort_order, actor_identifier, actor_name, role)
258             VALUES (?1, ?2, ?3, ?4)"
259        )?;
260        for (i, stakeholder) in stakeholders.iter().enumerate() {
261            stakeholder_stmt.execute(params![
262                i as i32,
263                stakeholder.actor.identifier,
264                stakeholder.actor.name,
265                stakeholder.role,
266            ])?;
267        }
268    }
269
270    Ok(())
271}
272
273fn write_issues(tx: &Transaction, issues: &[DucIssue]) -> SerializeResult<()> {
274    let mut issue_stmt = tx.prepare_cached(
275        "INSERT INTO duc_issues
276            (id, local_id, title, status, dismissed_reason, due_date, author_id,
277             created_at, updated_at, deleted_at, sort_order)
278         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"
279    )?;
280
281    for (i, issue) in issues.iter().enumerate() {
282        issue_stmt.execute(params![
283            issue.id,
284            issue.local_id,
285            issue.title,
286            issue_status_to_str(issue.status),
287            issue.dismissed_reason,
288            issue.due_date,
289            issue.author_id,
290            issue.created_at,
291            issue.updated_at,
292            issue.deleted_at,
293            i as i32,
294        ])?;
295        write_issue_actor_ids(tx, "duc_issue_assignees", &issue.id, issue.assignee_ids.as_deref())?;
296        write_issue_actor_ids(tx, "duc_issue_followers", &issue.id, issue.follower_ids.as_deref())?;
297        write_issue_messages(tx, &issue.id, &issue.messages)?;
298        if let Some(anchor) = &issue.anchor {
299            write_issue_anchor(tx, &issue.id, anchor)?;
300        }
301    }
302
303    Ok(())
304}
305
306fn write_issue_actor_ids(
307    tx: &Transaction,
308    table: &str,
309    issue_id: &str,
310    actor_ids: Option<&[String]>,
311) -> SerializeResult<()> {
312    let Some(actor_ids) = actor_ids else { return Ok(()) };
313    let sql = format!(
314        "INSERT OR REPLACE INTO {table} (issue_id, actor_identifier, sort_order) VALUES (?1, ?2, ?3)"
315    );
316    let mut stmt = tx.prepare_cached(&sql)?;
317    for (i, actor_id) in actor_ids.iter().enumerate() {
318        stmt.execute(params![issue_id, actor_id, i as i32])?;
319    }
320    Ok(())
321}
322
323fn write_issue_messages(tx: &Transaction, issue_id: &str, messages: &[DucIssueMessage]) -> SerializeResult<()> {
324    let mut message_stmt = tx.prepare_cached(
325        "INSERT INTO duc_issue_messages
326            (id, issue_id, author_identifier, author_name, content, reply_to_id,
327             created_at, edited_at, deleted_at, sort_order)
328         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"
329    )?;
330    let mut reaction_stmt = tx.prepare_cached(
331        "INSERT OR REPLACE INTO duc_issue_message_reactions
332            (message_id, emoji, actor_identifier, sort_order)
333         VALUES (?1, ?2, ?3, ?4)"
334    )?;
335
336    for (i, message) in messages.iter().enumerate() {
337        message_stmt.execute(params![
338            message.id,
339            issue_id,
340            message.author.identifier,
341            message.author.name,
342            message.content,
343            message.reply_to_id,
344            message.created_at,
345            message.edited_at,
346            message.deleted_at,
347            i as i32,
348        ])?;
349        if let Some(reactions) = &message.reactions {
350            for (emoji, actor_ids) in reactions {
351                for (j, actor_id) in actor_ids.iter().enumerate() {
352                    reaction_stmt.execute(params![message.id, emoji, actor_id, j as i32])?;
353                }
354            }
355        }
356    }
357
358    Ok(())
359}
360
361fn write_issue_anchor(tx: &Transaction, issue_id: &str, anchor: &DucIssueAnchor) -> SerializeResult<()> {
362    match anchor {
363        DucIssueAnchor::Canvas { x, y, scope } => {
364            tx.execute(
365                "INSERT INTO duc_issue_anchors (issue_id, anchor_type, canvas_x, canvas_y, canvas_scope)
366                 VALUES (?1, 'canvas', ?2, ?3, ?4)",
367                params![issue_id, x, y, scope],
368            )?;
369        }
370        DucIssueAnchor::Element { element_id, anchor_x, anchor_y } => {
371            tx.execute(
372                "INSERT INTO duc_issue_anchors (issue_id, anchor_type, element_id, anchor_x, anchor_y)
373                 VALUES (?1, 'element', ?2, ?3, ?4)",
374                params![issue_id, element_id, anchor_x, anchor_y],
375            )?;
376        }
377        DucIssueAnchor::Model { element_id, point, normal, viewer_state, topology_id } => {
378            tx.execute(
379                "INSERT INTO duc_issue_anchors (
380                    issue_id, anchor_type, element_id,
381                    model_point_x, model_point_y, model_point_z,
382                    model_normal_x, model_normal_y, model_normal_z, topology_id
383                 ) VALUES (?1, 'model', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
384                params![
385                    issue_id,
386                    element_id,
387                    point[0], point[1], point[2],
388                    normal.as_ref().map(|n| n[0]),
389                    normal.as_ref().map(|n| n[1]),
390                    normal.as_ref().map(|n| n[2]),
391                    topology_id,
392                ],
393            )?;
394            if let Some(viewer_state) = viewer_state {
395                write_model_viewer_state(tx, "issue_anchor", issue_id, viewer_state)?;
396            }
397        }
398    }
399    Ok(())
400}
401
402// ─── duc_global_state ────────────────────────────────────────────────────────
403
404fn write_global_state(tx: &Transaction, gs: &Option<DucGlobalState>) -> SerializeResult<()> {
405    let Some(gs) = gs else { return Ok(()) };
406    tx.execute(
407        "INSERT OR REPLACE INTO duc_global_state
408            (id, view_background_color, main_scope, scope_exponent_threshold)
409         VALUES (1, ?1, ?2, ?3)",
410        params![
411            gs.view_background_color,
412            gs.main_scope,
413            gs.scope_exponent_threshold,
414        ],
415    )?;
416    Ok(())
417}
418
419// ─── duc_local_state ─────────────────────────────────────────────────────────
420
421fn write_local_state(tx: &Transaction, ls: &Option<DucLocalState>) -> SerializeResult<()> {
422    let Some(ls) = ls else { return Ok(()) };
423    tx.execute(
424        "INSERT OR REPLACE INTO duc_local_state (
425            id, scope, scroll_x, scroll_y, zoom,
426            is_binding_enabled,
427            current_item_opacity, current_item_font_family, current_item_font_size,
428            current_item_text_align, current_item_roundness,
429            start_head_type, start_head_block_id, start_head_size,
430            end_head_type, end_head_block_id, end_head_size,
431            pen_mode, view_mode_enabled, objects_snap_mode_enabled,
432            grid_mode_enabled, outline_mode_enabled, manual_save_mode,
433            decimal_places
434        ) VALUES (
435            1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
436            ?11, ?12, ?13, ?14, ?15, ?16,
437            ?17, ?18, ?19, ?20, ?21, ?22, ?23
438        )",
439        params![
440            ls.scope,
441            ls.scroll_x,
442            ls.scroll_y,
443            ls.zoom,
444            ls.is_binding_enabled as i32,
445            ls.current_item_opacity,
446            ls.current_item_font_family,
447            ls.current_item_font_size,
448            ls.current_item_text_align as i32,
449            ls.current_item_roundness,
450            ls.current_item_start_line_head.as_ref().and_then(|h| h.head_type.map(|t| t as i32)),
451            ls.current_item_start_line_head.as_ref().and_then(|h| h.block_id.clone()),
452            ls.current_item_start_line_head.as_ref().map(|h| h.size),
453            ls.current_item_end_line_head.as_ref().and_then(|h| h.head_type.map(|t| t as i32)),
454            ls.current_item_end_line_head.as_ref().and_then(|h| h.block_id.clone()),
455            ls.current_item_end_line_head.as_ref().map(|h| h.size),
456            ls.pen_mode as i32,
457            ls.view_mode_enabled as i32,
458            ls.objects_snap_mode_enabled as i32,
459            ls.grid_mode_enabled as i32,
460            ls.outline_mode_enabled as i32,
461            ls.manual_save_mode as i32,
462            ls.decimal_places,
463        ],
464    )?;
465
466    if let Some(ref stroke) = ls.current_item_stroke {
467        write_stroke(tx, "local_state", "1", 0, stroke)?;
468    }
469    if let Some(ref bg) = ls.current_item_background {
470        write_background(tx, "local_state", "1", 0, bg)?;
471    }
472
473    Ok(())
474}
475
476// ─── dictionary ──────────────────────────────────────────────────────────────
477
478fn write_dictionary(tx: &Transaction, dict: &Option<std::collections::HashMap<String, String>>) -> SerializeResult<()> {
479    let Some(dict) = dict else { return Ok(()) };
480    let mut stmt = tx.prepare_cached(
481        "INSERT OR REPLACE INTO document_dictionary (key, value) VALUES (?1, ?2)"
482    )?;
483    for (key, value) in dict {
484        stmt.execute(params![key, value])?;
485    }
486    Ok(())
487}
488
489// ─── stack_properties, layers, groups, regions ───────────────────────────────
490
491fn write_stack_base(tx: &Transaction, id: &str, sb: &DucStackBase) -> SerializeResult<()> {
492    tx.execute(
493        "INSERT OR REPLACE INTO stack_properties
494            (id, label, description, is_collapsed, is_plot, is_visible, locked, opacity)
495         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
496        params![
497            id,
498            sb.label,
499            sb.description,
500            sb.is_collapsed as i32,
501            sb.is_plot as i32,
502            sb.is_visible as i32,
503            sb.locked as i32,
504            sb.styles.opacity,
505        ],
506    )?;
507    Ok(())
508}
509
510fn write_stack_and_containers(tx: &Transaction, state: &ExportedDataState) -> SerializeResult<()> {
511    for layer in &state.layers {
512        write_stack_base(tx, &layer.id, &layer.stack_base)?;
513        tx.execute(
514            "INSERT OR REPLACE INTO layers (id, readonly) VALUES (?1, ?2)",
515            params![layer.id, layer.readonly as i32],
516        )?;
517        if let Some(ref ov) = layer.overrides {
518            write_stroke(tx, "layer", &layer.id, 0, &ov.stroke)?;
519            write_background(tx, "layer", &layer.id, 0, &ov.background)?;
520        }
521    }
522    for group in &state.groups {
523        write_stack_base(tx, &group.id, &group.stack_base)?;
524        tx.execute(
525            "INSERT OR REPLACE INTO groups (id) VALUES (?1)",
526            params![group.id],
527        )?;
528    }
529    for region in &state.regions {
530        write_stack_base(tx, &region.id, &region.stack_base)?;
531        tx.execute(
532            "INSERT OR REPLACE INTO regions (id, boolean_operation) VALUES (?1, ?2)",
533            params![region.id, region.boolean_operation as i32],
534        )?;
535    }
536    Ok(())
537}
538
539// ─── blocks ──────────────────────────────────────────────────────────────────
540
541fn write_blocks(tx: &Transaction, state: &ExportedDataState) -> SerializeResult<()> {
542    for block in &state.blocks {
543        tx.execute(
544            "INSERT OR REPLACE INTO blocks (id, label, description, version)
545             VALUES (?1, ?2, ?3, ?4)",
546            params![block.id, block.label, block.description, block.version],
547        )?;
548        if let Some(ref meta) = block.metadata {
549            write_block_metadata(tx, "block", &block.id, meta, block.thumbnail.as_deref())?;
550        }
551    }
552
553    for inst in &state.block_instances {
554        let (dup_rows, dup_cols, dup_row_sp, dup_col_sp) = match &inst.duplication_array {
555            Some(da) => (Some(da.rows), Some(da.cols), Some(da.row_spacing), Some(da.col_spacing)),
556            None => (None, None, None, None),
557        };
558        tx.execute(
559            "INSERT OR REPLACE INTO block_instances
560                (id, block_id, version, dup_rows, dup_cols, dup_row_spacing, dup_col_spacing)
561             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
562            params![inst.id, inst.block_id, inst.version, dup_rows, dup_cols, dup_row_sp, dup_col_sp],
563        )?;
564        if let Some(ref overrides) = inst.element_overrides {
565            let mut stmt = tx.prepare_cached(
566                "INSERT OR REPLACE INTO block_instance_overrides (instance_id, key, value)
567                 VALUES (?1, ?2, ?3)"
568            )?;
569            for ov in overrides {
570                stmt.execute(params![inst.id, ov.key, ov.value])?;
571            }
572        }
573    }
574
575    for col in &state.block_collections {
576        tx.execute(
577            "INSERT OR REPLACE INTO block_collections (id, label) VALUES (?1, ?2)",
578            params![col.id, col.label],
579        )?;
580        if let Some(ref meta) = col.metadata {
581            write_block_metadata(tx, "collection", &col.id, meta, col.thumbnail.as_deref())?;
582        }
583        let mut stmt = tx.prepare_cached(
584            "INSERT OR REPLACE INTO block_collection_entries (collection_id, child_id, is_collection)
585             VALUES (?1, ?2, ?3)"
586        )?;
587        for child in &col.children {
588            stmt.execute(params![col.id, child.id, child.is_collection as i32])?;
589        }
590    }
591
592    Ok(())
593}
594
595fn write_block_metadata(
596    tx: &Transaction,
597    owner_type: &str,
598    owner_id: &str,
599    meta: &DucBlockMetadata,
600    thumbnail: Option<&[u8]>,
601) -> SerializeResult<()> {
602    tx.execute(
603        "INSERT OR REPLACE INTO block_metadata
604            (owner_type, owner_id, source, usage_count, created_at, updated_at, localization, thumbnail)
605         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
606        params![
607            owner_type,
608            owner_id,
609            meta.source,
610            meta.usage_count,
611            meta.created_at,
612            meta.updated_at,
613            meta.localization,
614            thumbnail,
615        ],
616    )?;
617    Ok(())
618}
619
620// ─── elements ────────────────────────────────────────────────────────────────
621
622fn write_elements(tx: &Transaction, state: &ExportedDataState) -> SerializeResult<()> {
623    for wrapper in &state.elements {
624        write_element_wrapper(tx, wrapper)?;
625    }
626    Ok(())
627}
628
629fn write_element_wrapper(tx: &Transaction, wrapper: &ElementWrapper) -> SerializeResult<()> {
630    match &wrapper.element {
631        DucElementEnum::DucRectangleElement(e) => {
632            write_base_element(tx, "rectangle", &e.base)?;
633        }
634        DucElementEnum::DucPolygonElement(e) => {
635            write_base_element(tx, "polygon", &e.base)?;
636            tx.execute(
637                "INSERT INTO element_polygon (element_id, sides) VALUES (?1, ?2)",
638                params![e.base.id, e.sides],
639            )?;
640        }
641        DucElementEnum::DucEllipseElement(e) => {
642            write_base_element(tx, "ellipse", &e.base)?;
643            tx.execute(
644                "INSERT INTO element_ellipse (element_id, ratio, start_angle, end_angle, show_aux_crosshair)
645                 VALUES (?1, ?2, ?3, ?4, ?5)",
646                params![e.base.id, e.ratio, e.start_angle, e.end_angle, e.show_aux_crosshair as i32],
647            )?;
648        }
649        DucElementEnum::DucEmbeddableElement(e) => {
650            write_base_element(tx, "embeddable", &e.base)?;
651            tx.execute(
652                "INSERT INTO element_embeddable (element_id) VALUES (?1)",
653                params![e.base.id],
654            )?;
655        }
656        DucElementEnum::DucTextElement(e) => {
657            write_base_element(tx, "text", &e.base)?;
658            write_text_element(tx, e)?;
659        }
660        DucElementEnum::DucImageElement(e) => {
661            write_base_element(tx, "image", &e.base)?;
662            write_image_element(tx, e)?;
663        }
664        DucElementEnum::DucFreeDrawElement(e) => {
665            write_base_element(tx, "freedraw", &e.base)?;
666            write_freedraw_element(tx, e)?;
667        }
668        DucElementEnum::DucLinearElement(e) => {
669            write_base_element(tx, "line", &e.linear_base.base)?;
670            write_linear_element(tx, &e.linear_base, e.wipeout_below, false)?;
671        }
672        DucElementEnum::DucArrowElement(e) => {
673            write_base_element(tx, "arrow", &e.linear_base.base)?;
674            write_linear_element(tx, &e.linear_base, false, e.elbowed)?;
675        }
676        DucElementEnum::DucFrameElement(e) => {
677            write_base_element(tx, "frame", &e.stack_element_base.base)?;
678            write_stack_element_base(tx, &e.stack_element_base)?;
679            tx.execute(
680                "INSERT INTO element_frame (element_id) VALUES (?1)",
681                params![e.stack_element_base.base.id],
682            )?;
683        }
684        DucElementEnum::DucPlotElement(e) => {
685            write_base_element(tx, "plot", &e.stack_element_base.base)?;
686            write_stack_element_base(tx, &e.stack_element_base)?;
687            tx.execute(
688                "INSERT INTO element_plot (element_id, margin_top, margin_right, margin_bottom, margin_left)
689                 VALUES (?1, ?2, ?3, ?4, ?5)",
690                params![
691                    e.stack_element_base.base.id,
692                    e.layout.margins.top,
693                    e.layout.margins.right,
694                    e.layout.margins.bottom,
695                    e.layout.margins.left,
696                ],
697            )?;
698        }
699        DucElementEnum::DucPdfElement(e) => {
700            write_base_element(tx, "pdf", &e.base)?;
701            write_document_grid_config(tx, &e.base.id, e.file_id.as_deref(), &e.grid_config)?;
702            tx.execute(
703                "INSERT INTO element_pdf (element_id) VALUES (?1)",
704                params![e.base.id],
705            )?;
706        }
707        DucElementEnum::DucDocElement(e) => {
708            write_base_element(tx, "doc", &e.base)?;
709            write_document_grid_config(tx, &e.base.id, e.file_id.as_deref(), &e.grid_config)?;
710            tx.execute(
711                "INSERT INTO element_doc (element_id, text) VALUES (?1, ?2)",
712                params![e.base.id, e.text],
713            )?;
714            write_doc_referenced_file_ids(tx, &e.base.id, &e.referenced_file_ids)?;
715        }
716        DucElementEnum::DucTableElement(e) => {
717            write_base_element(tx, "table", &e.base)?;
718            write_document_grid_config(tx, &e.base.id, e.file_id.as_deref(), &e.grid_config)?;
719            tx.execute(
720                "INSERT INTO element_table (element_id) VALUES (?1)",
721                params![e.base.id],
722            )?;
723        }
724        DucElementEnum::DucModelElement(e) => {
725            write_base_element(tx, "model", &e.base)?;
726            write_model_element(tx, e)?;
727        }
728    }
729    Ok(())
730}
731
732// ─── base element ────────────────────────────────────────────────────────────
733
734fn write_base_element(tx: &Transaction, element_type: &str, base: &DucElementBase) -> SerializeResult<()> {
735    tx.execute(
736        "INSERT INTO elements (
737            id, element_type,
738            x, y, width, height, angle,
739            scope, label, description, is_visible,
740            seed, version, version_nonce, updated, \"index\",
741            is_plot, is_deleted,
742            roundness, blending, opacity,
743            instance_id, layer_id, frame_id,
744            z_index, link, locked, custom_data
745        ) VALUES (
746            ?1, ?2,
747            ?3, ?4, ?5, ?6, ?7,
748            ?8, ?9, ?10, ?11,
749            ?12, ?13, ?14, ?15, ?16,
750            ?17, ?18,
751            ?19, ?20, ?21,
752            ?22, ?23, ?24,
753            ?25, ?26, ?27, ?28
754        )",
755        params![
756            base.id, element_type,
757            base.x, base.y, base.width, base.height, base.angle,
758            base.scope, base.label, base.description, base.is_visible as i32,
759            base.seed, base.version, base.version_nonce, base.updated, base.index,
760            base.is_plot as i32, base.is_deleted as i32,
761            base.styles.roundness, base.styles.blending.map(|b| b as i32), base.styles.opacity,
762            base.instance_id, base.layer_id, base.frame_id,
763            base.z_index, base.link, base.locked as i32, base.custom_data,
764        ],
765    )?;
766
767    for (i, bg) in base.styles.background.iter().enumerate() {
768        write_background(tx, "element", &base.id, i as i32, bg)?;
769    }
770    for (i, st) in base.styles.stroke.iter().enumerate() {
771        write_stroke(tx, "element", &base.id, i as i32, st)?;
772    }
773
774    if let Some(ref bound) = base.bound_elements {
775        let mut stmt = tx.prepare_cached(
776            "INSERT INTO element_bound_elements (element_id, bound_element_id, bound_type, sort_order)
777             VALUES (?1, ?2, ?3, ?4)"
778        )?;
779        for (i, be) in bound.iter().enumerate() {
780            stmt.execute(params![base.id, be.id, be.element_type, i as i32])?;
781        }
782    }
783
784    {
785        let mut stmt = tx.prepare_cached(
786            "INSERT INTO element_group_memberships (element_id, group_id, sort_order) VALUES (?1, ?2, ?3)"
787        )?;
788        for (i, gid) in base.group_ids.iter().enumerate() {
789            stmt.execute(params![base.id, gid, i as i32])?;
790        }
791    }
792
793    {
794        let mut stmt = tx.prepare_cached(
795            "INSERT INTO element_block_memberships (element_id, block_id, sort_order) VALUES (?1, ?2, ?3)"
796        )?;
797        for (i, bid) in base.block_ids.iter().enumerate() {
798            stmt.execute(params![base.id, bid, i as i32])?;
799        }
800    }
801
802    {
803        let mut stmt = tx.prepare_cached(
804            "INSERT INTO element_region_memberships (element_id, region_id, sort_order) VALUES (?1, ?2, ?3)"
805        )?;
806        for (i, rid) in base.region_ids.iter().enumerate() {
807            stmt.execute(params![base.id, rid, i as i32])?;
808        }
809    }
810
811    Ok(())
812}
813
814// ─── element_stack_properties ────────────────────────────────────────────────
815
816fn write_stack_element_base(tx: &Transaction, seb: &DucStackElementBase) -> SerializeResult<()> {
817    tx.execute(
818        "INSERT INTO element_stack_properties
819            (element_id, label, description, is_collapsed, is_plot, is_visible, locked, opacity, clip, label_visible)
820         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
821        params![
822            seb.base.id,
823            seb.stack_base.label,
824            seb.stack_base.description,
825            seb.stack_base.is_collapsed as i32,
826            seb.stack_base.is_plot as i32,
827            seb.stack_base.is_visible as i32,
828            seb.stack_base.locked as i32,
829            seb.stack_base.styles.opacity,
830            seb.clip as i32,
831            seb.label_visible as i32,
832        ],
833    )?;
834    Ok(())
835}
836
837// ─── element_text ────────────────────────────────────────────────────────────
838
839fn write_text_element(tx: &Transaction, e: &DucTextElement) -> SerializeResult<()> {
840    tx.execute(
841        "INSERT INTO element_text (
842            element_id, text, original_text, auto_resize, container_id,
843            is_ltr, font_family, big_font_family, text_align, vertical_align,
844            line_height, line_spacing_value, line_spacing_type,
845            oblique_angle, font_size, width_factor,
846            is_upside_down, is_backwards
847        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
848        params![
849            e.base.id,
850            e.text,
851            e.original_text,
852            e.auto_resize as i32,
853            e.container_id,
854            e.style.is_ltr as i32,
855            e.style.font_family,
856            e.style.big_font_family,
857            e.style.text_align as i32,
858            e.style.vertical_align as i32,
859            e.style.line_height,
860            e.style.line_spacing.value,
861            e.style.line_spacing.line_type.map(|t| t as i32),
862            e.style.oblique_angle,
863            e.style.font_size,
864            e.style.width_factor,
865            e.style.is_upside_down as i32,
866            e.style.is_backwards as i32,
867        ],
868    )?;
869    Ok(())
870}
871
872// ─── element_image ───────────────────────────────────────────────────────────
873
874fn write_image_element(tx: &Transaction, e: &DucImageElement) -> SerializeResult<()> {
875    let scale_x = e.scale.first().copied().unwrap_or(1.0);
876    let scale_y = e.scale.get(1).copied().unwrap_or(1.0);
877    tx.execute(
878        "INSERT INTO element_image (
879            element_id, file_id, status, scale_x, scale_y,
880            crop_x, crop_y, crop_width, crop_height, crop_natural_width, crop_natural_height,
881            filter_brightness, filter_contrast
882        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
883        params![
884            e.base.id,
885            e.file_id,
886            e.status as i32,
887            scale_x,
888            scale_y,
889            e.crop.as_ref().map(|c| c.x),
890            e.crop.as_ref().map(|c| c.y),
891            e.crop.as_ref().map(|c| c.width),
892            e.crop.as_ref().map(|c| c.height),
893            e.crop.as_ref().map(|c| c.natural_width),
894            e.crop.as_ref().map(|c| c.natural_height),
895            e.filter.as_ref().map(|f| f.brightness),
896            e.filter.as_ref().map(|f| f.contrast),
897        ],
898    )?;
899    Ok(())
900}
901
902// ─── element_freedraw ────────────────────────────────────────────────────────
903
904fn write_freedraw_element(tx: &Transaction, e: &DucFreeDrawElement) -> SerializeResult<()> {
905    let pressures_blob: Option<Vec<u8>> = if e.pressures.is_empty() {
906        None
907    } else {
908        Some(e.pressures.iter().flat_map(|p| p.to_le_bytes()).collect())
909    };
910
911    tx.execute(
912        "INSERT INTO element_freedraw (
913            element_id, size, thinning, smoothing, streamline, easing,
914            start_cap, start_taper, start_easing,
915            end_cap, end_taper, end_easing,
916            pressures, simulate_pressure,
917            last_committed_point_x, last_committed_point_y, last_committed_point_mirror,
918            svg_path
919        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
920        params![
921            e.base.id,
922            e.size,
923            e.thinning,
924            e.smoothing,
925            e.streamline,
926            e.easing,
927            e.start.as_ref().map(|s| s.cap as i32),
928            e.start.as_ref().map(|s| s.taper),
929            e.start.as_ref().map(|s| s.easing.clone()),
930            e.end.as_ref().map(|en| en.cap as i32),
931            e.end.as_ref().map(|en| en.taper),
932            e.end.as_ref().map(|en| en.easing.clone()),
933            pressures_blob,
934            e.simulate_pressure as i32,
935            e.last_committed_point.as_ref().map(|p| p.x),
936            e.last_committed_point.as_ref().map(|p| p.y),
937            e.last_committed_point.as_ref().and_then(|p| p.mirroring.map(|m| m as i32)),
938            e.svg_path,
939        ],
940    )?;
941
942    let mut stmt = tx.prepare_cached(
943        "INSERT INTO freedraw_element_points (element_id, sort_order, x, y, mirroring)
944         VALUES (?1, ?2, ?3, ?4, ?5)"
945    )?;
946    for (i, pt) in e.points.iter().enumerate() {
947        stmt.execute(params![e.base.id, i as i32, pt.x, pt.y, pt.mirroring.map(|m| m as i32)])?;
948    }
949
950    Ok(())
951}
952
953// ─── element_linear ──────────────────────────────────────────────────────────
954
955fn write_linear_element(
956    tx: &Transaction,
957    lb: &DucLinearElementBase,
958    wipeout_below: bool,
959    elbowed: bool,
960) -> SerializeResult<()> {
961    let id = &lb.base.id;
962
963    tx.execute(
964        "INSERT INTO element_linear (
965            element_id,
966            last_committed_point_x, last_committed_point_y, last_committed_point_mirror,
967            start_binding_element_id, start_binding_focus, start_binding_gap,
968            start_binding_fixed_point_x, start_binding_fixed_point_y,
969            start_binding_point_index, start_binding_point_offset,
970            start_binding_head_type, start_binding_head_block_id, start_binding_head_size,
971            end_binding_element_id, end_binding_focus, end_binding_gap,
972            end_binding_fixed_point_x, end_binding_fixed_point_y,
973            end_binding_point_index, end_binding_point_offset,
974            end_binding_head_type, end_binding_head_block_id, end_binding_head_size,
975            wipeout_below, elbowed
976        ) VALUES (
977            ?1,
978            ?2, ?3, ?4,
979            ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
980            ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24,
981            ?25, ?26
982        )",
983        params![
984            id,
985            lb.last_committed_point.as_ref().map(|p| p.x),
986            lb.last_committed_point.as_ref().map(|p| p.y),
987            lb.last_committed_point.as_ref().and_then(|p| p.mirroring.map(|m| m as i32)),
988            lb.start_binding.as_ref().map(|b| b.element_id.clone()),
989            lb.start_binding.as_ref().map(|b| b.focus),
990            lb.start_binding.as_ref().map(|b| b.gap),
991            lb.start_binding.as_ref().and_then(|b| b.fixed_point.as_ref().map(|fp| fp.x)),
992            lb.start_binding.as_ref().and_then(|b| b.fixed_point.as_ref().map(|fp| fp.y)),
993            lb.start_binding.as_ref().and_then(|b| b.point.as_ref().map(|p| p.index)),
994            lb.start_binding.as_ref().and_then(|b| b.point.as_ref().map(|p| p.offset)),
995            lb.start_binding.as_ref().and_then(|b| b.head.as_ref().and_then(|h| h.head_type.map(|t| t as i32))),
996            lb.start_binding.as_ref().and_then(|b| b.head.as_ref().and_then(|h| h.block_id.clone())),
997            lb.start_binding.as_ref().and_then(|b| b.head.as_ref().map(|h| h.size)),
998            lb.end_binding.as_ref().map(|b| b.element_id.clone()),
999            lb.end_binding.as_ref().map(|b| b.focus),
1000            lb.end_binding.as_ref().map(|b| b.gap),
1001            lb.end_binding.as_ref().and_then(|b| b.fixed_point.as_ref().map(|fp| fp.x)),
1002            lb.end_binding.as_ref().and_then(|b| b.fixed_point.as_ref().map(|fp| fp.y)),
1003            lb.end_binding.as_ref().and_then(|b| b.point.as_ref().map(|p| p.index)),
1004            lb.end_binding.as_ref().and_then(|b| b.point.as_ref().map(|p| p.offset)),
1005            lb.end_binding.as_ref().and_then(|b| b.head.as_ref().and_then(|h| h.head_type.map(|t| t as i32))),
1006            lb.end_binding.as_ref().and_then(|b| b.head.as_ref().and_then(|h| h.block_id.clone())),
1007            lb.end_binding.as_ref().and_then(|b| b.head.as_ref().map(|h| h.size)),
1008            wipeout_below as i32,
1009            elbowed as i32,
1010        ],
1011    )?;
1012
1013    {
1014        let mut stmt = tx.prepare_cached(
1015            "INSERT INTO linear_element_points (element_id, sort_order, x, y, mirroring)
1016             VALUES (?1, ?2, ?3, ?4, ?5)"
1017        )?;
1018        for (i, pt) in lb.points.iter().enumerate() {
1019            stmt.execute(params![id, i as i32, pt.x, pt.y, pt.mirroring.map(|m| m as i32)])?;
1020        }
1021    }
1022
1023    {
1024        let mut stmt = tx.prepare_cached(
1025            "INSERT INTO linear_element_lines (element_id, sort_order, start_index, start_handle_x, start_handle_y, end_index, end_handle_x, end_handle_y)
1026             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
1027        )?;
1028        for (i, line) in lb.lines.iter().enumerate() {
1029            stmt.execute(params![
1030                id,
1031                i as i32,
1032                line.start.index,
1033                line.start.handle.as_ref().map(|h| h.x),
1034                line.start.handle.as_ref().map(|h| h.y),
1035                line.end.index,
1036                line.end.handle.as_ref().map(|h| h.x),
1037                line.end.handle.as_ref().map(|h| h.y),
1038            ])?;
1039        }
1040    }
1041
1042    for (i, path) in lb.path_overrides.iter().enumerate() {
1043        let path_id: i64 = tx.query_row(
1044            "INSERT INTO linear_path_overrides (element_id, sort_order) VALUES (?1, ?2) RETURNING id",
1045            params![id, i as i32],
1046            |row| row.get(0),
1047        )?;
1048
1049        let mut idx_stmt = tx.prepare_cached(
1050            "INSERT INTO linear_path_override_indices (path_override_id, sort_order, line_index)
1051             VALUES (?1, ?2, ?3)"
1052        )?;
1053        for (j, &line_idx) in path.line_indices.iter().enumerate() {
1054            idx_stmt.execute(params![path_id, j as i32, line_idx])?;
1055        }
1056
1057        if let Some(ref bg) = path.background {
1058            write_background(tx, "path_override", &path_id.to_string(), 0, bg)?;
1059        }
1060        if let Some(ref st) = path.stroke {
1061            write_stroke(tx, "path_override", &path_id.to_string(), 0, st)?;
1062        }
1063    }
1064
1065    Ok(())
1066}
1067
1068// ─── document_grid_config ────────────────────────────────────────────────────
1069
1070fn write_document_grid_config(
1071    tx: &Transaction,
1072    element_id: &str,
1073    file_id: Option<&str>,
1074    gc: &DocumentGridConfig,
1075) -> SerializeResult<()> {
1076    tx.execute(
1077        "INSERT INTO document_grid_config
1078            (element_id, file_id, grid_columns, grid_gap_x, grid_gap_y, grid_first_page_alone, grid_scale)
1079         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1080        params![element_id, file_id, gc.columns, gc.gap_x, gc.gap_y, gc.first_page_alone as i32, gc.scale],
1081    )?;
1082    Ok(())
1083}
1084
1085// ─── element_model ───────────────────────────────────────────────────────────
1086
1087fn write_model_element(tx: &Transaction, e: &DucModelElement) -> SerializeResult<()> {
1088    tx.execute(
1089        "INSERT INTO element_model (element_id, model_type, code, thumbnail)
1090         VALUES (?1, ?2, ?3, ?4)",
1091        params![e.base.id, e.model_type, e.code, e.thumbnail],
1092    )?;
1093
1094    write_element_file_ids(tx, &e.base.id, &e.file_ids)?;
1095
1096    if let Some(ref vs) = e.viewer_state {
1097        write_model_viewer_state(tx, "element", &e.base.id, vs)?;
1098    }
1099
1100    Ok(())
1101}
1102
1103fn write_element_file_ids(tx: &Transaction, element_id: &str, file_ids: &[String]) -> SerializeResult<()> {
1104    let mut stmt = tx.prepare_cached(
1105        "INSERT OR IGNORE INTO model_element_files (element_id, file_id, sort_order) VALUES (?1, ?2, ?3)"
1106    )?;
1107    for (i, fid) in file_ids.iter().enumerate() {
1108        stmt.execute(params![element_id, fid, i as i32])?;
1109    }
1110    Ok(())
1111}
1112
1113fn write_doc_referenced_file_ids(tx: &Transaction, element_id: &str, file_ids: &[String]) -> SerializeResult<()> {
1114    let mut stmt = tx.prepare_cached(
1115        "INSERT OR IGNORE INTO doc_element_referenced_files (element_id, file_id, sort_order) VALUES (?1, ?2, ?3)"
1116    )?;
1117    for (i, fid) in file_ids.iter().enumerate() {
1118        stmt.execute(params![element_id, fid, i as i32])?;
1119    }
1120    Ok(())
1121}
1122
1123fn write_model_viewer_state(
1124    tx: &Transaction,
1125    owner_type: &str,
1126    owner_id: &str,
1127    vs: &Viewer3DState,
1128) -> SerializeResult<()> {
1129    let cam = &vs.camera;
1130    let disp = &vs.display;
1131    let mat = &vs.material;
1132    let clip = &vs.clipping;
1133    let expl = &vs.explode;
1134    let zeb = &vs.zebra;
1135
1136    let (grid_uniform, grid_xy, grid_xz, grid_yz) = match &disp.grid {
1137        Viewer3DGrid::Uniform(v) => (Some(*v as i32), 0i32, 0i32, 0i32),
1138        Viewer3DGrid::PerPlane(p) => (None, p.xy as i32, p.xz as i32, p.yz as i32),
1139    };
1140
1141    tx.execute(
1142        "INSERT INTO model_viewer_state (
1143            owner_type, owner_id,
1144            camera_control, camera_ortho, camera_up,
1145            camera_position_x, camera_position_y, camera_position_z,
1146            camera_quaternion_x, camera_quaternion_y, camera_quaternion_z, camera_quaternion_w,
1147            camera_target_x, camera_target_y, camera_target_z,
1148            camera_zoom, camera_pan_speed, camera_rotate_speed, camera_zoom_speed, camera_holroyd,
1149            display_wireframe, display_transparent, display_black_edges,
1150            display_grid_uniform, display_grid_xy, display_grid_xz, display_grid_yz,
1151            display_axes_visible, display_axes_at_origin,
1152            material_metalness, material_roughness, material_default_opacity,
1153            material_edge_color, material_ambient_intensity, material_direct_intensity,
1154            clip_x_enabled, clip_x_value, clip_x_normal_x, clip_x_normal_y, clip_x_normal_z,
1155            clip_y_enabled, clip_y_value, clip_y_normal_x, clip_y_normal_y, clip_y_normal_z,
1156            clip_z_enabled, clip_z_value, clip_z_normal_x, clip_z_normal_y, clip_z_normal_z,
1157            clip_intersection, clip_show_planes, clip_object_color_caps,
1158            explode_active, explode_value,
1159            zebra_active, zebra_stripe_count, zebra_stripe_direction,
1160            zebra_color_scheme, zebra_opacity, zebra_mapping_mode
1161        ) VALUES (
1162            ?, ?,
1163            ?, ?, ?,
1164            ?, ?, ?,
1165            ?, ?, ?, ?,
1166            ?, ?, ?,
1167            ?, ?, ?, ?, ?,
1168            ?, ?, ?,
1169            ?, ?, ?, ?,
1170            ?, ?,
1171            ?, ?, ?,
1172            ?, ?, ?,
1173            ?, ?, ?, ?, ?,
1174            ?, ?, ?, ?, ?,
1175            ?, ?, ?, ?, ?,
1176            ?, ?, ?,
1177            ?, ?,
1178            ?, ?, ?,
1179            ?, ?, ?
1180        )",
1181        params![
1182            owner_type,
1183            owner_id,
1184            cam.control, cam.ortho as i32, cam.up,
1185            cam.position[0], cam.position[1], cam.position[2],
1186            cam.quaternion[0], cam.quaternion[1], cam.quaternion[2], cam.quaternion[3],
1187            cam.target[0], cam.target[1], cam.target[2],
1188            cam.zoom, cam.pan_speed, cam.rotate_speed, cam.zoom_speed, cam.holroyd as i32,
1189            disp.wireframe as i32, disp.transparent as i32, disp.black_edges as i32,
1190            grid_uniform, grid_xy, grid_xz, grid_yz,
1191            disp.axes_visible as i32, disp.axes_at_origin as i32,
1192            mat.metalness, mat.roughness, mat.default_opacity,
1193            mat.edge_color, mat.ambient_intensity, mat.direct_intensity,
1194            clip.x.enabled as i32, clip.x.value,
1195            clip.x.normal.as_ref().map(|n| n[0]),
1196            clip.x.normal.as_ref().map(|n| n[1]),
1197            clip.x.normal.as_ref().map(|n| n[2]),
1198            clip.y.enabled as i32, clip.y.value,
1199            clip.y.normal.as_ref().map(|n| n[0]),
1200            clip.y.normal.as_ref().map(|n| n[1]),
1201            clip.y.normal.as_ref().map(|n| n[2]),
1202            clip.z.enabled as i32, clip.z.value,
1203            clip.z.normal.as_ref().map(|n| n[0]),
1204            clip.z.normal.as_ref().map(|n| n[1]),
1205            clip.z.normal.as_ref().map(|n| n[2]),
1206            clip.intersection as i32, clip.show_planes as i32, clip.object_color_caps as i32,
1207            expl.active as i32, expl.value,
1208            zeb.active as i32, zeb.stripe_count, zeb.stripe_direction,
1209            zeb.color_scheme, zeb.opacity, zeb.mapping_mode,
1210        ],
1211    )?;
1212    Ok(())
1213}
1214
1215// ─── backgrounds & strokes (polymorphic) ─────────────────────────────────────
1216
1217fn write_background(
1218    tx: &Transaction,
1219    owner_type: &str,
1220    owner_id: &str,
1221    sort_order: i32,
1222    bg: &ElementBackground,
1223) -> SerializeResult<()> {
1224    let c = &bg.content;
1225    tx.execute(
1226        "INSERT INTO backgrounds (
1227            owner_type, owner_id, sort_order,
1228            preference, src, visible, opacity,
1229            tiling_size_in_percent, tiling_angle, tiling_spacing, tiling_offset_x, tiling_offset_y,
1230            hatch_style, hatch_pattern_name, hatch_pattern_scale, hatch_pattern_angle,
1231            hatch_pattern_origin_x, hatch_pattern_origin_y, hatch_pattern_origin_mirror,
1232            hatch_pattern_double,
1233            hatch_custom_pattern_name, hatch_custom_pattern_desc,
1234            image_filter_brightness, image_filter_contrast
1235        ) VALUES (
1236            ?1, ?2, ?3, ?4, ?5, ?6, ?7,
1237            ?8, ?9, ?10, ?11, ?12,
1238            ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24
1239        )",
1240        params![
1241            owner_type, owner_id, sort_order,
1242            c.preference.map(|p| p as i32), c.src, c.visible as i32, c.opacity,
1243            c.tiling.as_ref().map(|t| t.size_in_percent),
1244            c.tiling.as_ref().map(|t| t.angle),
1245            c.tiling.as_ref().and_then(|t| t.spacing),
1246            c.tiling.as_ref().and_then(|t| t.offset_x),
1247            c.tiling.as_ref().and_then(|t| t.offset_y),
1248            c.hatch.as_ref().map(|h| h.hatch_style as i32),
1249            c.hatch.as_ref().map(|h| h.pattern_name.clone()),
1250            c.hatch.as_ref().map(|h| h.pattern_scale),
1251            c.hatch.as_ref().map(|h| h.pattern_angle),
1252            c.hatch.as_ref().map(|h| h.pattern_origin.x),
1253            c.hatch.as_ref().map(|h| h.pattern_origin.y),
1254            c.hatch.as_ref().and_then(|h| h.pattern_origin.mirroring.map(|m| m as i32)),
1255            c.hatch.as_ref().map(|h| h.pattern_double as i32),
1256            c.hatch.as_ref().and_then(|h| h.custom_pattern.as_ref().map(|cp| cp.name.clone())),
1257            c.hatch.as_ref().and_then(|h| h.custom_pattern.as_ref().and_then(|cp| cp.description.clone())),
1258            c.image_filter.as_ref().map(|f| f.brightness),
1259            c.image_filter.as_ref().map(|f| f.contrast),
1260        ],
1261    )?;
1262
1263    if let Some(ref hatch) = c.hatch {
1264        if let Some(ref cp) = hatch.custom_pattern {
1265            let bg_id: i64 = tx.query_row(
1266                "SELECT last_insert_rowid()", [], |row| row.get(0),
1267            )?;
1268            write_hatch_pattern_lines(tx, "background", bg_id, &cp.lines)?;
1269        }
1270    }
1271
1272    Ok(())
1273}
1274
1275fn write_stroke(
1276    tx: &Transaction,
1277    owner_type: &str,
1278    owner_id: &str,
1279    sort_order: i32,
1280    st: &ElementStroke,
1281) -> SerializeResult<()> {
1282    let c = &st.content;
1283    let dash_blob: Option<Vec<u8>> = st.style.dash.as_ref().map(|d| {
1284        d.iter().flat_map(|v| v.to_le_bytes()).collect()
1285    });
1286    let sides_blob: Option<Vec<u8>> = st.stroke_sides.as_ref().and_then(|s| {
1287        s.values.as_ref().map(|v| v.iter().flat_map(|val| val.to_le_bytes()).collect())
1288    });
1289
1290    tx.execute(
1291        "INSERT INTO strokes (
1292            owner_type, owner_id, sort_order,
1293            preference, src, visible, opacity,
1294            tiling_size_in_percent, tiling_angle, tiling_spacing, tiling_offset_x, tiling_offset_y,
1295            hatch_style, hatch_pattern_name, hatch_pattern_scale, hatch_pattern_angle,
1296            hatch_pattern_origin_x, hatch_pattern_origin_y, hatch_pattern_origin_mirror,
1297            hatch_pattern_double,
1298            hatch_custom_pattern_name, hatch_custom_pattern_desc,
1299            image_filter_brightness, image_filter_contrast,
1300            width,
1301            style_preference, style_cap, style_join, style_dash, style_dash_line_override, style_dash_cap, style_miter_limit,
1302            placement,
1303            sides_preference, sides_values
1304        ) VALUES (
1305            ?1, ?2, ?3,
1306            ?4, ?5, ?6, ?7,
1307            ?8, ?9, ?10, ?11, ?12,
1308            ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24,
1309            ?25,
1310            ?26, ?27, ?28, ?29, ?30, ?31, ?32,
1311            ?33,
1312            ?34, ?35
1313        )",
1314        params![
1315            owner_type, owner_id, sort_order,
1316            c.preference.map(|p| p as i32), c.src, c.visible as i32, c.opacity,
1317            c.tiling.as_ref().map(|t| t.size_in_percent),
1318            c.tiling.as_ref().map(|t| t.angle),
1319            c.tiling.as_ref().and_then(|t| t.spacing),
1320            c.tiling.as_ref().and_then(|t| t.offset_x),
1321            c.tiling.as_ref().and_then(|t| t.offset_y),
1322            c.hatch.as_ref().map(|h| h.hatch_style as i32),
1323            c.hatch.as_ref().map(|h| h.pattern_name.clone()),
1324            c.hatch.as_ref().map(|h| h.pattern_scale),
1325            c.hatch.as_ref().map(|h| h.pattern_angle),
1326            c.hatch.as_ref().map(|h| h.pattern_origin.x),
1327            c.hatch.as_ref().map(|h| h.pattern_origin.y),
1328            c.hatch.as_ref().and_then(|h| h.pattern_origin.mirroring.map(|m| m as i32)),
1329            c.hatch.as_ref().map(|h| h.pattern_double as i32),
1330            c.hatch.as_ref().and_then(|h| h.custom_pattern.as_ref().map(|cp| cp.name.clone())),
1331            c.hatch.as_ref().and_then(|h| h.custom_pattern.as_ref().and_then(|cp| cp.description.clone())),
1332            c.image_filter.as_ref().map(|f| f.brightness),
1333            c.image_filter.as_ref().map(|f| f.contrast),
1334            st.width,
1335            st.style.preference.map(|p| p as i32),
1336            st.style.cap.map(|p| p as i32),
1337            st.style.join.map(|p| p as i32),
1338            dash_blob,
1339            st.style.dash_line_override,
1340            st.style.dash_cap.map(|p| p as i32),
1341            st.style.miter_limit,
1342            st.placement.map(|p| p as i32),
1343            st.stroke_sides.as_ref().and_then(|s| s.preference.map(|p| p as i32)),
1344            sides_blob,
1345        ],
1346    )?;
1347
1348    if let Some(ref hatch) = c.hatch {
1349        if let Some(ref cp) = hatch.custom_pattern {
1350            let st_id: i64 = tx.query_row(
1351                "SELECT last_insert_rowid()", [], |row| row.get(0),
1352            )?;
1353            write_hatch_pattern_lines(tx, "stroke", st_id, &cp.lines)?;
1354        }
1355    }
1356
1357    Ok(())
1358}
1359
1360fn write_hatch_pattern_lines(
1361    tx: &Transaction,
1362    owner_type: &str,
1363    owner_id: i64,
1364    lines: &[HatchPatternLine],
1365) -> SerializeResult<()> {
1366    let mut stmt = tx.prepare_cached(
1367        "INSERT INTO hatch_pattern_lines (
1368            owner_type, owner_id, sort_order,
1369            angle, origin_x, origin_y, origin_mirroring,
1370            offset_x, offset_y, dash_pattern
1371        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"
1372    )?;
1373    for (i, line) in lines.iter().enumerate() {
1374        let offset_x = line.offset.first().copied().unwrap_or(0.0);
1375        let offset_y = line.offset.get(1).copied().unwrap_or(0.0);
1376        let dash_blob: Option<Vec<u8>> = if line.dash_pattern.is_empty() {
1377            None
1378        } else {
1379            Some(line.dash_pattern.iter().flat_map(|v| v.to_le_bytes()).collect())
1380        };
1381        stmt.execute(params![
1382            owner_type,
1383            owner_id,
1384            i as i32,
1385            line.angle,
1386            line.origin.x,
1387            line.origin.y,
1388            line.origin.mirroring.map(|m| m as i32),
1389            offset_x,
1390            offset_y,
1391            dash_blob,
1392        ])?;
1393    }
1394    Ok(())
1395}
1396
1397// ─── external_files ──────────────────────────────────────────────────────────
1398
1399fn write_external_files(
1400    tx: &Transaction,
1401    files: &Option<std::collections::HashMap<String, DucExternalFile>>,
1402    files_data: &Option<std::collections::HashMap<String, serde_bytes::ByteBuf>>,
1403) -> SerializeResult<()> {
1404    let Some(files) = files else { return Ok(()) };
1405    let mut file_stmt = tx.prepare_cached(
1406        "INSERT OR REPLACE INTO external_files (id, active_revision_id, updated, version)
1407         VALUES (?1, ?2, ?3, ?4)"
1408    )?;
1409    let mut rev_stmt = tx.prepare_cached(
1410        "INSERT OR REPLACE INTO external_file_revisions
1411            (id, file_id, size_bytes, checksum, source_name, mime_type, message, created, last_retrieved)
1412         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
1413    )?;
1414    let mut data_stmt = tx.prepare_cached(
1415        "INSERT OR REPLACE INTO external_file_revision_data (revision_id, data)
1416         VALUES (?1, ?2)"
1417    )?;
1418    for (_key, file) in files {
1419        file_stmt.execute(params![
1420            file.id,
1421            file.active_revision_id,
1422            file.updated,
1423            file.version,
1424        ])?;
1425        for (_rev_key, rev) in &file.revisions {
1426            rev_stmt.execute(params![
1427                rev.id,
1428                file.id,
1429                rev.size_bytes,
1430                rev.checksum,
1431                rev.source_name,
1432                rev.mime_type,
1433                rev.message,
1434                rev.created,
1435                rev.last_retrieved,
1436            ])?;
1437        }
1438    }
1439    // Write data blobs from the separate filesData map
1440    if let Some(data_map) = files_data {
1441        for (rev_id, blob) in data_map {
1442            data_stmt.execute(params![rev_id, blob.as_ref()])?;
1443        }
1444    }
1445    Ok(())
1446}
1447
1448// ─── version_graph ───────────────────────────────────────────────────────────
1449
1450fn write_version_graph(tx: &Transaction, vg: &Option<VersionGraph>) -> SerializeResult<()> {
1451    let Some(vg) = vg else { return Ok(()) };
1452
1453    tx.execute(
1454        "UPDATE version_graph SET
1455            current_version = ?1,
1456            current_schema_version = ?2,
1457            user_checkpoint_version_id = ?3,
1458            latest_version_id = ?4,
1459            chain_count = ?5,
1460            total_size = ?6
1461         WHERE id = 1",
1462        params![
1463            vg.metadata.current_version,
1464            vg.metadata.current_schema_version,
1465            vg.user_checkpoint_version_id,
1466            vg.latest_version_id,
1467            vg.metadata.chain_count,
1468            vg.metadata.total_size,
1469        ],
1470    )?;
1471
1472    {
1473        let mut stmt = tx.prepare_cached(
1474            "INSERT OR REPLACE INTO version_chains
1475                (id, schema_version, start_version, end_version, migration_id, root_checkpoint_id)
1476             VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
1477        )?;
1478        for chain in &vg.chains {
1479            let migration_id: Option<i64> = if let Some(ref mig) = chain.migration {
1480                let mid: i64 = tx.query_row(
1481                    "INSERT INTO schema_migrations
1482                        (from_schema_version, to_schema_version, migration_name, migration_checksum, applied_at, boundary_checkpoint_id)
1483                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1484                     RETURNING id",
1485                    params![
1486                        mig.from_schema_version,
1487                        mig.to_schema_version,
1488                        mig.migration_name,
1489                        mig.migration_checksum,
1490                        mig.applied_at,
1491                        mig.boundary_checkpoint_id,
1492                    ],
1493                    |row| row.get(0),
1494                )?;
1495                Some(mid)
1496            } else {
1497                None
1498            };
1499
1500            stmt.execute(params![
1501                chain.id,
1502                chain.schema_version,
1503                chain.start_version,
1504                chain.end_version,
1505                migration_id,
1506                chain.root_checkpoint_id,
1507            ])?;
1508        }
1509    }
1510
1511    {
1512        let mut cp_stmt = tx.prepare_cached(
1513            "INSERT OR REPLACE INTO checkpoints
1514                (id, parent_id, chain_id, version_number, schema_version, timestamp,
1515                 description, is_manual_save, is_schema_boundary, user_id, data, size_bytes)
1516             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)"
1517        )?;
1518        for cp in &vg.checkpoints {
1519            let chain_id = vg.chains.iter()
1520                .find(|c| c.schema_version == cp.schema_version)
1521                .map(|c| c.id.as_str())
1522                .unwrap_or("");
1523            cp_stmt.execute(params![
1524                cp.base.id,
1525                cp.base.parent_id,
1526                chain_id,
1527                cp.version_number,
1528                cp.schema_version,
1529                cp.base.timestamp,
1530                cp.base.description,
1531                cp.base.is_manual_save as i32,
1532                cp.is_schema_boundary as i32,
1533                cp.base.user_id,
1534                cp.data,
1535                cp.size_bytes,
1536            ])?;
1537        }
1538    }
1539
1540    {
1541        let mut d_stmt = tx.prepare_cached(
1542            "INSERT OR REPLACE INTO deltas
1543                (id, parent_id, base_checkpoint_id, chain_id, delta_sequence, version_number,
1544                 schema_version, timestamp, description, is_manual_save, user_id, changeset, size_bytes)
1545             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"
1546        )?;
1547        for (i, delta) in vg.deltas.iter().enumerate() {
1548            let chain_id = vg.chains.iter()
1549                .find(|c| c.schema_version == delta.schema_version)
1550                .map(|c| c.id.as_str())
1551                .unwrap_or("");
1552            d_stmt.execute(params![
1553                delta.base.id,
1554                delta.base.parent_id,
1555                delta.base_checkpoint_id,
1556                chain_id,
1557                (i + 1) as i64,
1558                delta.version_number,
1559                delta.schema_version,
1560                delta.base.timestamp,
1561                delta.base.description,
1562                delta.base.is_manual_save as i32,
1563                delta.base.user_id,
1564                delta.payload,
1565                delta.size_bytes,
1566            ])?;
1567        }
1568    }
1569
1570    Ok(())
1571}