Skip to main content

anyback_reader/
markdown.rs

1use std::fmt;
2use std::fs;
3use std::path::Path;
4use std::{collections::HashMap, hash::RandomState};
5
6use anyhow::{Context, Result, anyhow, bail};
7use prost::Message;
8use prost_types::{Struct, value::Kind};
9
10use crate::archive::ArchiveReader;
11use anytype_rpc::{
12    anytype::SnapshotWithType,
13    model::{
14        Block, Range, SmartBlockType,
15        block::{
16            ContentValue,
17            content::{
18                Bookmark, Div, File, Latex, Link, Table, TableColumn, TableRow, Text,
19                div::Style as DivStyle,
20                file::{State as FileState, Type as FileType},
21                layout::Style as LayoutStyle,
22                text::{Mark, Style as TextStyle, mark::Type as MarkType},
23            },
24        },
25    },
26};
27use serde_json::Value as JsonValue;
28
29/// Metadata used to resolve object links and file names during markdown rendering.
30#[derive(Debug, Clone)]
31pub struct ArchiveObjectInfo {
32    pub id: String,
33    pub name: String,
34    pub snippet: String,
35    pub layout: Option<i64>,
36    pub file_ext: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SavedObjectKind {
41    Markdown,
42    Raw,
43}
44
45/// Error returned when a file-layout snapshot has no matching archive payload.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct MissingRawPayloadError {
48    object_id: String,
49}
50
51impl MissingRawPayloadError {
52    fn new(object_id: &str) -> Self {
53        Self {
54            object_id: object_id.to_string(),
55        }
56    }
57
58    /// Returns the object whose payload could not be resolved.
59    #[must_use]
60    pub fn object_id(&self) -> &str {
61        &self.object_id
62    }
63}
64
65impl fmt::Display for MissingRawPayloadError {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(
68            formatter,
69            "could not resolve raw payload for object: {}",
70            self.object_id
71        )
72    }
73}
74
75impl std::error::Error for MissingRawPayloadError {}
76
77#[derive(Debug, Clone, Default)]
78struct RenderState {
79    indent: String,
80    list_opened: bool,
81    list_number: usize,
82}
83
84impl RenderState {
85    fn with_space_indent(&self) -> Self {
86        let mut next = self.clone();
87        next.indent.push_str("    ");
88        next
89    }
90
91    fn with_nb_indent(&self) -> Self {
92        let mut next = self.clone();
93        next.indent.push_str("  ");
94        next
95    }
96}
97
98#[derive(Debug)]
99struct MarkdownConverter<'a> {
100    blocks_by_id: HashMap<String, &'a Block>,
101    docs: &'a HashMap<String, ArchiveObjectInfo, RandomState>,
102}
103
104impl MarkdownConverter<'_> {
105    fn render(&self, root: &Block) -> String {
106        let mut out = String::new();
107        let mut state = RenderState::default();
108        self.render_children(&mut out, &mut state, root);
109        out
110    }
111
112    fn render_children(&self, out: &mut String, state: &mut RenderState, parent: &Block) {
113        for child_id in &parent.children_ids {
114            let Some(block) = self.blocks_by_id.get(child_id) else {
115                continue;
116            };
117            self.render_block(out, state, block);
118        }
119    }
120
121    fn render_block(&self, out: &mut String, state: &mut RenderState, block: &Block) {
122        match block.content_value.as_ref() {
123            Some(ContentValue::Text(text)) => self.render_text(out, state, block, text),
124            Some(ContentValue::File(file)) => self.render_file(out, state, file),
125            Some(ContentValue::Bookmark(bookmark)) => self.render_bookmark(out, state, bookmark),
126            Some(ContentValue::Table(_)) => self.render_table(out, state, block),
127            Some(ContentValue::Div(div)) => {
128                if matches!(
129                    DivStyle::try_from(div.style).ok(),
130                    Some(DivStyle::Dots | DivStyle::Line)
131                ) {
132                    out.push_str(" --- \n");
133                }
134                self.render_children(out, state, block);
135            }
136            Some(ContentValue::Link(link)) => self.render_link(out, state, link),
137            Some(ContentValue::Latex(latex)) => self.render_latex(out, state, latex),
138            _ => self.render_children(out, state, block),
139        }
140    }
141
142    fn render_text(&self, out: &mut String, state: &mut RenderState, block: &Block, text: &Text) {
143        let style = TextStyle::try_from(text.style).unwrap_or(TextStyle::Paragraph);
144        if state.list_opened && !matches!(style, TextStyle::Marked | TextStyle::Numbered) {
145            out.push_str("   \n");
146            state.list_opened = false;
147            state.list_number = 0;
148        }
149
150        out.push_str(&state.indent);
151        match style {
152            TextStyle::Header1 | TextStyle::ToggleHeader1 | TextStyle::Title => {
153                out.push_str("# ");
154                self.render_text_content(out, text);
155                let mut nested = state.with_space_indent();
156                self.render_children(out, &mut nested, block);
157            }
158            TextStyle::Header2 | TextStyle::ToggleHeader2 => {
159                out.push_str("## ");
160                self.render_text_content(out, text);
161                let mut nested = state.with_space_indent();
162                self.render_children(out, &mut nested, block);
163            }
164            TextStyle::Header3 | TextStyle::ToggleHeader3 => {
165                out.push_str("### ");
166                self.render_text_content(out, text);
167                let mut nested = state.with_space_indent();
168                self.render_children(out, &mut nested, block);
169            }
170            TextStyle::Header4 => {
171                out.push_str("#### ");
172                self.render_text_content(out, text);
173                let mut nested = state.with_space_indent();
174                self.render_children(out, &mut nested, block);
175            }
176            TextStyle::Quote | TextStyle::Toggle => {
177                out.push_str("> ");
178                out.push_str(&text.text.replace('\n', "   \n> "));
179                out.push_str("   \n\n");
180                self.render_children(out, state, block);
181            }
182            TextStyle::Code => {
183                out.push_str("```\n");
184                out.push_str(&state.indent);
185                out.push_str(&text.text.replace("```", "\\`\\`\\`"));
186                out.push('\n');
187                out.push_str(&state.indent);
188                out.push_str("```\n");
189                self.render_children(out, state, block);
190            }
191            TextStyle::Checkbox => {
192                if text.checked {
193                    out.push_str("- [x] ");
194                } else {
195                    out.push_str("- [ ] ");
196                }
197                self.render_text_content(out, text);
198                let mut nested = state.with_nb_indent();
199                self.render_children(out, &mut nested, block);
200            }
201            TextStyle::Marked => {
202                out.push_str("- ");
203                self.render_text_content(out, text);
204                let mut nested = state.with_space_indent();
205                self.render_children(out, &mut nested, block);
206                state.list_opened = true;
207            }
208            TextStyle::Numbered => {
209                state.list_number += 1;
210                out.push_str(&format!("{}. ", state.list_number));
211                self.render_text_content(out, text);
212                let mut nested = state.with_space_indent();
213                self.render_children(out, &mut nested, block);
214                state.list_opened = true;
215            }
216            _ => {
217                self.render_text_content(out, text);
218                let mut nested = state.with_nb_indent();
219                self.render_children(out, &mut nested, block);
220            }
221        }
222    }
223
224    fn render_text_content(&self, out: &mut String, text: &Text) {
225        let mut marks = MarksWriter::new(self, text);
226        let chars: Vec<char> = text.text.chars().collect();
227        for (idx, ch) in chars.iter().enumerate() {
228            marks.write_marks(out, idx);
229            escape_markdown_char(*ch, out);
230        }
231        marks.write_marks(out, chars.len());
232        out.push_str("   \n");
233    }
234
235    fn render_file(&self, out: &mut String, state: &RenderState, file: &File) {
236        if !matches!(FileState::try_from(file.state).ok(), Some(FileState::Done)) {
237            return;
238        }
239        let (title, filename) = self.link_info_for_file(file);
240        if title.is_empty() || filename.is_empty() {
241            return;
242        }
243        out.push_str(&state.indent);
244        if matches!(FileType::try_from(file.r#type).ok(), Some(FileType::Image)) {
245            out.push_str(&format!("![{title}]({filename})    \n"));
246        } else {
247            out.push_str(&format!("[{title}]({filename})    \n"));
248        }
249    }
250
251    #[allow(clippy::unused_self)]
252    fn render_bookmark(&self, out: &mut String, state: &RenderState, bookmark: &Bookmark) {
253        if bookmark.url.is_empty() {
254            return;
255        }
256        out.push_str(&state.indent);
257        let title = if bookmark.title.is_empty() {
258            bookmark.url.clone()
259        } else {
260            escape_markdown_string(&bookmark.title)
261        };
262        out.push_str(&format!("[{}]({})    \n", title, bookmark.url));
263    }
264
265    fn render_link(&self, out: &mut String, state: &RenderState, link: &Link) {
266        if link.target_block_id.is_empty() {
267            return;
268        }
269        let Some((title, filename)) = self.link_info(&link.target_block_id) else {
270            return;
271        };
272        out.push_str(&state.indent);
273        out.push_str(&format!(
274            "[{}]({})    \n",
275            escape_markdown_string(&title),
276            filename
277        ));
278    }
279
280    #[allow(clippy::unused_self)]
281    fn render_latex(&self, out: &mut String, state: &RenderState, latex: &Latex) {
282        out.push_str(&state.indent);
283        out.push_str("\n$$\n");
284        out.push_str(&latex.text);
285        out.push_str("\n$$\n");
286    }
287
288    fn render_table(&self, out: &mut String, state: &mut RenderState, table_block: &Block) {
289        let mut column_ids: Vec<String> = Vec::new();
290        let mut row_ids: Vec<String> = Vec::new();
291
292        for child_id in &table_block.children_ids {
293            let Some(child) = self.blocks_by_id.get(child_id) else {
294                continue;
295            };
296            match child.content_value.as_ref() {
297                Some(ContentValue::Layout(layout)) => {
298                    match LayoutStyle::try_from(layout.style).ok() {
299                        Some(LayoutStyle::TableColumns) => {
300                            column_ids.clone_from(&child.children_ids);
301                        }
302                        Some(LayoutStyle::TableRows) => {
303                            row_ids.clone_from(&child.children_ids);
304                        }
305                        _ => {}
306                    }
307                }
308                Some(ContentValue::TableRow(_)) => row_ids.push(child.id.clone()),
309                Some(ContentValue::TableColumn(_)) => column_ids.push(child.id.clone()),
310                _ => {}
311            }
312        }
313
314        if row_ids.is_empty() {
315            self.render_children(out, state, table_block);
316            return;
317        }
318
319        let rows = self.build_table_rows(&row_ids, &column_ids);
320        write_markdown_table(out, &state.indent, rows);
321    }
322
323    fn build_table_rows(&self, row_ids: &[String], column_ids: &[String]) -> Vec<Vec<String>> {
324        let mut rows: Vec<Vec<String>> = Vec::new();
325        for row_id in row_ids {
326            let Some(row_block) = self.blocks_by_id.get(row_id) else {
327                continue;
328            };
329            let mut by_col: HashMap<String, String> = HashMap::new();
330            let mut unordered: Vec<String> = Vec::new();
331
332            for cell_id in &row_block.children_ids {
333                let Some(cell_block) = self.blocks_by_id.get(cell_id) else {
334                    continue;
335                };
336                let content = self.render_cell(cell_block);
337                if let Some(col_id) = cell_id.strip_prefix(&format!("{row_id}-")) {
338                    by_col.insert(col_id.to_string(), content);
339                } else {
340                    unordered.push(content);
341                }
342            }
343
344            if column_ids.is_empty() {
345                if by_col.is_empty() {
346                    rows.push(unordered);
347                } else {
348                    let mut pairs: Vec<(String, String)> = by_col.into_iter().collect();
349                    pairs.sort_by(|a, b| a.0.cmp(&b.0));
350                    rows.push(pairs.into_iter().map(|(_, v)| v).collect());
351                }
352                continue;
353            }
354
355            let mut row = Vec::with_capacity(column_ids.len());
356            for (idx, col_id) in column_ids.iter().enumerate() {
357                if let Some(cell) = by_col.remove(col_id) {
358                    row.push(cell);
359                } else if let Some(cell) = unordered.get(idx) {
360                    row.push(cell.clone());
361                } else {
362                    row.push(" ".to_string());
363                }
364            }
365            rows.push(row);
366        }
367        rows
368    }
369
370    fn render_cell(&self, block: &Block) -> String {
371        let mut text = String::new();
372        let mut state = RenderState::default();
373        self.render_block(&mut text, &mut state, block);
374        text = text.replace("\r\n", " ").replace('\n', " ");
375        let trimmed = text.trim();
376        if trimmed.is_empty() {
377            " ".to_string()
378        } else {
379            trimmed.to_string()
380        }
381    }
382
383    fn link_info_for_file(&self, file: &File) -> (String, String) {
384        if !file.target_object_id.is_empty() {
385            if let Some((title, filename)) = self.link_info(&file.target_object_id) {
386                return (title, filename);
387            }
388            let fallback_title = path_basename(&file.name).to_string();
389            let fallback_ext = file_ext_from_name(&file.name).unwrap_or_default();
390            let filename =
391                file_name_for_file(&file.target_object_id, &fallback_title, &fallback_ext);
392            return (fallback_title, filename);
393        }
394
395        let title = path_basename(&file.name).to_string();
396        let ext = file_ext_from_name(&file.name).unwrap_or_default();
397        let filename = file_name_for_file(&file.hash, &title, &ext);
398        (title, filename)
399    }
400
401    fn link_info(&self, object_id: &str) -> Option<(String, String)> {
402        let info = self.docs.get(object_id)?;
403        let mut title = info.name.clone();
404        if title.is_empty() {
405            title.clone_from(&info.snippet);
406        }
407        if title.is_empty() {
408            title = object_id.to_string();
409        }
410
411        let is_file = matches!(info.layout, Some(8..=12));
412        if is_file {
413            let ext = info
414                .file_ext
415                .as_deref()
416                .map(|ext| format!(".{}", ext.trim_start_matches('.')))
417                .unwrap_or_default();
418            let file_title = title.trim_end_matches(&ext).to_string();
419            let filename = file_name_for_file(object_id, &file_title, &ext);
420            return Some((file_title, filename));
421        }
422
423        let filename = file_name_for_doc(object_id, &title);
424        Some((title, filename))
425    }
426}
427
428#[derive(Debug, Clone)]
429struct MarkRange {
430    from: usize,
431    to: usize,
432    mark: Mark,
433}
434
435#[derive(Debug)]
436struct MarksWriter<'a, 'b> {
437    converter: &'a MarkdownConverter<'b>,
438    starts: HashMap<usize, Vec<MarkRange>>,
439    ends: HashMap<usize, Vec<MarkRange>>,
440    open: Vec<MarkRange>,
441}
442
443impl<'a, 'b> MarksWriter<'a, 'b> {
444    fn new(converter: &'a MarkdownConverter<'b>, text: &Text) -> Self {
445        let mut starts: HashMap<usize, Vec<MarkRange>> = HashMap::new();
446        let mut ends: HashMap<usize, Vec<MarkRange>> = HashMap::new();
447        if let Some(marks) = text.marks.as_ref() {
448            for mark in &marks.marks {
449                let Some(range) = mark.range.as_ref() else {
450                    continue;
451                };
452                if range.from == range.to || range.from < 0 || range.to < 0 {
453                    continue;
454                }
455                #[allow(clippy::cast_sign_loss)]
456                let item = MarkRange {
457                    from: range.from as usize,
458                    to: range.to as usize,
459                    mark: mark.clone(),
460                };
461                starts.entry(item.from).or_default().push(item.clone());
462                ends.entry(item.to).or_default().push(item);
463            }
464        }
465        for values in starts.values_mut() {
466            values.sort_by(|a, b| {
467                let la = a.to.saturating_sub(a.from);
468                let lb = b.to.saturating_sub(b.from);
469                lb.cmp(&la).then_with(|| a.mark.r#type.cmp(&b.mark.r#type))
470            });
471        }
472        for values in ends.values_mut() {
473            values.sort_by(|a, b| {
474                let la = a.to.saturating_sub(a.from);
475                let lb = b.to.saturating_sub(b.from);
476                lb.cmp(&la).then_with(|| a.mark.r#type.cmp(&b.mark.r#type))
477            });
478        }
479        Self {
480            converter,
481            starts,
482            ends,
483            open: Vec::new(),
484        }
485    }
486
487    fn write_marks(&mut self, out: &mut String, pos: usize) {
488        if let Some(ends) = self.ends.get(&pos).cloned() {
489            for item in ends.iter().rev() {
490                if let Some(last) = self.open.pop()
491                    && (last.from != item.from || last.to != item.to || last.mark != item.mark)
492                {
493                    self.open.push(last.clone());
494                }
495                self.write_mark(out, &item.mark, false);
496            }
497        }
498        if let Some(starts) = self.starts.get(&pos).cloned() {
499            for item in &starts {
500                self.write_mark(out, &item.mark, true);
501                self.open.push(item.clone());
502            }
503        }
504    }
505
506    fn write_mark(&self, out: &mut String, mark: &Mark, start: bool) {
507        let kind = MarkType::try_from(mark.r#type).ok();
508        match kind {
509            Some(MarkType::Strikethrough) => out.push_str("~~"),
510            Some(MarkType::Italic) => out.push('*'),
511            Some(MarkType::Bold) => out.push_str("**"),
512            Some(MarkType::Keyboard) => out.push('`'),
513            Some(MarkType::Link) => {
514                if start {
515                    out.push('[');
516                } else {
517                    out.push_str(&format!("]({})", mark.param));
518                }
519            }
520            Some(MarkType::Mention | MarkType::Object) => {
521                if let Some((_, filename)) = self.converter.link_info(&mark.param) {
522                    if start {
523                        out.push('[');
524                    } else {
525                        out.push_str(&format!("]({filename})"));
526                    }
527                }
528            }
529            Some(MarkType::Emoji) if start => {
530                out.push_str(&mark.param);
531            }
532            _ => {}
533        }
534    }
535}
536
537fn write_markdown_table(out: &mut String, indent: &str, mut rows: Vec<Vec<String>>) {
538    if rows.is_empty() {
539        return;
540    }
541    let cols = rows.iter().map(std::vec::Vec::len).max().unwrap_or(0);
542    if cols == 0 {
543        return;
544    }
545    for row in &mut rows {
546        while row.len() < cols {
547            row.push(" ".to_string());
548        }
549    }
550
551    let mut widths = vec![3usize; cols];
552    for row in &rows {
553        for (idx, cell) in row.iter().enumerate() {
554            widths[idx] = widths[idx].max(cell.len());
555        }
556    }
557
558    for (idx, row) in rows.iter().enumerate() {
559        out.push_str(indent);
560        out.push('|');
561        for (col, cell) in row.iter().enumerate() {
562            out.push_str(&format!(" {:<width$} |", cell, width = widths[col]));
563        }
564        out.push('\n');
565
566        if idx == 0 {
567            out.push_str(indent);
568            out.push('|');
569            for width in &widths {
570                out.push(':');
571                out.push_str(&"-".repeat(width.saturating_add(1)));
572                out.push('|');
573            }
574            out.push('\n');
575        }
576    }
577    out.push('\n');
578}
579
580fn escape_markdown_char(ch: char, out: &mut String) {
581    if matches!(
582        ch,
583        '\\' | '`'
584            | '*'
585            | '_'
586            | '{'
587            | '}'
588            | '['
589            | ']'
590            | '('
591            | ')'
592            | '#'
593            | '+'
594            | '-'
595            | '.'
596            | '!'
597            | '|'
598            | '>'
599            | '~'
600    ) {
601        out.push('\\');
602    }
603    out.push(ch);
604}
605
606fn escape_markdown_string(value: &str) -> String {
607    let mut out = String::with_capacity(value.len() + 8);
608    for ch in value.chars() {
609        escape_markdown_char(ch, &mut out);
610    }
611    out
612}
613
614fn path_basename(path: &str) -> &str {
615    Path::new(path)
616        .file_name()
617        .and_then(|v| v.to_str())
618        .unwrap_or(path)
619}
620
621fn file_ext_from_name(name: &str) -> Option<String> {
622    Path::new(name)
623        .extension()
624        .and_then(|v| v.to_str())
625        .map(|v| format!(".{v}"))
626}
627
628fn sanitize_filename(input: &str) -> String {
629    let mut out = String::with_capacity(input.len());
630    for ch in input.chars() {
631        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
632            out.push(ch.to_ascii_lowercase());
633        } else if ch.is_whitespace() || matches!(ch, '/' | '\\') {
634            out.push('_');
635        }
636    }
637    let compact = out.trim_matches('_');
638    if compact.is_empty() {
639        "untitled".to_string()
640    } else {
641        compact.to_string()
642    }
643}
644
645fn file_name_for_doc(id: &str, title: &str) -> String {
646    let base = sanitize_filename(title);
647    format!("{base}_{id}.md")
648}
649
650fn file_name_for_file(id: &str, title: &str, ext: &str) -> String {
651    let base = sanitize_filename(title);
652    format!("files/{base}_{id}{ext}")
653}
654
655fn struct_field_as_string(details: &Struct, key: &str) -> Option<String> {
656    let value = details.fields.get(key)?;
657    match value.kind.as_ref()? {
658        Kind::StringValue(v) => Some(v.clone()),
659        Kind::NumberValue(v) => Some(v.to_string()),
660        Kind::BoolValue(v) => Some(v.to_string()),
661        _ => None,
662    }
663}
664
665/// Build a lightweight archive-wide object metadata index used for markdown link rendering.
666pub fn build_archive_object_index(
667    reader: &ArchiveReader,
668) -> Result<HashMap<String, ArchiveObjectInfo>> {
669    let mut out = HashMap::new();
670    for file in reader.list_files()? {
671        let lower = file.path.to_ascii_lowercase();
672        #[allow(clippy::case_sensitive_file_extension_comparisons)]
673        if !lower.ends_with(".pb") && !lower.ends_with(".pb.json") {
674            continue;
675        }
676        let Ok(bytes) = reader.read_bytes(&file.path) else {
677            continue;
678        };
679        let Ok(details) = parse_snapshot_details_to_map(&file.path, &bytes) else {
680            continue;
681        };
682        let Some(id) = details.get("id").cloned().filter(|v| !v.is_empty()) else {
683            continue;
684        };
685        let info = ArchiveObjectInfo {
686            id: id.clone(),
687            name: details.get("name").cloned().unwrap_or_default(),
688            snippet: details.get("snippet").cloned().unwrap_or_default(),
689            layout: details
690                .get("layout")
691                .and_then(|v| v.parse::<i64>().ok())
692                .or_else(|| {
693                    details
694                        .get("resolvedLayout")
695                        .and_then(|v| v.parse::<i64>().ok())
696                }),
697            file_ext: details.get("fileExt").cloned(),
698        };
699        out.insert(info.id.clone(), info);
700    }
701    Ok(out)
702}
703
704fn find_snapshot_path(reader: &ArchiveReader, object_id: &str) -> Option<String> {
705    let pb = format!("{object_id}.pb");
706    let pb_json = format!("{object_id}.pb.json");
707    let files = reader.list_files().ok()?;
708    files.iter().find_map(|f| {
709        let lower = f.path.to_ascii_lowercase();
710        if lower.ends_with(&pb) || lower.ends_with(&pb_json) {
711            Some(f.path.clone())
712        } else {
713            None
714        }
715    })
716}
717
718/// Convert a snapshot file (`objects/<id>.pb` or `objects/<id>.pb.json`) to markdown text,
719/// using a prebuilt object index for link/name resolution.
720pub fn convert_archive_snapshot_to_markdown(
721    reader: &ArchiveReader,
722    snapshot_path: &str,
723    object_index: &HashMap<String, ArchiveObjectInfo, RandomState>,
724) -> Result<String> {
725    let snapshot_bytes = reader
726        .read_bytes(snapshot_path)
727        .with_context(|| format!("failed reading snapshot from archive: {snapshot_path}"))?;
728    convert_snapshot_bytes_to_markdown(snapshot_path, &snapshot_bytes, object_index)
729}
730
731/// Convert raw snapshot bytes (`*.pb`/`*.pb.json`) to markdown using a prebuilt object index.
732pub fn convert_snapshot_bytes_to_markdown(
733    snapshot_path: &str,
734    snapshot_bytes: &[u8],
735    object_index: &HashMap<String, ArchiveObjectInfo, RandomState>,
736) -> Result<String> {
737    let lower = snapshot_path.to_ascii_lowercase();
738    #[allow(clippy::case_sensitive_file_extension_comparisons)]
739    if lower.ends_with(".pb") {
740        return convert_pb_snapshot_to_markdown(snapshot_bytes, object_index);
741    }
742    if lower.ends_with(".pb.json") {
743        return convert_pb_json_snapshot_to_markdown(snapshot_bytes, object_index);
744    }
745    bail!("unsupported snapshot format: {snapshot_path}")
746}
747
748fn parse_snapshot_details_to_map(path: &str, bytes: &[u8]) -> Result<HashMap<String, String>> {
749    let lower = path.to_ascii_lowercase();
750    #[allow(clippy::case_sensitive_file_extension_comparisons)]
751    if lower.ends_with(".pb") {
752        let snapshot =
753            SnapshotWithType::decode(bytes).context("failed to decode protobuf snapshot")?;
754        let data = snapshot
755            .snapshot
756            .and_then(|v| v.data)
757            .ok_or_else(|| anyhow!("snapshot payload missing data"))?;
758        let Some(details) = data.details else {
759            return Ok(HashMap::new());
760        };
761        let mut map = HashMap::new();
762        for (k, v) in details.fields {
763            if let Some(value) = prost_value_to_string(&v) {
764                map.insert(k, value);
765            }
766        }
767        return Ok(map);
768    }
769    if lower.ends_with(".pb.json") {
770        let root: JsonValue = serde_json::from_slice(bytes).context("invalid pb-json")?;
771        let details = root
772            .get("snapshot")
773            .and_then(|v| v.get("data"))
774            .and_then(|v| v.get("details"))
775            .and_then(JsonValue::as_object)
776            .ok_or_else(|| anyhow!("pb-json snapshot missing details object"))?;
777        let mut map = HashMap::new();
778        for (k, v) in details {
779            if let Some(value) = json_value_to_string(v) {
780                map.insert(k.clone(), value);
781            }
782        }
783        return Ok(map);
784    }
785    bail!("unsupported snapshot format: {path}")
786}
787
788fn prost_value_to_string(v: &prost_types::Value) -> Option<String> {
789    match v.kind.as_ref()? {
790        Kind::StringValue(s) => Some(s.clone()),
791        Kind::NumberValue(n) => Some(n.to_string()),
792        Kind::BoolValue(b) => Some(b.to_string()),
793        _ => None,
794    }
795}
796
797fn json_value_to_string(v: &JsonValue) -> Option<String> {
798    if let Some(s) = v.as_str() {
799        return Some(s.to_string());
800    }
801    if let Some(n) = v.as_i64() {
802        return Some(n.to_string());
803    }
804    if let Some(n) = v.as_f64() {
805        return Some(n.to_string());
806    }
807    if let Some(b) = v.as_bool() {
808        return Some(b.to_string());
809    }
810    None
811}
812
813fn infer_raw_payload_path(
814    object_id: &str,
815    details: &HashMap<String, String>,
816    files: &[crate::archive::ArchiveFileEntry],
817) -> Option<String> {
818    let mut tokens = Vec::<String>::new();
819    if !object_id.is_empty() {
820        tokens.push(object_id.to_ascii_lowercase());
821    }
822    for key in [
823        "source",
824        "fileHash",
825        "hash",
826        "fileObjectId",
827        "targetObjectId",
828        "fileName",
829        "name",
830        "oldAnytypeID",
831    ] {
832        if let Some(value) = details.get(key) {
833            let token = value.trim().to_ascii_lowercase();
834            if !token.is_empty() {
835                tokens.push(token);
836            }
837        }
838    }
839    if let Some(ext) = details.get("fileExt") {
840        let token = ext.trim().trim_start_matches('.').to_ascii_lowercase();
841        if !token.is_empty() {
842            tokens.push(format!(".{token}"));
843        }
844    }
845
846    let mut best: Option<(&str, i32)> = None;
847    for file in files {
848        let path_lc = file.path.to_ascii_lowercase();
849        #[allow(clippy::case_sensitive_file_extension_comparisons)]
850        if path_lc.ends_with(".pb") || path_lc.ends_with(".pb.json") || path_lc == "manifest.json" {
851            continue;
852        }
853        let mut score = 0;
854        if path_lc.starts_with("files/") {
855            score += 30;
856        }
857        for token in &tokens {
858            if token.len() < 3 {
859                continue;
860            }
861            if path_lc.contains(token) {
862                score += 25;
863            }
864        }
865        if score == 0 {
866            continue;
867        }
868        match best {
869            Some((_, best_score)) if best_score >= score => {}
870            _ => best = Some((file.path.as_str(), score)),
871        }
872    }
873    best.map(|(path, _)| path.to_string())
874}
875
876fn should_skip_export(sb_type: SmartBlockType) -> bool {
877    matches!(
878        sb_type,
879        SmartBlockType::StType
880            | SmartBlockType::StRelation
881            | SmartBlockType::StRelationOption
882            | SmartBlockType::Participant
883            | SmartBlockType::SpaceView
884            | SmartBlockType::ChatObjectDeprecated
885            | SmartBlockType::ChatDerivedObject
886    )
887}
888
889/// Convert one archive object snapshot (`objects/<id>.pb`) to markdown text.
890///
891/// This reads the target object snapshot from the archive and builds a lightweight
892/// index of object details from sibling `*.pb` snapshots so object/file links can be
893/// rendered as markdown links.
894pub fn convert_archive_object_pb_to_markdown(
895    archive_path: &Path,
896    object_id: &str,
897) -> Result<String> {
898    let reader = ArchiveReader::from_path(archive_path)?;
899    let snapshot_path = find_snapshot_path(&reader, object_id)
900        .ok_or_else(|| anyhow!("snapshot not found in archive for object: {object_id}"))?;
901    if !snapshot_path.to_ascii_lowercase().ends_with(".pb") {
902        bail!("markdown conversion currently supports protobuf snapshots (*.pb) only");
903    }
904    let snapshot_bytes = reader
905        .read_bytes(&snapshot_path)
906        .with_context(|| format!("failed reading snapshot from archive: {snapshot_path}"))?;
907    let object_index = build_archive_object_index(&reader)?;
908    convert_pb_snapshot_to_markdown(&snapshot_bytes, &object_index)
909}
910
911/// Convert one archive object snapshot (`objects/<id>.pb` or `objects/<id>.pb.json`) to markdown.
912pub fn convert_archive_object_to_markdown(archive_path: &Path, object_id: &str) -> Result<String> {
913    let reader = ArchiveReader::from_path(archive_path)?;
914    let snapshot_path = find_snapshot_path(&reader, object_id)
915        .ok_or_else(|| anyhow!("snapshot not found in archive for object: {object_id}"))?;
916    let object_index = build_archive_object_index(&reader)?;
917    convert_archive_snapshot_to_markdown(&reader, &snapshot_path, &object_index)
918}
919
920pub fn save_archive_object(
921    archive_path: &Path,
922    object_id: &str,
923    dest: &Path,
924) -> Result<SavedObjectKind> {
925    let reader = ArchiveReader::from_path(archive_path)?;
926    let files = reader.list_files()?;
927    let snapshot_path = find_snapshot_path(&reader, object_id)
928        .ok_or_else(|| anyhow!("snapshot not found in archive for object: {object_id}"))?;
929    let snapshot_bytes = reader
930        .read_bytes(&snapshot_path)
931        .with_context(|| format!("failed reading snapshot from archive: {snapshot_path}"))?;
932    let details = parse_snapshot_details_to_map(&snapshot_path, &snapshot_bytes)?;
933
934    if !is_file_layout_from_details(&details) {
935        let markdown = convert_archive_object_to_markdown(archive_path, object_id)?;
936        fs::write(dest, markdown)
937            .with_context(|| format!("failed writing markdown to {}", dest.display()))?;
938        return Ok(SavedObjectKind::Markdown);
939    }
940
941    let payload = infer_raw_payload_path(object_id, &details, &files)
942        .ok_or_else(|| MissingRawPayloadError::new(object_id))?;
943    let bytes = reader
944        .read_bytes(&payload)
945        .with_context(|| format!("failed reading payload from archive: {payload}"))?;
946    fs::write(dest, bytes)
947        .with_context(|| format!("failed writing raw payload to {}", dest.display()))?;
948    Ok(SavedObjectKind::Raw)
949}
950
951fn is_file_layout_from_details(details: &HashMap<String, String>) -> bool {
952    let parse_i64 = |key: &str| details.get(key).and_then(|v| v.parse::<i64>().ok());
953    matches!(
954        parse_i64("layout").or_else(|| parse_i64("resolvedLayout")),
955        Some(8..=12)
956    )
957}
958
959fn convert_pb_json_snapshot_to_markdown(
960    snapshot_bytes: &[u8],
961    object_index: &HashMap<String, ArchiveObjectInfo>,
962) -> Result<String> {
963    let root: JsonValue = serde_json::from_slice(snapshot_bytes).context("invalid pb-json")?;
964    let sb_type = parse_json_smart_block_type(&root);
965    if let Some(sb_type) = sb_type
966        && should_skip_export(sb_type)
967    {
968        return Ok(String::new());
969    }
970    let data = root
971        .get("snapshot")
972        .and_then(|v| v.get("data"))
973        .ok_or_else(|| anyhow!("pb-json snapshot missing snapshot.data"))?;
974    let blocks_json = data
975        .get("blocks")
976        .and_then(JsonValue::as_array)
977        .ok_or_else(|| anyhow!("pb-json snapshot missing snapshot.data.blocks"))?;
978    if blocks_json.is_empty() {
979        return Ok(String::new());
980    }
981    let blocks: Vec<Block> = blocks_json
982        .iter()
983        .map(parse_json_block)
984        .collect::<Result<Vec<_>>>()?;
985    if blocks.is_empty() {
986        return Ok(String::new());
987    }
988
989    let mut blocks_by_id = HashMap::<String, &Block>::with_capacity(blocks.len());
990    for block in &blocks {
991        blocks_by_id.insert(block.id.clone(), block);
992    }
993
994    let root_id = data
995        .get("details")
996        .and_then(JsonValue::as_object)
997        .and_then(|details| details.get("id"))
998        .and_then(JsonValue::as_str)
999        .map_or_else(|| blocks[0].id.clone(), ToString::to_string);
1000    let Some(root) = blocks_by_id.get(&root_id) else {
1001        bail!("root block not found: {root_id}");
1002    };
1003    if root.children_ids.is_empty() {
1004        return Ok(String::new());
1005    }
1006
1007    let converter = MarkdownConverter {
1008        blocks_by_id,
1009        docs: object_index,
1010    };
1011    let root = converter
1012        .blocks_by_id
1013        .get(&root_id)
1014        .ok_or_else(|| anyhow!("root block not found after converter init: {root_id}"))?;
1015    Ok(converter.render(root))
1016}
1017
1018fn parse_json_smart_block_type(root: &JsonValue) -> Option<SmartBlockType> {
1019    let sb = root.get("sbType")?;
1020    if let Some(name) = sb.as_str() {
1021        return SmartBlockType::from_str_name(name);
1022    }
1023    if let Some(value) = sb.as_i64().and_then(|n| i32::try_from(n).ok()) {
1024        return SmartBlockType::try_from(value).ok();
1025    }
1026    None
1027}
1028
1029fn parse_json_block(value: &JsonValue) -> Result<Block> {
1030    let obj = value
1031        .as_object()
1032        .ok_or_else(|| anyhow!("pb-json block is not an object"))?;
1033    let id = obj
1034        .get("id")
1035        .and_then(JsonValue::as_str)
1036        .ok_or_else(|| anyhow!("pb-json block missing id"))?
1037        .to_string();
1038    let children_ids = obj
1039        .get("childrenIds")
1040        .and_then(JsonValue::as_array)
1041        .map_or_else(Vec::new, |items| {
1042            items
1043                .iter()
1044                .filter_map(JsonValue::as_str)
1045                .map(ToString::to_string)
1046                .collect()
1047        });
1048    let background_color = obj
1049        .get("backgroundColor")
1050        .and_then(JsonValue::as_str)
1051        .unwrap_or_default()
1052        .to_string();
1053    let align = obj
1054        .get("align")
1055        .map_or(0, |v| parse_block_align(v).unwrap_or_default());
1056    let vertical_align = obj
1057        .get("verticalAlign")
1058        .map_or(0, |v| parse_block_vertical_align(v).unwrap_or_default());
1059    let content_value = parse_json_content_value(obj)?;
1060
1061    Ok(Block {
1062        id,
1063        fields: None,
1064        restrictions: None,
1065        children_ids,
1066        background_color,
1067        align,
1068        vertical_align,
1069        content_value,
1070    })
1071}
1072
1073fn parse_json_content_value(
1074    obj: &serde_json::Map<String, JsonValue>,
1075) -> Result<Option<ContentValue>> {
1076    if let Some(v) = obj.get("text") {
1077        return Ok(Some(ContentValue::Text(parse_json_text(v)?)));
1078    }
1079    if let Some(v) = obj.get("file") {
1080        return Ok(Some(ContentValue::File(parse_json_file(v)?)));
1081    }
1082    if let Some(v) = obj.get("bookmark") {
1083        return Ok(Some(ContentValue::Bookmark(parse_json_bookmark(v))));
1084    }
1085    if let Some(v) = obj.get("link") {
1086        return Ok(Some(ContentValue::Link(parse_json_link(v))));
1087    }
1088    if let Some(v) = obj.get("latex") {
1089        return Ok(Some(ContentValue::Latex(parse_json_latex(v))));
1090    }
1091    if let Some(v) = obj.get("div") {
1092        return Ok(Some(ContentValue::Div(parse_json_div(v))));
1093    }
1094    if obj.contains_key("table") {
1095        return Ok(Some(ContentValue::Table(Table {})));
1096    }
1097    if obj.contains_key("tableColumn") {
1098        return Ok(Some(ContentValue::TableColumn(TableColumn {})));
1099    }
1100    if let Some(v) = obj.get("tableRow") {
1101        return Ok(Some(ContentValue::TableRow(parse_json_table_row(v))));
1102    }
1103    Ok(None)
1104}
1105
1106fn parse_json_text(value: &JsonValue) -> Result<Text> {
1107    let obj = value
1108        .as_object()
1109        .ok_or_else(|| anyhow!("pb-json text block is not an object"))?;
1110    let style = obj
1111        .get("style")
1112        .map_or(0, |v| parse_text_style(v).unwrap_or(0));
1113    let marks = obj
1114        .get("marks")
1115        .map(parse_json_marks)
1116        .transpose()?
1117        .or_else(|| Some(anytype_rpc::model::block::content::text::Marks { marks: Vec::new() }));
1118
1119    Ok(Text {
1120        text: obj
1121            .get("text")
1122            .and_then(JsonValue::as_str)
1123            .unwrap_or_default()
1124            .to_string(),
1125        style,
1126        marks,
1127        checked: obj
1128            .get("checked")
1129            .and_then(JsonValue::as_bool)
1130            .unwrap_or(false),
1131        color: obj
1132            .get("color")
1133            .and_then(JsonValue::as_str)
1134            .unwrap_or_default()
1135            .to_string(),
1136        icon_emoji: obj
1137            .get("iconEmoji")
1138            .and_then(JsonValue::as_str)
1139            .unwrap_or_default()
1140            .to_string(),
1141        icon_image: obj
1142            .get("iconImage")
1143            .and_then(JsonValue::as_str)
1144            .unwrap_or_default()
1145            .to_string(),
1146    })
1147}
1148
1149fn parse_json_marks(value: &JsonValue) -> Result<anytype_rpc::model::block::content::text::Marks> {
1150    let obj = value
1151        .as_object()
1152        .ok_or_else(|| anyhow!("pb-json marks is not an object"))?;
1153    let marks = obj
1154        .get("marks")
1155        .and_then(JsonValue::as_array)
1156        .map_or_else(Vec::new, |items| {
1157            items.iter().filter_map(parse_json_mark).collect()
1158        });
1159    Ok(anytype_rpc::model::block::content::text::Marks { marks })
1160}
1161
1162fn parse_json_mark(value: &JsonValue) -> Option<Mark> {
1163    let obj = value.as_object()?;
1164    let range = obj.get("range").and_then(parse_json_range);
1165    let r#type = obj
1166        .get("type")
1167        .map_or(0, |v| parse_mark_type(v).unwrap_or(0));
1168    let param = obj
1169        .get("param")
1170        .and_then(JsonValue::as_str)
1171        .unwrap_or_default()
1172        .to_string();
1173    Some(Mark {
1174        range,
1175        r#type,
1176        param,
1177    })
1178}
1179
1180fn parse_json_range(value: &JsonValue) -> Option<Range> {
1181    let obj = value.as_object()?;
1182    let from = obj.get("from").and_then(JsonValue::as_i64)?;
1183    let to = obj.get("to").and_then(JsonValue::as_i64)?;
1184    Some(Range {
1185        from: i32::try_from(from).ok()?,
1186        to: i32::try_from(to).ok()?,
1187    })
1188}
1189
1190fn parse_json_file(value: &JsonValue) -> Result<File> {
1191    let obj = value
1192        .as_object()
1193        .ok_or_else(|| anyhow!("pb-json file block is not an object"))?;
1194    Ok(File {
1195        hash: obj
1196            .get("hash")
1197            .and_then(JsonValue::as_str)
1198            .unwrap_or_default()
1199            .to_string(),
1200        name: obj
1201            .get("name")
1202            .and_then(JsonValue::as_str)
1203            .unwrap_or_default()
1204            .to_string(),
1205        r#type: obj
1206            .get("type")
1207            .map_or(0, |v| parse_file_type(v).unwrap_or(0)),
1208        mime: obj
1209            .get("mime")
1210            .and_then(JsonValue::as_str)
1211            .unwrap_or_default()
1212            .to_string(),
1213        size: obj.get("size").and_then(JsonValue::as_i64).unwrap_or(0),
1214        added_at: obj.get("addedAt").and_then(JsonValue::as_i64).unwrap_or(0),
1215        target_object_id: obj
1216            .get("targetObjectId")
1217            .and_then(JsonValue::as_str)
1218            .unwrap_or_default()
1219            .to_string(),
1220        state: obj
1221            .get("state")
1222            .map_or(FileState::Done as i32, |v| parse_file_state(v).unwrap_or(0)),
1223        style: obj
1224            .get("style")
1225            .map_or(0, |v| parse_file_style(v).unwrap_or(0)),
1226    })
1227}
1228
1229fn parse_json_bookmark(value: &JsonValue) -> Bookmark {
1230    let obj = value.as_object();
1231    Bookmark {
1232        url: obj
1233            .and_then(|o| o.get("url"))
1234            .and_then(JsonValue::as_str)
1235            .unwrap_or_default()
1236            .to_string(),
1237        title: obj
1238            .and_then(|o| o.get("title"))
1239            .and_then(JsonValue::as_str)
1240            .unwrap_or_default()
1241            .to_string(),
1242        description: obj
1243            .and_then(|o| o.get("description"))
1244            .and_then(JsonValue::as_str)
1245            .unwrap_or_default()
1246            .to_string(),
1247        image_hash: obj
1248            .and_then(|o| o.get("imageHash"))
1249            .and_then(JsonValue::as_str)
1250            .unwrap_or_default()
1251            .to_string(),
1252        favicon_hash: obj
1253            .and_then(|o| o.get("faviconHash"))
1254            .and_then(JsonValue::as_str)
1255            .unwrap_or_default()
1256            .to_string(),
1257        r#type: 0,
1258        target_object_id: obj
1259            .and_then(|o| o.get("targetObjectId"))
1260            .and_then(JsonValue::as_str)
1261            .unwrap_or_default()
1262            .to_string(),
1263        state: 0,
1264    }
1265}
1266
1267fn parse_json_link(value: &JsonValue) -> Link {
1268    let obj = value.as_object();
1269    Link {
1270        target_block_id: obj
1271            .and_then(|o| o.get("targetBlockId"))
1272            .and_then(JsonValue::as_str)
1273            .unwrap_or_default()
1274            .to_string(),
1275        style: obj
1276            .and_then(|o| o.get("style"))
1277            .map_or(0, |v| parse_link_style(v).unwrap_or(0)),
1278        fields: None,
1279        icon_size: obj
1280            .and_then(|o| o.get("iconSize"))
1281            .map_or(0, |v| parse_link_icon_size(v).unwrap_or(0)),
1282        card_style: obj
1283            .and_then(|o| o.get("cardStyle"))
1284            .map_or(0, |v| parse_link_card_style(v).unwrap_or(0)),
1285        description: obj
1286            .and_then(|o| o.get("description"))
1287            .map_or(0, |v| parse_link_description(v).unwrap_or(0)),
1288        relations: obj
1289            .and_then(|o| o.get("relations"))
1290            .and_then(JsonValue::as_array)
1291            .map_or_else(Vec::new, |arr| {
1292                arr.iter()
1293                    .filter_map(JsonValue::as_str)
1294                    .map(ToString::to_string)
1295                    .collect()
1296            }),
1297    }
1298}
1299
1300fn parse_json_latex(value: &JsonValue) -> Latex {
1301    let obj = value.as_object();
1302    Latex {
1303        text: obj
1304            .and_then(|o| o.get("text"))
1305            .and_then(JsonValue::as_str)
1306            .unwrap_or_default()
1307            .to_string(),
1308        processor: 0,
1309    }
1310}
1311
1312fn parse_json_div(value: &JsonValue) -> Div {
1313    let style = value
1314        .as_object()
1315        .and_then(|o| o.get("style"))
1316        .and_then(parse_div_style)
1317        .unwrap_or(0);
1318    Div { style }
1319}
1320
1321fn parse_json_table_row(value: &JsonValue) -> TableRow {
1322    let is_header = value
1323        .as_object()
1324        .and_then(|o| o.get("isHeader"))
1325        .and_then(JsonValue::as_bool)
1326        .unwrap_or(false);
1327    TableRow { is_header }
1328}
1329
1330fn parse_block_align(value: &JsonValue) -> Option<i32> {
1331    if let Some(name) = value.as_str() {
1332        return anytype_rpc::model::block::Align::from_str_name(name).map(|v| v as i32);
1333    }
1334    value.as_i64().and_then(|n| i32::try_from(n).ok())
1335}
1336
1337fn parse_block_vertical_align(value: &JsonValue) -> Option<i32> {
1338    if let Some(name) = value.as_str() {
1339        return anytype_rpc::model::block::VerticalAlign::from_str_name(name).map(|v| v as i32);
1340    }
1341    value.as_i64().and_then(|n| i32::try_from(n).ok())
1342}
1343
1344fn parse_text_style(value: &JsonValue) -> Option<i32> {
1345    if let Some(name) = value.as_str() {
1346        return TextStyle::from_str_name(name).map(|v| v as i32);
1347    }
1348    value.as_i64().and_then(|n| i32::try_from(n).ok())
1349}
1350
1351fn parse_mark_type(value: &JsonValue) -> Option<i32> {
1352    if let Some(name) = value.as_str() {
1353        return MarkType::from_str_name(name).map(|v| v as i32);
1354    }
1355    value.as_i64().and_then(|n| i32::try_from(n).ok())
1356}
1357
1358fn parse_file_type(value: &JsonValue) -> Option<i32> {
1359    if let Some(name) = value.as_str() {
1360        return FileType::from_str_name(name).map(|v| v as i32);
1361    }
1362    value.as_i64().and_then(|n| i32::try_from(n).ok())
1363}
1364
1365fn parse_file_state(value: &JsonValue) -> Option<i32> {
1366    if let Some(name) = value.as_str() {
1367        return FileState::from_str_name(name).map(|v| v as i32);
1368    }
1369    value.as_i64().and_then(|n| i32::try_from(n).ok())
1370}
1371
1372fn parse_file_style(value: &JsonValue) -> Option<i32> {
1373    if let Some(name) = value.as_str() {
1374        return anytype_rpc::model::block::content::file::Style::from_str_name(name)
1375            .map(|v| v as i32);
1376    }
1377    value.as_i64().and_then(|n| i32::try_from(n).ok())
1378}
1379
1380fn parse_link_style(value: &JsonValue) -> Option<i32> {
1381    if let Some(name) = value.as_str() {
1382        return anytype_rpc::model::block::content::link::Style::from_str_name(name)
1383            .map(|v| v as i32);
1384    }
1385    value.as_i64().and_then(|n| i32::try_from(n).ok())
1386}
1387
1388fn parse_link_icon_size(value: &JsonValue) -> Option<i32> {
1389    if let Some(name) = value.as_str() {
1390        return anytype_rpc::model::block::content::link::IconSize::from_str_name(name)
1391            .map(|v| v as i32);
1392    }
1393    value.as_i64().and_then(|n| i32::try_from(n).ok())
1394}
1395
1396fn parse_link_card_style(value: &JsonValue) -> Option<i32> {
1397    if let Some(name) = value.as_str() {
1398        return anytype_rpc::model::block::content::link::CardStyle::from_str_name(name)
1399            .map(|v| v as i32);
1400    }
1401    value.as_i64().and_then(|n| i32::try_from(n).ok())
1402}
1403
1404fn parse_link_description(value: &JsonValue) -> Option<i32> {
1405    if let Some(name) = value.as_str() {
1406        return anytype_rpc::model::block::content::link::Description::from_str_name(name)
1407            .map(|v| v as i32);
1408    }
1409    value.as_i64().and_then(|n| i32::try_from(n).ok())
1410}
1411
1412fn parse_div_style(value: &JsonValue) -> Option<i32> {
1413    if let Some(name) = value.as_str() {
1414        return DivStyle::from_str_name(name).map(|v| v as i32);
1415    }
1416    value.as_i64().and_then(|n| i32::try_from(n).ok())
1417}
1418
1419/// Convert a protobuf snapshot payload to markdown.
1420///
1421/// `object_index` should include object metadata (name/layout/file extension) for
1422/// linked objects so mentions/files can be rendered as markdown links.
1423pub fn convert_pb_snapshot_to_markdown(
1424    snapshot_bytes: &[u8],
1425    object_index: &HashMap<String, ArchiveObjectInfo, RandomState>,
1426) -> Result<String> {
1427    let snapshot =
1428        SnapshotWithType::decode(snapshot_bytes).context("failed to decode protobuf snapshot")?;
1429    let sb_type = SmartBlockType::try_from(snapshot.sb_type).unwrap_or(SmartBlockType::Page);
1430    if should_skip_export(sb_type) {
1431        return Ok(String::new());
1432    }
1433    let data = snapshot
1434        .snapshot
1435        .and_then(|v| v.data)
1436        .ok_or_else(|| anyhow!("snapshot payload missing data"))?;
1437    if data.blocks.is_empty() {
1438        return Ok(String::new());
1439    }
1440
1441    let mut blocks_by_id = HashMap::<String, &Block>::with_capacity(data.blocks.len());
1442    for block in &data.blocks {
1443        blocks_by_id.insert(block.id.clone(), block);
1444    }
1445
1446    let root_id = data
1447        .details
1448        .as_ref()
1449        .and_then(|details| struct_field_as_string(details, "id"))
1450        .unwrap_or_else(|| data.blocks[0].id.clone());
1451    let Some(root) = blocks_by_id.get(&root_id) else {
1452        bail!("root block not found: {root_id}");
1453    };
1454    if root.children_ids.is_empty() {
1455        return Ok(String::new());
1456    }
1457
1458    let converter = MarkdownConverter {
1459        blocks_by_id,
1460        docs: object_index,
1461    };
1462    let root = converter
1463        .blocks_by_id
1464        .get(&root_id)
1465        .ok_or_else(|| anyhow!("root block not found after converter init: {root_id}"))?;
1466    Ok(converter.render(root))
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472
1473    use anytype_rpc::anytype::change::Snapshot as ChangeSnapshot;
1474    use anytype_rpc::model::SmartBlockSnapshotBase;
1475    use anytype_rpc::model::block::content::text::Marks;
1476    use prost_types::Value as ProstValue;
1477    use serde_json::json;
1478    use std::path::PathBuf;
1479
1480    /// Synthetic object id for the in-code archive fixtures.
1481    ///
1482    /// The id is invented for tests; no archive outside this repository is required.
1483    const DOC_ID: &str = "bafyreifixturedoc000000000000000000000000000000000000000000";
1484    const DOC_NAME: &str = "Fixture Handbook";
1485    const RAW_FILE_NAME: &str = "fixture-manual.pdf";
1486    const RAW_PAYLOAD: &[u8] = b"fixture raw payload\n";
1487
1488    fn text_block(id: &str, body: &str, style: TextStyle) -> Block {
1489        Block {
1490            id: id.to_string(),
1491            content_value: Some(ContentValue::Text(Text {
1492                text: body.to_string(),
1493                style: style as i32,
1494                marks: Some(Marks::default()),
1495                ..Text::default()
1496            })),
1497            ..Block::default()
1498        }
1499    }
1500
1501    fn parent_block(id: &str, children: &[&str], content: Option<ContentValue>) -> Block {
1502        Block {
1503            id: id.to_string(),
1504            children_ids: children.iter().map(|v| (*v).to_string()).collect(),
1505            content_value: content,
1506            ..Block::default()
1507        }
1508    }
1509
1510    /// One small document exercising headings, a list/paragraph boundary and a 2x2 table.
1511    fn fixture_blocks() -> Vec<Block> {
1512        vec![
1513            parent_block(DOC_ID, &["title", "section", "item", "para", "table"], None),
1514            text_block("title", DOC_NAME, TextStyle::Header1),
1515            text_block("section", "Widget Basics", TextStyle::Header2),
1516            text_block("item", "Alpha", TextStyle::Marked),
1517            text_block("para", "Body paragraph", TextStyle::Paragraph),
1518            parent_block(
1519                "table",
1520                &["col-a", "col-b", "row-1", "row-2"],
1521                Some(ContentValue::Table(Table {})),
1522            ),
1523            parent_block(
1524                "col-a",
1525                &[],
1526                Some(ContentValue::TableColumn(TableColumn {})),
1527            ),
1528            parent_block(
1529                "col-b",
1530                &[],
1531                Some(ContentValue::TableColumn(TableColumn {})),
1532            ),
1533            parent_block(
1534                "row-1",
1535                &["row-1-col-a", "row-1-col-b"],
1536                Some(ContentValue::TableRow(TableRow { is_header: true })),
1537            ),
1538            parent_block(
1539                "row-2",
1540                &["row-2-col-a", "row-2-col-b"],
1541                Some(ContentValue::TableRow(TableRow::default())),
1542            ),
1543            text_block("row-1-col-a", "Widget", TextStyle::Paragraph),
1544            text_block("row-1-col-b", "Count", TextStyle::Paragraph),
1545            text_block("row-2-col-a", "Gear", TextStyle::Paragraph),
1546            text_block("row-2-col-b", "3", TextStyle::Paragraph),
1547        ]
1548    }
1549
1550    fn string_value(value: &str) -> ProstValue {
1551        ProstValue {
1552            kind: Some(Kind::StringValue(value.to_string())),
1553        }
1554    }
1555
1556    fn number_value(value: f64) -> ProstValue {
1557        ProstValue {
1558            kind: Some(Kind::NumberValue(value)),
1559        }
1560    }
1561
1562    /// Details for a plain page (layout 0), keyed by the fixture object id.
1563    fn fixture_details(root_id: &str) -> Struct {
1564        Struct {
1565            fields: [
1566                ("id".to_string(), string_value(root_id)),
1567                ("name".to_string(), string_value(DOC_NAME)),
1568                ("layout".to_string(), number_value(0.0)),
1569            ]
1570            .into_iter()
1571            .collect(),
1572        }
1573    }
1574
1575    fn pb_snapshot_bytes(blocks: Vec<Block>, details: Struct) -> Vec<u8> {
1576        let snapshot = SnapshotWithType {
1577            sb_type: SmartBlockType::Page as i32,
1578            snapshot: Some(ChangeSnapshot {
1579                data: Some(SmartBlockSnapshotBase {
1580                    blocks,
1581                    details: Some(details),
1582                    ..SmartBlockSnapshotBase::default()
1583                }),
1584                ..ChangeSnapshot::default()
1585            }),
1586        };
1587        snapshot.encode_to_vec()
1588    }
1589
1590    /// Render one fixture block in the archive `*.pb.json` shape so both snapshot
1591    /// formats are generated from a single in-code document definition.
1592    fn block_to_json(block: &Block) -> JsonValue {
1593        let mut obj = serde_json::Map::new();
1594        obj.insert("id".to_string(), json!(block.id));
1595        if !block.children_ids.is_empty() {
1596            obj.insert("childrenIds".to_string(), json!(block.children_ids));
1597        }
1598        match block.content_value.as_ref() {
1599            Some(ContentValue::Text(text)) => {
1600                let style = TextStyle::try_from(text.style).unwrap_or(TextStyle::Paragraph);
1601                obj.insert(
1602                    "text".to_string(),
1603                    json!({
1604                        "text": text.text,
1605                        "style": style.as_str_name(),
1606                        "checked": text.checked,
1607                    }),
1608                );
1609            }
1610            Some(ContentValue::Table(_)) => {
1611                obj.insert("table".to_string(), json!({}));
1612            }
1613            Some(ContentValue::TableColumn(_)) => {
1614                obj.insert("tableColumn".to_string(), json!({}));
1615            }
1616            Some(ContentValue::TableRow(row)) => {
1617                obj.insert("tableRow".to_string(), json!({ "isHeader": row.is_header }));
1618            }
1619            _ => {}
1620        }
1621        JsonValue::Object(obj)
1622    }
1623
1624    fn json_snapshot_bytes(blocks: &[Block], root_id: &str) -> Vec<u8> {
1625        let doc = json!({
1626            "sbType": SmartBlockType::Page.as_str_name(),
1627            "snapshot": {
1628                "data": {
1629                    "blocks": blocks.iter().map(block_to_json).collect::<Vec<_>>(),
1630                    "details": {
1631                        "id": root_id,
1632                        "name": DOC_NAME,
1633                        "layout": 0,
1634                    },
1635                }
1636            }
1637        });
1638        serde_json::to_vec(&doc).expect("fixture snapshot serializes")
1639    }
1640
1641    /// Write a minimal directory archive containing a single object snapshot.
1642    fn write_archive(base: &Path, snapshot_name: &str, snapshot_bytes: &[u8]) -> PathBuf {
1643        let root = base.join("archive");
1644        fs::create_dir_all(root.join("objects")).unwrap();
1645        fs::write(root.join("manifest.json"), b"{}").unwrap();
1646        fs::write(root.join("objects").join(snapshot_name), snapshot_bytes).unwrap();
1647        root
1648    }
1649
1650    fn pb_archive(base: &Path) -> PathBuf {
1651        let bytes = pb_snapshot_bytes(fixture_blocks(), fixture_details(DOC_ID));
1652        write_archive(base, &format!("{DOC_ID}.pb"), &bytes)
1653    }
1654
1655    fn json_archive(base: &Path) -> PathBuf {
1656        let bytes = json_snapshot_bytes(&fixture_blocks(), DOC_ID);
1657        write_archive(base, &format!("{DOC_ID}.pb.json"), &bytes)
1658    }
1659
1660    fn raw_json_archive(base: &Path, layout: i64, include_payload: bool) -> PathBuf {
1661        let bytes = serde_json::to_vec(&json!({
1662            "sbType": SmartBlockType::File.as_str_name(),
1663            "snapshot": {
1664                "data": {
1665                    "blocks": [],
1666                    "details": {
1667                        "id": DOC_ID,
1668                        "name": RAW_FILE_NAME,
1669                        "fileName": RAW_FILE_NAME,
1670                        "layout": layout,
1671                    },
1672                },
1673            },
1674        }))
1675        .expect("raw fixture snapshot serializes");
1676        let root = write_archive(base, &format!("{DOC_ID}.pb.json"), &bytes);
1677        if include_payload {
1678            fs::create_dir_all(root.join("files")).unwrap();
1679            fs::write(root.join("files").join(RAW_FILE_NAME), RAW_PAYLOAD).unwrap();
1680        }
1681        root
1682    }
1683
1684    #[test]
1685    fn convert_fixture_pb_object_to_markdown_contains_headings() {
1686        let dir = tempfile::tempdir().unwrap();
1687        let archive = pb_archive(dir.path());
1688        let markdown = convert_archive_object_pb_to_markdown(&archive, DOC_ID).unwrap();
1689        assert!(markdown.contains("# Fixture Handbook"), "{markdown}");
1690        assert!(markdown.contains("## Widget Basics"), "{markdown}");
1691        assert!(!markdown.is_empty());
1692    }
1693
1694    #[test]
1695    fn convert_fixture_pb_object_closes_lists_with_trailing_whitespace() {
1696        let dir = tempfile::tempdir().unwrap();
1697        let archive = pb_archive(dir.path());
1698        let markdown = convert_archive_object_pb_to_markdown(&archive, DOC_ID).unwrap();
1699        // A marked list item ends with the hard-break marker, and leaving the list
1700        // emits a standalone marker line before the following paragraph.
1701        assert!(
1702            markdown.contains("- Alpha   \n   \nBody paragraph   \n"),
1703            "{markdown}"
1704        );
1705    }
1706
1707    #[test]
1708    fn convert_fixture_pb_object_renders_markdown_tables() {
1709        let dir = tempfile::tempdir().unwrap();
1710        let archive = pb_archive(dir.path());
1711        let markdown = convert_archive_object_pb_to_markdown(&archive, DOC_ID).unwrap();
1712        assert!(markdown.contains("| Widget | Count |"), "{markdown}");
1713        assert!(markdown.contains(":-"), "{markdown}");
1714        assert!(markdown.contains("| Gear"), "{markdown}");
1715        assert!(markdown.contains("| 3"), "{markdown}");
1716    }
1717
1718    #[test]
1719    fn convert_fixture_pb_json_object_matches_pb_markdown() {
1720        let dir = tempfile::tempdir().unwrap();
1721        let pb = pb_archive(&dir.path().join("pb"));
1722        let pb_json = json_archive(&dir.path().join("json"));
1723
1724        let from_pb = convert_archive_object_to_markdown(&pb, DOC_ID).unwrap();
1725        let from_json = convert_archive_object_to_markdown(&pb_json, DOC_ID).unwrap();
1726
1727        assert!(from_json.contains("# Fixture Handbook"), "{from_json}");
1728        assert!(from_json.contains("## Widget Basics"), "{from_json}");
1729        assert_eq!(from_pb, from_json);
1730    }
1731
1732    #[test]
1733    fn save_fixture_pb_json_document_writes_markdown() {
1734        let dir = tempfile::tempdir().unwrap();
1735        let archive = json_archive(dir.path());
1736        let dest = dir.path().join("out.md");
1737        let kind = save_archive_object(&archive, DOC_ID, &dest).unwrap();
1738        assert_eq!(kind, SavedObjectKind::Markdown);
1739        let text = fs::read_to_string(&dest).unwrap();
1740        assert!(text.contains("# Fixture Handbook"), "{text}");
1741        assert!(text.contains("| Widget | Count |"), "{text}");
1742    }
1743
1744    #[test]
1745    fn save_file_layout_fixture_writes_raw_payload() {
1746        let dir = tempfile::tempdir().unwrap();
1747        for layout in 8..=12 {
1748            let base = dir.path().join(layout.to_string());
1749            let archive = raw_json_archive(&base, layout, true);
1750            let dest = base.join("out.pdf");
1751
1752            let kind = save_archive_object(&archive, DOC_ID, &dest).unwrap();
1753
1754            assert_eq!(kind, SavedObjectKind::Raw, "layout {layout}");
1755            assert_eq!(fs::read(&dest).unwrap(), RAW_PAYLOAD, "layout {layout}");
1756        }
1757    }
1758
1759    #[test]
1760    fn save_file_layout_fixture_classifies_missing_payload() {
1761        let dir = tempfile::tempdir().unwrap();
1762        let archive = raw_json_archive(dir.path(), 8, false);
1763        let dest = dir.path().join("out.pdf");
1764
1765        let err = save_archive_object(&archive, DOC_ID, &dest)
1766            .expect_err("a file-layout snapshot without a payload must be rejected");
1767        let classified = err
1768            .downcast_ref::<MissingRawPayloadError>()
1769            .expect("missing raw payload must retain its error classification");
1770
1771        assert_eq!(classified.object_id(), DOC_ID);
1772        assert!(!dest.exists());
1773    }
1774
1775    #[test]
1776    fn convert_archive_object_reports_unknown_object_id() {
1777        let dir = tempfile::tempdir().unwrap();
1778        let archive = pb_archive(dir.path());
1779        let err = convert_archive_object_pb_to_markdown(&archive, "bafyreimissingobject")
1780            .expect_err("unknown object id must be rejected");
1781        assert!(
1782            err.to_string().contains("snapshot not found in archive"),
1783            "{err}"
1784        );
1785    }
1786
1787    #[test]
1788    fn convert_archive_object_pb_rejects_json_snapshot() {
1789        let dir = tempfile::tempdir().unwrap();
1790        let archive = json_archive(dir.path());
1791        let err = convert_archive_object_pb_to_markdown(&archive, DOC_ID)
1792            .expect_err("pb-only conversion must reject a pb-json snapshot");
1793        assert!(err.to_string().contains("protobuf snapshots"), "{err}");
1794    }
1795
1796    #[test]
1797    fn convert_archive_object_reports_malformed_pb_snapshot() {
1798        let dir = tempfile::tempdir().unwrap();
1799        let archive = write_archive(
1800            dir.path(),
1801            &format!("{DOC_ID}.pb"),
1802            b"not a protobuf snapshot",
1803        );
1804        let err = convert_archive_object_to_markdown(&archive, DOC_ID)
1805            .expect_err("malformed protobuf must be rejected");
1806        assert!(
1807            err.to_string()
1808                .contains("failed to decode protobuf snapshot"),
1809            "{err}"
1810        );
1811    }
1812
1813    #[test]
1814    fn convert_archive_object_reports_malformed_pb_json_snapshot() {
1815        let dir = tempfile::tempdir().unwrap();
1816        let archive = write_archive(dir.path(), &format!("{DOC_ID}.pb.json"), b"{ not json");
1817        let err = convert_archive_object_to_markdown(&archive, DOC_ID)
1818            .expect_err("malformed pb-json must be rejected");
1819        assert!(err.to_string().contains("invalid pb-json"), "{err}");
1820    }
1821
1822    #[test]
1823    fn convert_snapshot_bytes_rejects_unsupported_extension() {
1824        let index = HashMap::new();
1825        let err = convert_snapshot_bytes_to_markdown("objects/note.txt", b"{}", &index)
1826            .expect_err("unsupported snapshot extension must be rejected");
1827        assert!(
1828            err.to_string().contains("unsupported snapshot format"),
1829            "{err}"
1830        );
1831    }
1832
1833    #[test]
1834    fn convert_pb_snapshot_reports_missing_root_block() {
1835        let bytes = pb_snapshot_bytes(fixture_blocks(), fixture_details("no-such-block"));
1836        let index = HashMap::new();
1837        let err = convert_pb_snapshot_to_markdown(&bytes, &index)
1838            .expect_err("details pointing at an absent root block must be rejected");
1839        assert!(err.to_string().contains("root block not found"), "{err}");
1840    }
1841
1842    #[test]
1843    fn convert_pb_snapshot_without_blocks_yields_empty_markdown() {
1844        let bytes = pb_snapshot_bytes(Vec::new(), fixture_details(DOC_ID));
1845        let index = HashMap::new();
1846        assert_eq!(
1847            convert_pb_snapshot_to_markdown(&bytes, &index).unwrap(),
1848            String::new()
1849        );
1850    }
1851
1852    #[test]
1853    fn save_archive_object_reports_missing_archive() {
1854        let dir = tempfile::tempdir().unwrap();
1855        let missing = dir.path().join("absent-archive");
1856        let dest = dir.path().join("out.md");
1857        let err = save_archive_object(&missing, DOC_ID, &dest)
1858            .expect_err("a missing archive path must be rejected");
1859        assert!(
1860            err.to_string()
1861                .contains("archive must be a directory or zip file"),
1862            "{err}"
1863        );
1864        assert!(!dest.exists());
1865    }
1866
1867    #[test]
1868    fn build_archive_object_index_skips_unreadable_snapshots() {
1869        let dir = tempfile::tempdir().unwrap();
1870        let archive = pb_archive(dir.path());
1871        fs::write(archive.join("objects").join("broken.pb"), b"\xff\xff\xff").unwrap();
1872
1873        let reader = ArchiveReader::from_path(&archive).unwrap();
1874        let index = build_archive_object_index(&reader).unwrap();
1875        assert_eq!(index.len(), 1);
1876        assert_eq!(
1877            index.get(DOC_ID).map(|info| info.name.as_str()),
1878            Some(DOC_NAME)
1879        );
1880    }
1881}