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#[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#[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 #[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!(" \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
665pub 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
718pub 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
731pub 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
889pub 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
911pub 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
1419pub 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 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 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 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 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 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 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}