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