Skip to main content

anyback_reader/
markdown.rs

1use std::fs;
2use std::path::Path;
3use std::{collections::HashMap, hash::RandomState};
4
5use anyhow::{Context, Result, anyhow, bail};
6use prost::Message;
7use prost_types::{Struct, value::Kind};
8
9use crate::archive::ArchiveReader;
10use anytype_rpc::{
11    anytype::SnapshotWithType,
12    model::{
13        Block, Range, SmartBlockType,
14        block::{
15            ContentValue,
16            content::{
17                Bookmark, Div, File, Latex, Link, Table, TableColumn, TableRow, Text,
18                div::Style as DivStyle,
19                file::{State as FileState, Type as FileType},
20                layout::Style as LayoutStyle,
21                text::{Mark, Style as TextStyle, mark::Type as MarkType},
22            },
23        },
24    },
25};
26use serde_json::Value as JsonValue;
27
28/// Metadata used to resolve object links and file names during markdown rendering.
29#[derive(Debug, Clone)]
30pub struct ArchiveObjectInfo {
31    pub id: String,
32    pub name: String,
33    pub snippet: String,
34    pub layout: Option<i64>,
35    pub file_ext: Option<String>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum SavedObjectKind {
40    Markdown,
41    Raw,
42}
43
44#[derive(Debug, Clone, Default)]
45struct RenderState {
46    indent: String,
47    list_opened: bool,
48    list_number: usize,
49}
50
51impl RenderState {
52    fn with_space_indent(&self) -> Self {
53        let mut next = self.clone();
54        next.indent.push_str("    ");
55        next
56    }
57
58    fn with_nb_indent(&self) -> Self {
59        let mut next = self.clone();
60        next.indent.push_str("  ");
61        next
62    }
63}
64
65#[derive(Debug)]
66struct MarkdownConverter<'a> {
67    blocks_by_id: HashMap<String, &'a Block>,
68    docs: &'a HashMap<String, ArchiveObjectInfo, RandomState>,
69}
70
71impl MarkdownConverter<'_> {
72    fn render(&self, root: &Block) -> String {
73        let mut out = String::new();
74        let mut state = RenderState::default();
75        self.render_children(&mut out, &mut state, root);
76        out
77    }
78
79    fn render_children(&self, out: &mut String, state: &mut RenderState, parent: &Block) {
80        for child_id in &parent.children_ids {
81            let Some(block) = self.blocks_by_id.get(child_id) else {
82                continue;
83            };
84            self.render_block(out, state, block);
85        }
86    }
87
88    fn render_block(&self, out: &mut String, state: &mut RenderState, block: &Block) {
89        match block.content_value.as_ref() {
90            Some(ContentValue::Text(text)) => self.render_text(out, state, block, text),
91            Some(ContentValue::File(file)) => self.render_file(out, state, file),
92            Some(ContentValue::Bookmark(bookmark)) => self.render_bookmark(out, state, bookmark),
93            Some(ContentValue::Table(_)) => self.render_table(out, state, block),
94            Some(ContentValue::Div(div)) => {
95                if matches!(
96                    DivStyle::try_from(div.style).ok(),
97                    Some(DivStyle::Dots | DivStyle::Line)
98                ) {
99                    out.push_str(" --- \n");
100                }
101                self.render_children(out, state, block);
102            }
103            Some(ContentValue::Link(link)) => self.render_link(out, state, link),
104            Some(ContentValue::Latex(latex)) => self.render_latex(out, state, latex),
105            _ => self.render_children(out, state, block),
106        }
107    }
108
109    fn render_text(&self, out: &mut String, state: &mut RenderState, block: &Block, text: &Text) {
110        let style = TextStyle::try_from(text.style).unwrap_or(TextStyle::Paragraph);
111        if state.list_opened && !matches!(style, TextStyle::Marked | TextStyle::Numbered) {
112            out.push_str("   \n");
113            state.list_opened = false;
114            state.list_number = 0;
115        }
116
117        out.push_str(&state.indent);
118        match style {
119            TextStyle::Header1 | TextStyle::ToggleHeader1 | TextStyle::Title => {
120                out.push_str("# ");
121                self.render_text_content(out, text);
122                let mut nested = state.with_space_indent();
123                self.render_children(out, &mut nested, block);
124            }
125            TextStyle::Header2 | TextStyle::ToggleHeader2 => {
126                out.push_str("## ");
127                self.render_text_content(out, text);
128                let mut nested = state.with_space_indent();
129                self.render_children(out, &mut nested, block);
130            }
131            TextStyle::Header3 | TextStyle::ToggleHeader3 => {
132                out.push_str("### ");
133                self.render_text_content(out, text);
134                let mut nested = state.with_space_indent();
135                self.render_children(out, &mut nested, block);
136            }
137            TextStyle::Header4 => {
138                out.push_str("#### ");
139                self.render_text_content(out, text);
140                let mut nested = state.with_space_indent();
141                self.render_children(out, &mut nested, block);
142            }
143            TextStyle::Quote | TextStyle::Toggle => {
144                out.push_str("> ");
145                out.push_str(&text.text.replace('\n', "   \n> "));
146                out.push_str("   \n\n");
147                self.render_children(out, state, block);
148            }
149            TextStyle::Code => {
150                out.push_str("```\n");
151                out.push_str(&state.indent);
152                out.push_str(&text.text.replace("```", "\\`\\`\\`"));
153                out.push('\n');
154                out.push_str(&state.indent);
155                out.push_str("```\n");
156                self.render_children(out, state, block);
157            }
158            TextStyle::Checkbox => {
159                if text.checked {
160                    out.push_str("- [x] ");
161                } else {
162                    out.push_str("- [ ] ");
163                }
164                self.render_text_content(out, text);
165                let mut nested = state.with_nb_indent();
166                self.render_children(out, &mut nested, block);
167            }
168            TextStyle::Marked => {
169                out.push_str("- ");
170                self.render_text_content(out, text);
171                let mut nested = state.with_space_indent();
172                self.render_children(out, &mut nested, block);
173                state.list_opened = true;
174            }
175            TextStyle::Numbered => {
176                state.list_number += 1;
177                out.push_str(&format!("{}. ", state.list_number));
178                self.render_text_content(out, text);
179                let mut nested = state.with_space_indent();
180                self.render_children(out, &mut nested, block);
181                state.list_opened = true;
182            }
183            _ => {
184                self.render_text_content(out, text);
185                let mut nested = state.with_nb_indent();
186                self.render_children(out, &mut nested, block);
187            }
188        }
189    }
190
191    fn render_text_content(&self, out: &mut String, text: &Text) {
192        let mut marks = MarksWriter::new(self, text);
193        let chars: Vec<char> = text.text.chars().collect();
194        for (idx, ch) in chars.iter().enumerate() {
195            marks.write_marks(out, idx);
196            escape_markdown_char(*ch, out);
197        }
198        marks.write_marks(out, chars.len());
199        out.push_str("   \n");
200    }
201
202    fn render_file(&self, out: &mut String, state: &RenderState, file: &File) {
203        if !matches!(FileState::try_from(file.state).ok(), Some(FileState::Done)) {
204            return;
205        }
206        let (title, filename) = self.link_info_for_file(file);
207        if title.is_empty() || filename.is_empty() {
208            return;
209        }
210        out.push_str(&state.indent);
211        if matches!(FileType::try_from(file.r#type).ok(), Some(FileType::Image)) {
212            out.push_str(&format!("![{title}]({filename})    \n"));
213        } else {
214            out.push_str(&format!("[{title}]({filename})    \n"));
215        }
216    }
217
218    #[allow(clippy::unused_self)]
219    fn render_bookmark(&self, out: &mut String, state: &RenderState, bookmark: &Bookmark) {
220        if bookmark.url.is_empty() {
221            return;
222        }
223        out.push_str(&state.indent);
224        let title = if bookmark.title.is_empty() {
225            bookmark.url.clone()
226        } else {
227            escape_markdown_string(&bookmark.title)
228        };
229        out.push_str(&format!("[{}]({})    \n", title, bookmark.url));
230    }
231
232    fn render_link(&self, out: &mut String, state: &RenderState, link: &Link) {
233        if link.target_block_id.is_empty() {
234            return;
235        }
236        let Some((title, filename)) = self.link_info(&link.target_block_id) else {
237            return;
238        };
239        out.push_str(&state.indent);
240        out.push_str(&format!(
241            "[{}]({})    \n",
242            escape_markdown_string(&title),
243            filename
244        ));
245    }
246
247    #[allow(clippy::unused_self)]
248    fn render_latex(&self, out: &mut String, state: &RenderState, latex: &Latex) {
249        out.push_str(&state.indent);
250        out.push_str("\n$$\n");
251        out.push_str(&latex.text);
252        out.push_str("\n$$\n");
253    }
254
255    fn render_table(&self, out: &mut String, state: &mut RenderState, table_block: &Block) {
256        let mut column_ids: Vec<String> = Vec::new();
257        let mut row_ids: Vec<String> = Vec::new();
258
259        for child_id in &table_block.children_ids {
260            let Some(child) = self.blocks_by_id.get(child_id) else {
261                continue;
262            };
263            match child.content_value.as_ref() {
264                Some(ContentValue::Layout(layout)) => {
265                    match LayoutStyle::try_from(layout.style).ok() {
266                        Some(LayoutStyle::TableColumns) => {
267                            column_ids.clone_from(&child.children_ids);
268                        }
269                        Some(LayoutStyle::TableRows) => {
270                            row_ids.clone_from(&child.children_ids);
271                        }
272                        _ => {}
273                    }
274                }
275                Some(ContentValue::TableRow(_)) => row_ids.push(child.id.clone()),
276                Some(ContentValue::TableColumn(_)) => column_ids.push(child.id.clone()),
277                _ => {}
278            }
279        }
280
281        if row_ids.is_empty() {
282            self.render_children(out, state, table_block);
283            return;
284        }
285
286        let rows = self.build_table_rows(&row_ids, &column_ids);
287        write_markdown_table(out, &state.indent, rows);
288    }
289
290    fn build_table_rows(&self, row_ids: &[String], column_ids: &[String]) -> Vec<Vec<String>> {
291        let mut rows: Vec<Vec<String>> = Vec::new();
292        for row_id in row_ids {
293            let Some(row_block) = self.blocks_by_id.get(row_id) else {
294                continue;
295            };
296            let mut by_col: HashMap<String, String> = HashMap::new();
297            let mut unordered: Vec<String> = Vec::new();
298
299            for cell_id in &row_block.children_ids {
300                let Some(cell_block) = self.blocks_by_id.get(cell_id) else {
301                    continue;
302                };
303                let content = self.render_cell(cell_block);
304                if let Some(col_id) = cell_id.strip_prefix(&format!("{row_id}-")) {
305                    by_col.insert(col_id.to_string(), content);
306                } else {
307                    unordered.push(content);
308                }
309            }
310
311            if column_ids.is_empty() {
312                if by_col.is_empty() {
313                    rows.push(unordered);
314                } else {
315                    let mut pairs: Vec<(String, String)> = by_col.into_iter().collect();
316                    pairs.sort_by(|a, b| a.0.cmp(&b.0));
317                    rows.push(pairs.into_iter().map(|(_, v)| v).collect());
318                }
319                continue;
320            }
321
322            let mut row = Vec::with_capacity(column_ids.len());
323            for (idx, col_id) in column_ids.iter().enumerate() {
324                if let Some(cell) = by_col.remove(col_id) {
325                    row.push(cell);
326                } else if let Some(cell) = unordered.get(idx) {
327                    row.push(cell.clone());
328                } else {
329                    row.push(" ".to_string());
330                }
331            }
332            rows.push(row);
333        }
334        rows
335    }
336
337    fn render_cell(&self, block: &Block) -> String {
338        let mut text = String::new();
339        let mut state = RenderState::default();
340        self.render_block(&mut text, &mut state, block);
341        text = text.replace("\r\n", " ").replace('\n', " ");
342        let trimmed = text.trim();
343        if trimmed.is_empty() {
344            " ".to_string()
345        } else {
346            trimmed.to_string()
347        }
348    }
349
350    fn link_info_for_file(&self, file: &File) -> (String, String) {
351        if !file.target_object_id.is_empty() {
352            if let Some((title, filename)) = self.link_info(&file.target_object_id) {
353                return (title, filename);
354            }
355            let fallback_title = path_basename(&file.name).to_string();
356            let fallback_ext = file_ext_from_name(&file.name).unwrap_or_default();
357            let filename =
358                file_name_for_file(&file.target_object_id, &fallback_title, &fallback_ext);
359            return (fallback_title, filename);
360        }
361
362        let title = path_basename(&file.name).to_string();
363        let ext = file_ext_from_name(&file.name).unwrap_or_default();
364        let filename = file_name_for_file(&file.hash, &title, &ext);
365        (title, filename)
366    }
367
368    fn link_info(&self, object_id: &str) -> Option<(String, String)> {
369        let info = self.docs.get(object_id)?;
370        let mut title = info.name.clone();
371        if title.is_empty() {
372            title.clone_from(&info.snippet);
373        }
374        if title.is_empty() {
375            title = object_id.to_string();
376        }
377
378        let is_file = matches!(info.layout, Some(8..=12));
379        if is_file {
380            let ext = info
381                .file_ext
382                .as_deref()
383                .map(|ext| format!(".{}", ext.trim_start_matches('.')))
384                .unwrap_or_default();
385            let file_title = title.trim_end_matches(&ext).to_string();
386            let filename = file_name_for_file(object_id, &file_title, &ext);
387            return Some((file_title, filename));
388        }
389
390        let filename = file_name_for_doc(object_id, &title);
391        Some((title, filename))
392    }
393}
394
395#[derive(Debug, Clone)]
396struct MarkRange {
397    from: usize,
398    to: usize,
399    mark: Mark,
400}
401
402#[derive(Debug)]
403struct MarksWriter<'a, 'b> {
404    converter: &'a MarkdownConverter<'b>,
405    starts: HashMap<usize, Vec<MarkRange>>,
406    ends: HashMap<usize, Vec<MarkRange>>,
407    open: Vec<MarkRange>,
408}
409
410impl<'a, 'b> MarksWriter<'a, 'b> {
411    fn new(converter: &'a MarkdownConverter<'b>, text: &Text) -> Self {
412        let mut starts: HashMap<usize, Vec<MarkRange>> = HashMap::new();
413        let mut ends: HashMap<usize, Vec<MarkRange>> = HashMap::new();
414        if let Some(marks) = text.marks.as_ref() {
415            for mark in &marks.marks {
416                let Some(range) = mark.range.as_ref() else {
417                    continue;
418                };
419                if range.from == range.to || range.from < 0 || range.to < 0 {
420                    continue;
421                }
422                #[allow(clippy::cast_sign_loss)]
423                let item = MarkRange {
424                    from: range.from as usize,
425                    to: range.to as usize,
426                    mark: mark.clone(),
427                };
428                starts.entry(item.from).or_default().push(item.clone());
429                ends.entry(item.to).or_default().push(item);
430            }
431        }
432        for values in starts.values_mut() {
433            values.sort_by(|a, b| {
434                let la = a.to.saturating_sub(a.from);
435                let lb = b.to.saturating_sub(b.from);
436                lb.cmp(&la).then_with(|| a.mark.r#type.cmp(&b.mark.r#type))
437            });
438        }
439        for values in ends.values_mut() {
440            values.sort_by(|a, b| {
441                let la = a.to.saturating_sub(a.from);
442                let lb = b.to.saturating_sub(b.from);
443                lb.cmp(&la).then_with(|| a.mark.r#type.cmp(&b.mark.r#type))
444            });
445        }
446        Self {
447            converter,
448            starts,
449            ends,
450            open: Vec::new(),
451        }
452    }
453
454    fn write_marks(&mut self, out: &mut String, pos: usize) {
455        if let Some(ends) = self.ends.get(&pos).cloned() {
456            for item in ends.iter().rev() {
457                if let Some(last) = self.open.pop()
458                    && (last.from != item.from || last.to != item.to || last.mark != item.mark)
459                {
460                    self.open.push(last.clone());
461                }
462                self.write_mark(out, &item.mark, false);
463            }
464        }
465        if let Some(starts) = self.starts.get(&pos).cloned() {
466            for item in &starts {
467                self.write_mark(out, &item.mark, true);
468                self.open.push(item.clone());
469            }
470        }
471    }
472
473    fn write_mark(&self, out: &mut String, mark: &Mark, start: bool) {
474        let kind = MarkType::try_from(mark.r#type).ok();
475        match kind {
476            Some(MarkType::Strikethrough) => out.push_str("~~"),
477            Some(MarkType::Italic) => out.push('*'),
478            Some(MarkType::Bold) => out.push_str("**"),
479            Some(MarkType::Keyboard) => out.push('`'),
480            Some(MarkType::Link) => {
481                if start {
482                    out.push('[');
483                } else {
484                    out.push_str(&format!("]({})", mark.param));
485                }
486            }
487            Some(MarkType::Mention | MarkType::Object) => {
488                if let Some((_, filename)) = self.converter.link_info(&mark.param) {
489                    if start {
490                        out.push('[');
491                    } else {
492                        out.push_str(&format!("]({filename})"));
493                    }
494                }
495            }
496            Some(MarkType::Emoji) => {
497                if start {
498                    out.push_str(&mark.param);
499                }
500            }
501            _ => {}
502        }
503    }
504}
505
506fn write_markdown_table(out: &mut String, indent: &str, mut rows: Vec<Vec<String>>) {
507    if rows.is_empty() {
508        return;
509    }
510    let cols = rows.iter().map(std::vec::Vec::len).max().unwrap_or(0);
511    if cols == 0 {
512        return;
513    }
514    for row in &mut rows {
515        while row.len() < cols {
516            row.push(" ".to_string());
517        }
518    }
519
520    let mut widths = vec![3usize; cols];
521    for row in &rows {
522        for (idx, cell) in row.iter().enumerate() {
523            widths[idx] = widths[idx].max(cell.len());
524        }
525    }
526
527    for (idx, row) in rows.iter().enumerate() {
528        out.push_str(indent);
529        out.push('|');
530        for (col, cell) in row.iter().enumerate() {
531            out.push_str(&format!(" {:<width$} |", cell, width = widths[col]));
532        }
533        out.push('\n');
534
535        if idx == 0 {
536            out.push_str(indent);
537            out.push('|');
538            for width in &widths {
539                out.push(':');
540                out.push_str(&"-".repeat(width.saturating_add(1)));
541                out.push('|');
542            }
543            out.push('\n');
544        }
545    }
546    out.push('\n');
547}
548
549fn escape_markdown_char(ch: char, out: &mut String) {
550    if matches!(
551        ch,
552        '\\' | '`'
553            | '*'
554            | '_'
555            | '{'
556            | '}'
557            | '['
558            | ']'
559            | '('
560            | ')'
561            | '#'
562            | '+'
563            | '-'
564            | '.'
565            | '!'
566            | '|'
567            | '>'
568            | '~'
569    ) {
570        out.push('\\');
571    }
572    out.push(ch);
573}
574
575fn escape_markdown_string(value: &str) -> String {
576    let mut out = String::with_capacity(value.len() + 8);
577    for ch in value.chars() {
578        escape_markdown_char(ch, &mut out);
579    }
580    out
581}
582
583fn path_basename(path: &str) -> &str {
584    Path::new(path)
585        .file_name()
586        .and_then(|v| v.to_str())
587        .unwrap_or(path)
588}
589
590fn file_ext_from_name(name: &str) -> Option<String> {
591    Path::new(name)
592        .extension()
593        .and_then(|v| v.to_str())
594        .map(|v| format!(".{v}"))
595}
596
597fn sanitize_filename(input: &str) -> String {
598    let mut out = String::with_capacity(input.len());
599    for ch in input.chars() {
600        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
601            out.push(ch.to_ascii_lowercase());
602        } else if ch.is_whitespace() || matches!(ch, '/' | '\\') {
603            out.push('_');
604        }
605    }
606    let compact = out.trim_matches('_');
607    if compact.is_empty() {
608        "untitled".to_string()
609    } else {
610        compact.to_string()
611    }
612}
613
614fn file_name_for_doc(id: &str, title: &str) -> String {
615    let base = sanitize_filename(title);
616    format!("{base}_{id}.md")
617}
618
619fn file_name_for_file(id: &str, title: &str, ext: &str) -> String {
620    let base = sanitize_filename(title);
621    format!("files/{base}_{id}{ext}")
622}
623
624fn struct_field_as_string(details: &Struct, key: &str) -> Option<String> {
625    let value = details.fields.get(key)?;
626    match value.kind.as_ref()? {
627        Kind::StringValue(v) => Some(v.clone()),
628        Kind::NumberValue(v) => Some(v.to_string()),
629        Kind::BoolValue(v) => Some(v.to_string()),
630        _ => None,
631    }
632}
633
634fn build_archive_object_index(
635    reader: &ArchiveReader,
636) -> Result<HashMap<String, ArchiveObjectInfo>> {
637    let mut out = HashMap::new();
638    for file in reader.list_files()? {
639        let lower = file.path.to_ascii_lowercase();
640        #[allow(clippy::case_sensitive_file_extension_comparisons)]
641        if !lower.ends_with(".pb") && !lower.ends_with(".pb.json") {
642            continue;
643        }
644        let Ok(bytes) = reader.read_bytes(&file.path) else {
645            continue;
646        };
647        let Ok(details) = parse_snapshot_details_to_map(&file.path, &bytes) else {
648            continue;
649        };
650        let Some(id) = details.get("id").cloned().filter(|v| !v.is_empty()) else {
651            continue;
652        };
653        let info = ArchiveObjectInfo {
654            id: id.clone(),
655            name: details.get("name").cloned().unwrap_or_default(),
656            snippet: details.get("snippet").cloned().unwrap_or_default(),
657            layout: details
658                .get("layout")
659                .and_then(|v| v.parse::<i64>().ok())
660                .or_else(|| {
661                    details
662                        .get("resolvedLayout")
663                        .and_then(|v| v.parse::<i64>().ok())
664                }),
665            file_ext: details.get("fileExt").cloned(),
666        };
667        out.insert(info.id.clone(), info);
668    }
669    Ok(out)
670}
671
672fn find_snapshot_path(reader: &ArchiveReader, object_id: &str) -> Option<String> {
673    let pb = format!("{object_id}.pb");
674    let pb_json = format!("{object_id}.pb.json");
675    let files = reader.list_files().ok()?;
676    files.iter().find_map(|f| {
677        let lower = f.path.to_ascii_lowercase();
678        if lower.ends_with(&pb) || lower.ends_with(&pb_json) {
679            Some(f.path.clone())
680        } else {
681            None
682        }
683    })
684}
685
686fn parse_snapshot_details_to_map(path: &str, bytes: &[u8]) -> Result<HashMap<String, String>> {
687    let lower = path.to_ascii_lowercase();
688    #[allow(clippy::case_sensitive_file_extension_comparisons)]
689    if lower.ends_with(".pb") {
690        let snapshot =
691            SnapshotWithType::decode(bytes).context("failed to decode protobuf snapshot")?;
692        let data = snapshot
693            .snapshot
694            .and_then(|v| v.data)
695            .ok_or_else(|| anyhow!("snapshot payload missing data"))?;
696        let Some(details) = data.details else {
697            return Ok(HashMap::new());
698        };
699        let mut map = HashMap::new();
700        for (k, v) in details.fields {
701            if let Some(value) = prost_value_to_string(&v) {
702                map.insert(k, value);
703            }
704        }
705        return Ok(map);
706    }
707    if lower.ends_with(".pb.json") {
708        let root: JsonValue = serde_json::from_slice(bytes).context("invalid pb-json")?;
709        let details = root
710            .get("snapshot")
711            .and_then(|v| v.get("data"))
712            .and_then(|v| v.get("details"))
713            .and_then(JsonValue::as_object)
714            .ok_or_else(|| anyhow!("pb-json snapshot missing details object"))?;
715        let mut map = HashMap::new();
716        for (k, v) in details {
717            if let Some(value) = json_value_to_string(v) {
718                map.insert(k.clone(), value);
719            }
720        }
721        return Ok(map);
722    }
723    bail!("unsupported snapshot format: {path}")
724}
725
726fn prost_value_to_string(v: &prost_types::Value) -> Option<String> {
727    match v.kind.as_ref()? {
728        Kind::StringValue(s) => Some(s.clone()),
729        Kind::NumberValue(n) => Some(n.to_string()),
730        Kind::BoolValue(b) => Some(b.to_string()),
731        _ => None,
732    }
733}
734
735fn json_value_to_string(v: &JsonValue) -> Option<String> {
736    if let Some(s) = v.as_str() {
737        return Some(s.to_string());
738    }
739    if let Some(n) = v.as_i64() {
740        return Some(n.to_string());
741    }
742    if let Some(n) = v.as_f64() {
743        return Some(n.to_string());
744    }
745    if let Some(b) = v.as_bool() {
746        return Some(b.to_string());
747    }
748    None
749}
750
751fn infer_raw_payload_path(
752    object_id: &str,
753    details: &HashMap<String, String>,
754    files: &[crate::archive::ArchiveFileEntry],
755) -> Option<String> {
756    let mut tokens = Vec::<String>::new();
757    if !object_id.is_empty() {
758        tokens.push(object_id.to_ascii_lowercase());
759    }
760    for key in [
761        "source",
762        "fileHash",
763        "hash",
764        "fileObjectId",
765        "targetObjectId",
766        "fileName",
767        "name",
768        "oldAnytypeID",
769    ] {
770        if let Some(value) = details.get(key) {
771            let token = value.trim().to_ascii_lowercase();
772            if !token.is_empty() {
773                tokens.push(token);
774            }
775        }
776    }
777    if let Some(ext) = details.get("fileExt") {
778        let token = ext.trim().trim_start_matches('.').to_ascii_lowercase();
779        if !token.is_empty() {
780            tokens.push(format!(".{token}"));
781        }
782    }
783
784    let mut best: Option<(&str, i32)> = None;
785    for file in files {
786        let path_lc = file.path.to_ascii_lowercase();
787        #[allow(clippy::case_sensitive_file_extension_comparisons)]
788        if path_lc.ends_with(".pb") || path_lc.ends_with(".pb.json") || path_lc == "manifest.json" {
789            continue;
790        }
791        let mut score = 0;
792        if path_lc.starts_with("files/") {
793            score += 30;
794        }
795        for token in &tokens {
796            if token.len() < 3 {
797                continue;
798            }
799            if path_lc.contains(token) {
800                score += 25;
801            }
802        }
803        if score == 0 {
804            continue;
805        }
806        match best {
807            Some((_, best_score)) if best_score >= score => {}
808            _ => best = Some((file.path.as_str(), score)),
809        }
810    }
811    best.map(|(path, _)| path.to_string())
812}
813
814fn should_skip_export(sb_type: SmartBlockType) -> bool {
815    matches!(
816        sb_type,
817        SmartBlockType::StType
818            | SmartBlockType::StRelation
819            | SmartBlockType::StRelationOption
820            | SmartBlockType::Participant
821            | SmartBlockType::SpaceView
822            | SmartBlockType::ChatObjectDeprecated
823            | SmartBlockType::ChatDerivedObject
824    )
825}
826
827/// Convert one archive object snapshot (`objects/<id>.pb`) to markdown text.
828///
829/// This reads the target object snapshot from the archive and builds a lightweight
830/// index of object details from sibling `*.pb` snapshots so object/file links can be
831/// rendered as markdown links.
832pub fn convert_archive_object_pb_to_markdown(
833    archive_path: &Path,
834    object_id: &str,
835) -> Result<String> {
836    let reader = ArchiveReader::from_path(archive_path)?;
837    let snapshot_path = find_snapshot_path(&reader, object_id)
838        .ok_or_else(|| anyhow!("snapshot not found in archive for object: {object_id}"))?;
839    if !snapshot_path.to_ascii_lowercase().ends_with(".pb") {
840        bail!("markdown conversion currently supports protobuf snapshots (*.pb) only");
841    }
842    let snapshot_bytes = reader
843        .read_bytes(&snapshot_path)
844        .with_context(|| format!("failed reading snapshot from archive: {snapshot_path}"))?;
845    let object_index = build_archive_object_index(&reader)?;
846    convert_pb_snapshot_to_markdown(&snapshot_bytes, &object_index)
847}
848
849/// Convert one archive object snapshot (`objects/<id>.pb` or `objects/<id>.pb.json`) to markdown.
850pub fn convert_archive_object_to_markdown(archive_path: &Path, object_id: &str) -> Result<String> {
851    let reader = ArchiveReader::from_path(archive_path)?;
852    let snapshot_path = find_snapshot_path(&reader, object_id)
853        .ok_or_else(|| anyhow!("snapshot not found in archive for object: {object_id}"))?;
854    let snapshot_bytes = reader
855        .read_bytes(&snapshot_path)
856        .with_context(|| format!("failed reading snapshot from archive: {snapshot_path}"))?;
857    let object_index = build_archive_object_index(&reader)?;
858    let lower = snapshot_path.to_ascii_lowercase();
859    #[allow(clippy::case_sensitive_file_extension_comparisons)]
860    if lower.ends_with(".pb") {
861        return convert_pb_snapshot_to_markdown(&snapshot_bytes, &object_index);
862    }
863    if lower.ends_with(".pb.json") {
864        return convert_pb_json_snapshot_to_markdown(&snapshot_bytes, &object_index);
865    }
866    bail!("unsupported snapshot format: {snapshot_path}")
867}
868
869pub fn save_archive_object(
870    archive_path: &Path,
871    object_id: &str,
872    dest: &Path,
873) -> Result<SavedObjectKind> {
874    let reader = ArchiveReader::from_path(archive_path)?;
875    let files = reader.list_files()?;
876    let snapshot_path = find_snapshot_path(&reader, object_id)
877        .ok_or_else(|| anyhow!("snapshot not found in archive for object: {object_id}"))?;
878    let snapshot_bytes = reader
879        .read_bytes(&snapshot_path)
880        .with_context(|| format!("failed reading snapshot from archive: {snapshot_path}"))?;
881    let details = parse_snapshot_details_to_map(&snapshot_path, &snapshot_bytes)?;
882
883    if !is_file_layout_from_details(&details) {
884        let markdown = convert_archive_object_to_markdown(archive_path, object_id)?;
885        fs::write(dest, markdown)
886            .with_context(|| format!("failed writing markdown to {}", dest.display()))?;
887        return Ok(SavedObjectKind::Markdown);
888    }
889
890    let payload = infer_raw_payload_path(object_id, &details, &files)
891        .ok_or_else(|| anyhow!("could not resolve raw payload for object: {object_id}"))?;
892    let bytes = reader
893        .read_bytes(&payload)
894        .with_context(|| format!("failed reading payload from archive: {payload}"))?;
895    fs::write(dest, bytes)
896        .with_context(|| format!("failed writing raw payload to {}", dest.display()))?;
897    Ok(SavedObjectKind::Raw)
898}
899
900fn is_file_layout_from_details(details: &HashMap<String, String>) -> bool {
901    let parse_i64 = |key: &str| details.get(key).and_then(|v| v.parse::<i64>().ok());
902    matches!(
903        parse_i64("layout").or_else(|| parse_i64("resolvedLayout")),
904        Some(8..=12)
905    )
906}
907
908fn convert_pb_json_snapshot_to_markdown(
909    snapshot_bytes: &[u8],
910    object_index: &HashMap<String, ArchiveObjectInfo>,
911) -> Result<String> {
912    let root: JsonValue = serde_json::from_slice(snapshot_bytes).context("invalid pb-json")?;
913    let sb_type = parse_json_smart_block_type(&root);
914    if let Some(sb_type) = sb_type
915        && should_skip_export(sb_type)
916    {
917        return Ok(String::new());
918    }
919    let data = root
920        .get("snapshot")
921        .and_then(|v| v.get("data"))
922        .ok_or_else(|| anyhow!("pb-json snapshot missing snapshot.data"))?;
923    let blocks_json = data
924        .get("blocks")
925        .and_then(JsonValue::as_array)
926        .ok_or_else(|| anyhow!("pb-json snapshot missing snapshot.data.blocks"))?;
927    if blocks_json.is_empty() {
928        return Ok(String::new());
929    }
930    let blocks: Vec<Block> = blocks_json
931        .iter()
932        .map(parse_json_block)
933        .collect::<Result<Vec<_>>>()?;
934    if blocks.is_empty() {
935        return Ok(String::new());
936    }
937
938    let mut blocks_by_id = HashMap::<String, &Block>::with_capacity(blocks.len());
939    for block in &blocks {
940        blocks_by_id.insert(block.id.clone(), block);
941    }
942
943    let root_id = data
944        .get("details")
945        .and_then(JsonValue::as_object)
946        .and_then(|details| details.get("id"))
947        .and_then(JsonValue::as_str)
948        .map_or_else(|| blocks[0].id.clone(), ToString::to_string);
949    let Some(root) = blocks_by_id.get(&root_id) else {
950        bail!("root block not found: {root_id}");
951    };
952    if root.children_ids.is_empty() {
953        return Ok(String::new());
954    }
955
956    let converter = MarkdownConverter {
957        blocks_by_id,
958        docs: object_index,
959    };
960    let root = converter
961        .blocks_by_id
962        .get(&root_id)
963        .ok_or_else(|| anyhow!("root block not found after converter init: {root_id}"))?;
964    Ok(converter.render(root))
965}
966
967fn parse_json_smart_block_type(root: &JsonValue) -> Option<SmartBlockType> {
968    let sb = root.get("sbType")?;
969    if let Some(name) = sb.as_str() {
970        return SmartBlockType::from_str_name(name);
971    }
972    if let Some(value) = sb.as_i64().and_then(|n| i32::try_from(n).ok()) {
973        return SmartBlockType::try_from(value).ok();
974    }
975    None
976}
977
978fn parse_json_block(value: &JsonValue) -> Result<Block> {
979    let obj = value
980        .as_object()
981        .ok_or_else(|| anyhow!("pb-json block is not an object"))?;
982    let id = obj
983        .get("id")
984        .and_then(JsonValue::as_str)
985        .ok_or_else(|| anyhow!("pb-json block missing id"))?
986        .to_string();
987    let children_ids = obj
988        .get("childrenIds")
989        .and_then(JsonValue::as_array)
990        .map_or_else(Vec::new, |items| {
991            items
992                .iter()
993                .filter_map(JsonValue::as_str)
994                .map(ToString::to_string)
995                .collect()
996        });
997    let background_color = obj
998        .get("backgroundColor")
999        .and_then(JsonValue::as_str)
1000        .unwrap_or_default()
1001        .to_string();
1002    let align = obj
1003        .get("align")
1004        .map_or(0, |v| parse_block_align(v).unwrap_or_default());
1005    let vertical_align = obj
1006        .get("verticalAlign")
1007        .map_or(0, |v| parse_block_vertical_align(v).unwrap_or_default());
1008    let content_value = parse_json_content_value(obj)?;
1009
1010    Ok(Block {
1011        id,
1012        fields: None,
1013        restrictions: None,
1014        children_ids,
1015        background_color,
1016        align,
1017        vertical_align,
1018        content_value,
1019    })
1020}
1021
1022fn parse_json_content_value(
1023    obj: &serde_json::Map<String, JsonValue>,
1024) -> Result<Option<ContentValue>> {
1025    if let Some(v) = obj.get("text") {
1026        return Ok(Some(ContentValue::Text(parse_json_text(v)?)));
1027    }
1028    if let Some(v) = obj.get("file") {
1029        return Ok(Some(ContentValue::File(parse_json_file(v)?)));
1030    }
1031    if let Some(v) = obj.get("bookmark") {
1032        return Ok(Some(ContentValue::Bookmark(parse_json_bookmark(v))));
1033    }
1034    if let Some(v) = obj.get("link") {
1035        return Ok(Some(ContentValue::Link(parse_json_link(v))));
1036    }
1037    if let Some(v) = obj.get("latex") {
1038        return Ok(Some(ContentValue::Latex(parse_json_latex(v))));
1039    }
1040    if let Some(v) = obj.get("div") {
1041        return Ok(Some(ContentValue::Div(parse_json_div(v))));
1042    }
1043    if obj.contains_key("table") {
1044        return Ok(Some(ContentValue::Table(Table {})));
1045    }
1046    if obj.contains_key("tableColumn") {
1047        return Ok(Some(ContentValue::TableColumn(TableColumn {})));
1048    }
1049    if let Some(v) = obj.get("tableRow") {
1050        return Ok(Some(ContentValue::TableRow(parse_json_table_row(v))));
1051    }
1052    Ok(None)
1053}
1054
1055fn parse_json_text(value: &JsonValue) -> Result<Text> {
1056    let obj = value
1057        .as_object()
1058        .ok_or_else(|| anyhow!("pb-json text block is not an object"))?;
1059    let style = obj
1060        .get("style")
1061        .map_or(0, |v| parse_text_style(v).unwrap_or(0));
1062    let marks = obj
1063        .get("marks")
1064        .map(parse_json_marks)
1065        .transpose()?
1066        .or_else(|| Some(anytype_rpc::model::block::content::text::Marks { marks: Vec::new() }));
1067
1068    Ok(Text {
1069        text: obj
1070            .get("text")
1071            .and_then(JsonValue::as_str)
1072            .unwrap_or_default()
1073            .to_string(),
1074        style,
1075        marks,
1076        checked: obj
1077            .get("checked")
1078            .and_then(JsonValue::as_bool)
1079            .unwrap_or(false),
1080        color: obj
1081            .get("color")
1082            .and_then(JsonValue::as_str)
1083            .unwrap_or_default()
1084            .to_string(),
1085        icon_emoji: obj
1086            .get("iconEmoji")
1087            .and_then(JsonValue::as_str)
1088            .unwrap_or_default()
1089            .to_string(),
1090        icon_image: obj
1091            .get("iconImage")
1092            .and_then(JsonValue::as_str)
1093            .unwrap_or_default()
1094            .to_string(),
1095    })
1096}
1097
1098fn parse_json_marks(value: &JsonValue) -> Result<anytype_rpc::model::block::content::text::Marks> {
1099    let obj = value
1100        .as_object()
1101        .ok_or_else(|| anyhow!("pb-json marks is not an object"))?;
1102    let marks = obj
1103        .get("marks")
1104        .and_then(JsonValue::as_array)
1105        .map_or_else(Vec::new, |items| {
1106            items.iter().filter_map(parse_json_mark).collect()
1107        });
1108    Ok(anytype_rpc::model::block::content::text::Marks { marks })
1109}
1110
1111fn parse_json_mark(value: &JsonValue) -> Option<Mark> {
1112    let obj = value.as_object()?;
1113    let range = obj.get("range").and_then(parse_json_range);
1114    let r#type = obj
1115        .get("type")
1116        .map_or(0, |v| parse_mark_type(v).unwrap_or(0));
1117    let param = obj
1118        .get("param")
1119        .and_then(JsonValue::as_str)
1120        .unwrap_or_default()
1121        .to_string();
1122    Some(Mark {
1123        range,
1124        r#type,
1125        param,
1126    })
1127}
1128
1129fn parse_json_range(value: &JsonValue) -> Option<Range> {
1130    let obj = value.as_object()?;
1131    let from = obj.get("from").and_then(JsonValue::as_i64)?;
1132    let to = obj.get("to").and_then(JsonValue::as_i64)?;
1133    Some(Range {
1134        from: i32::try_from(from).ok()?,
1135        to: i32::try_from(to).ok()?,
1136    })
1137}
1138
1139fn parse_json_file(value: &JsonValue) -> Result<File> {
1140    let obj = value
1141        .as_object()
1142        .ok_or_else(|| anyhow!("pb-json file block is not an object"))?;
1143    Ok(File {
1144        hash: obj
1145            .get("hash")
1146            .and_then(JsonValue::as_str)
1147            .unwrap_or_default()
1148            .to_string(),
1149        name: obj
1150            .get("name")
1151            .and_then(JsonValue::as_str)
1152            .unwrap_or_default()
1153            .to_string(),
1154        r#type: obj
1155            .get("type")
1156            .map_or(0, |v| parse_file_type(v).unwrap_or(0)),
1157        mime: obj
1158            .get("mime")
1159            .and_then(JsonValue::as_str)
1160            .unwrap_or_default()
1161            .to_string(),
1162        size: obj.get("size").and_then(JsonValue::as_i64).unwrap_or(0),
1163        added_at: obj.get("addedAt").and_then(JsonValue::as_i64).unwrap_or(0),
1164        target_object_id: obj
1165            .get("targetObjectId")
1166            .and_then(JsonValue::as_str)
1167            .unwrap_or_default()
1168            .to_string(),
1169        state: obj
1170            .get("state")
1171            .map_or(FileState::Done as i32, |v| parse_file_state(v).unwrap_or(0)),
1172        style: obj
1173            .get("style")
1174            .map_or(0, |v| parse_file_style(v).unwrap_or(0)),
1175    })
1176}
1177
1178fn parse_json_bookmark(value: &JsonValue) -> Bookmark {
1179    let obj = value.as_object();
1180    Bookmark {
1181        url: obj
1182            .and_then(|o| o.get("url"))
1183            .and_then(JsonValue::as_str)
1184            .unwrap_or_default()
1185            .to_string(),
1186        title: obj
1187            .and_then(|o| o.get("title"))
1188            .and_then(JsonValue::as_str)
1189            .unwrap_or_default()
1190            .to_string(),
1191        description: obj
1192            .and_then(|o| o.get("description"))
1193            .and_then(JsonValue::as_str)
1194            .unwrap_or_default()
1195            .to_string(),
1196        image_hash: obj
1197            .and_then(|o| o.get("imageHash"))
1198            .and_then(JsonValue::as_str)
1199            .unwrap_or_default()
1200            .to_string(),
1201        favicon_hash: obj
1202            .and_then(|o| o.get("faviconHash"))
1203            .and_then(JsonValue::as_str)
1204            .unwrap_or_default()
1205            .to_string(),
1206        r#type: 0,
1207        target_object_id: obj
1208            .and_then(|o| o.get("targetObjectId"))
1209            .and_then(JsonValue::as_str)
1210            .unwrap_or_default()
1211            .to_string(),
1212        state: 0,
1213    }
1214}
1215
1216fn parse_json_link(value: &JsonValue) -> Link {
1217    let obj = value.as_object();
1218    Link {
1219        target_block_id: obj
1220            .and_then(|o| o.get("targetBlockId"))
1221            .and_then(JsonValue::as_str)
1222            .unwrap_or_default()
1223            .to_string(),
1224        style: obj
1225            .and_then(|o| o.get("style"))
1226            .map_or(0, |v| parse_link_style(v).unwrap_or(0)),
1227        fields: None,
1228        icon_size: obj
1229            .and_then(|o| o.get("iconSize"))
1230            .map_or(0, |v| parse_link_icon_size(v).unwrap_or(0)),
1231        card_style: obj
1232            .and_then(|o| o.get("cardStyle"))
1233            .map_or(0, |v| parse_link_card_style(v).unwrap_or(0)),
1234        description: obj
1235            .and_then(|o| o.get("description"))
1236            .map_or(0, |v| parse_link_description(v).unwrap_or(0)),
1237        relations: obj
1238            .and_then(|o| o.get("relations"))
1239            .and_then(JsonValue::as_array)
1240            .map_or_else(Vec::new, |arr| {
1241                arr.iter()
1242                    .filter_map(JsonValue::as_str)
1243                    .map(ToString::to_string)
1244                    .collect()
1245            }),
1246    }
1247}
1248
1249fn parse_json_latex(value: &JsonValue) -> Latex {
1250    let obj = value.as_object();
1251    Latex {
1252        text: obj
1253            .and_then(|o| o.get("text"))
1254            .and_then(JsonValue::as_str)
1255            .unwrap_or_default()
1256            .to_string(),
1257        processor: 0,
1258    }
1259}
1260
1261fn parse_json_div(value: &JsonValue) -> Div {
1262    let style = value
1263        .as_object()
1264        .and_then(|o| o.get("style"))
1265        .and_then(parse_div_style)
1266        .unwrap_or(0);
1267    Div { style }
1268}
1269
1270fn parse_json_table_row(value: &JsonValue) -> TableRow {
1271    let is_header = value
1272        .as_object()
1273        .and_then(|o| o.get("isHeader"))
1274        .and_then(JsonValue::as_bool)
1275        .unwrap_or(false);
1276    TableRow { is_header }
1277}
1278
1279fn parse_block_align(value: &JsonValue) -> Option<i32> {
1280    if let Some(name) = value.as_str() {
1281        return anytype_rpc::model::block::Align::from_str_name(name).map(|v| v as i32);
1282    }
1283    value.as_i64().and_then(|n| i32::try_from(n).ok())
1284}
1285
1286fn parse_block_vertical_align(value: &JsonValue) -> Option<i32> {
1287    if let Some(name) = value.as_str() {
1288        return anytype_rpc::model::block::VerticalAlign::from_str_name(name).map(|v| v as i32);
1289    }
1290    value.as_i64().and_then(|n| i32::try_from(n).ok())
1291}
1292
1293fn parse_text_style(value: &JsonValue) -> Option<i32> {
1294    if let Some(name) = value.as_str() {
1295        return TextStyle::from_str_name(name).map(|v| v as i32);
1296    }
1297    value.as_i64().and_then(|n| i32::try_from(n).ok())
1298}
1299
1300fn parse_mark_type(value: &JsonValue) -> Option<i32> {
1301    if let Some(name) = value.as_str() {
1302        return MarkType::from_str_name(name).map(|v| v as i32);
1303    }
1304    value.as_i64().and_then(|n| i32::try_from(n).ok())
1305}
1306
1307fn parse_file_type(value: &JsonValue) -> Option<i32> {
1308    if let Some(name) = value.as_str() {
1309        return FileType::from_str_name(name).map(|v| v as i32);
1310    }
1311    value.as_i64().and_then(|n| i32::try_from(n).ok())
1312}
1313
1314fn parse_file_state(value: &JsonValue) -> Option<i32> {
1315    if let Some(name) = value.as_str() {
1316        return FileState::from_str_name(name).map(|v| v as i32);
1317    }
1318    value.as_i64().and_then(|n| i32::try_from(n).ok())
1319}
1320
1321fn parse_file_style(value: &JsonValue) -> Option<i32> {
1322    if let Some(name) = value.as_str() {
1323        return anytype_rpc::model::block::content::file::Style::from_str_name(name)
1324            .map(|v| v as i32);
1325    }
1326    value.as_i64().and_then(|n| i32::try_from(n).ok())
1327}
1328
1329fn parse_link_style(value: &JsonValue) -> Option<i32> {
1330    if let Some(name) = value.as_str() {
1331        return anytype_rpc::model::block::content::link::Style::from_str_name(name)
1332            .map(|v| v as i32);
1333    }
1334    value.as_i64().and_then(|n| i32::try_from(n).ok())
1335}
1336
1337fn parse_link_icon_size(value: &JsonValue) -> Option<i32> {
1338    if let Some(name) = value.as_str() {
1339        return anytype_rpc::model::block::content::link::IconSize::from_str_name(name)
1340            .map(|v| v as i32);
1341    }
1342    value.as_i64().and_then(|n| i32::try_from(n).ok())
1343}
1344
1345fn parse_link_card_style(value: &JsonValue) -> Option<i32> {
1346    if let Some(name) = value.as_str() {
1347        return anytype_rpc::model::block::content::link::CardStyle::from_str_name(name)
1348            .map(|v| v as i32);
1349    }
1350    value.as_i64().and_then(|n| i32::try_from(n).ok())
1351}
1352
1353fn parse_link_description(value: &JsonValue) -> Option<i32> {
1354    if let Some(name) = value.as_str() {
1355        return anytype_rpc::model::block::content::link::Description::from_str_name(name)
1356            .map(|v| v as i32);
1357    }
1358    value.as_i64().and_then(|n| i32::try_from(n).ok())
1359}
1360
1361fn parse_div_style(value: &JsonValue) -> Option<i32> {
1362    if let Some(name) = value.as_str() {
1363        return DivStyle::from_str_name(name).map(|v| v as i32);
1364    }
1365    value.as_i64().and_then(|n| i32::try_from(n).ok())
1366}
1367
1368/// Convert a protobuf snapshot payload to markdown.
1369///
1370/// `object_index` should include object metadata (name/layout/file extension) for
1371/// linked objects so mentions/files can be rendered as markdown links.
1372pub fn convert_pb_snapshot_to_markdown(
1373    snapshot_bytes: &[u8],
1374    object_index: &HashMap<String, ArchiveObjectInfo, RandomState>,
1375) -> Result<String> {
1376    let snapshot =
1377        SnapshotWithType::decode(snapshot_bytes).context("failed to decode protobuf snapshot")?;
1378    let sb_type = SmartBlockType::try_from(snapshot.sb_type).unwrap_or(SmartBlockType::Page);
1379    if should_skip_export(sb_type) {
1380        return Ok(String::new());
1381    }
1382    let data = snapshot
1383        .snapshot
1384        .and_then(|v| v.data)
1385        .ok_or_else(|| anyhow!("snapshot payload missing data"))?;
1386    if data.blocks.is_empty() {
1387        return Ok(String::new());
1388    }
1389
1390    let mut blocks_by_id = HashMap::<String, &Block>::with_capacity(data.blocks.len());
1391    for block in &data.blocks {
1392        blocks_by_id.insert(block.id.clone(), block);
1393    }
1394
1395    let root_id = data
1396        .details
1397        .as_ref()
1398        .and_then(|details| struct_field_as_string(details, "id"))
1399        .unwrap_or_else(|| data.blocks[0].id.clone());
1400    let Some(root) = blocks_by_id.get(&root_id) else {
1401        bail!("root block not found: {root_id}");
1402    };
1403    if root.children_ids.is_empty() {
1404        return Ok(String::new());
1405    }
1406
1407    let converter = MarkdownConverter {
1408        blocks_by_id,
1409        docs: object_index,
1410    };
1411    let root = converter
1412        .blocks_by_id
1413        .get(&root_id)
1414        .ok_or_else(|| anyhow!("root block not found after converter init: {root_id}"))?;
1415    Ok(converter.render(root))
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420    use super::*;
1421
1422    #[test]
1423    fn convert_sample_pb_object_to_markdown_contains_headings() {
1424        let archive = Path::new("samples/getting-started-pb");
1425        let object_id = "bafyreidgyug7rj6lweslb5rbeavhc44ytr5osfwj6w5snlspnjnsqa6ytm";
1426        let markdown = convert_archive_object_pb_to_markdown(archive, object_id).unwrap();
1427        assert!(markdown.contains("How Widgets Work"));
1428        assert!(markdown.contains("## "));
1429        assert!(!markdown.is_empty());
1430        assert!(markdown.contains("   \n"));
1431    }
1432
1433    #[test]
1434    fn convert_sample_pb_object_renders_markdown_tables() {
1435        let archive = Path::new("samples/getting-started-pb");
1436        let object_id = "bafyreihs3oyibcjqhwjuynp6j6aaqjhz6quijsy4vgakv4223exvruc5wi";
1437        let markdown = convert_archive_object_pb_to_markdown(archive, object_id).unwrap();
1438        assert!(markdown.contains("Simple table 3x2"));
1439        assert!(markdown.contains('|'));
1440        assert!(markdown.contains(":-"));
1441    }
1442
1443    #[test]
1444    fn convert_sample_pb_json_object_to_markdown_contains_headings() {
1445        let archive = Path::new("samples/getting-started-json");
1446        let object_id = "bafyreidgyug7rj6lweslb5rbeavhc44ytr5osfwj6w5snlspnjnsqa6ytm";
1447        let markdown = convert_archive_object_to_markdown(archive, object_id).unwrap();
1448        assert!(markdown.contains("How Widgets Work"));
1449        assert!(markdown.contains("## "));
1450        assert!(!markdown.is_empty());
1451    }
1452
1453    #[test]
1454    fn save_sample_pb_json_document_writes_markdown() {
1455        let archive = Path::new("samples/getting-started-json");
1456        let object_id = "bafyreidgyug7rj6lweslb5rbeavhc44ytr5osfwj6w5snlspnjnsqa6ytm";
1457        let dir = tempfile::tempdir().unwrap();
1458        let dest = dir.path().join("out.md");
1459        let kind = save_archive_object(archive, object_id, &dest).unwrap();
1460        assert_eq!(kind, SavedObjectKind::Markdown);
1461        let text = fs::read_to_string(dest).unwrap();
1462        assert!(text.contains("How Widgets Work"));
1463    }
1464}