affine_doc_loader 0.1.2

AFFiNE document parsing and rendering utilities.
Documentation
mod block_spec;
mod blocksuite;
mod doc_loader;
mod error;
mod markdown;
mod read;
#[cfg(test)]
mod roundtrip_tests;
mod schema;
mod table;
mod value;
mod write;

use block_spec::{BlockFlavour, BlockNode};
pub use error::ParseError;
pub use read::{
  BlockInfo, CrawlResult, MarkdownResult, PageDocContent, WorkspaceDocContent, get_doc_ids_from_binary,
  parse_doc_from_binary, parse_doc_to_markdown, parse_page_doc, parse_workspace_doc,
};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
pub use write::{
  add_doc_to_root_doc, build_full_doc, build_public_root_doc, update_doc, update_doc_properties, update_doc_title,
  update_root_doc_meta_title,
};
use y_octo::{TextAttributes, TextDeltaOp, TextInsert};

pub fn build_doc_snapshot(title: &str, markdown: &str, doc_id: &str) -> Result<serde_json::Value, ParseError> {
  let blocks = markdown::parse_markdown_blocks(markdown)?;
  Ok(json!({
    "type": "page",
    "meta": {
      "id": doc_id,
      "title": title,
      "createDate": 0,
      "tags": [],
    },
    "blocks": {
      "type": "block",
      "id": format!("block:{doc_id}"),
      "flavour": "affine:page",
      "props": {
        "title": {
          "$blocksuite:internal:text$": true,
          "delta": [{ "insert": title }]
        }
      },
      "children": [{
        "type": "block",
        "id": format!("block:{doc_id}:note"),
        "flavour": "affine:note",
        "props": {
          "xywh": "[0,0,800,95]",
          "background": {
            "light": "#ffffff",
            "dark": "#252525"
          },
          "index": "a0",
          "hidden": false,
          "displayMode": "both"
        },
        "children": snapshot_blocks(&blocks, doc_id)
      }]
    }
  }))
}

fn snapshot_blocks(blocks: &[BlockNode], doc_id: &str) -> Vec<JsonValue> {
  if blocks.is_empty() {
    return vec![json!({
      "type": "block",
      "id": format!("block:{doc_id}:empty"),
      "flavour": "affine:paragraph",
      "props": {
        "type": "text",
        "text": text_value(&[])
      },
      "children": []
    })];
  }

  blocks
    .iter()
    .enumerate()
    .map(|(index, block)| snapshot_block(block, &format!("block:{doc_id}:{index}")))
    .collect()
}

fn snapshot_block(block: &BlockNode, id: &str) -> JsonValue {
  json!({
    "type": "block",
    "id": id,
    "flavour": block.spec.flavour.as_str(),
    "props": block_props(block, id),
    "children": block.children.iter().enumerate().map(|(index, child)| {
      snapshot_block(child, &format!("{id}:{index}"))
    }).collect::<Vec<_>>()
  })
}

fn block_props(block: &BlockNode, id: &str) -> JsonValue {
  match block.spec.flavour {
    BlockFlavour::Image => block
      .spec
      .image
      .as_ref()
      .map(|image| {
        let mut props = JsonMap::new();
        props.insert("sourceId".into(), JsonValue::String(image.source_id.clone()));
        if let Some(caption) = image.caption.as_ref() {
          props.insert("caption".into(), JsonValue::String(caption.clone()));
        }
        if let Some(width) = image.width {
          props.insert("width".into(), json!(width));
        }
        if let Some(height) = image.height {
          props.insert("height".into(), json!(height));
        }
        JsonValue::Object(props)
      })
      .unwrap_or_else(|| json!({})),
    BlockFlavour::Table => block
      .spec
      .table
      .as_ref()
      .map(|table| table_props(&table.rows, id))
      .unwrap_or_else(|| json!({})),
    BlockFlavour::Bookmark => block
      .spec
      .bookmark
      .as_ref()
      .map(|bookmark| {
        let mut props = JsonMap::new();
        props.insert("url".into(), JsonValue::String(bookmark.url.clone()));
        if let Some(caption) = bookmark.caption.as_ref() {
          props.insert("caption".into(), JsonValue::String(caption.clone()));
        }
        JsonValue::Object(props)
      })
      .unwrap_or_else(|| json!({})),
    BlockFlavour::EmbedYoutube => block
      .spec
      .embed_youtube
      .as_ref()
      .map(|embed| json!({ "videoId": embed.video_id }))
      .unwrap_or_else(|| json!({})),
    BlockFlavour::EmbedIframe => block
      .spec
      .embed_iframe
      .as_ref()
      .map(|embed| json!({ "url": embed.url }))
      .unwrap_or_else(|| json!({})),
    BlockFlavour::Callout => json!({ "emoji": block.spec.callout_emoji.as_deref().unwrap_or("💡") }),
    _ => {
      let mut props = JsonMap::new();
      if let Some(block_type) = block.spec.block_type_str() {
        props.insert("type".into(), JsonValue::String(block_type.to_string()));
      }
      if !block.spec.text.is_empty() || block.spec.flavour != BlockFlavour::Divider {
        props.insert("text".into(), text_value(&block.spec.text));
      }
      if let Some(checked) = block.spec.checked {
        props.insert("checked".into(), JsonValue::Bool(checked));
      }
      if let Some(language) = block.spec.language.as_ref() {
        props.insert("language".into(), JsonValue::String(language.clone()));
      }
      if let Some(order) = block.spec.order {
        props.insert("order".into(), json!(order));
      }
      JsonValue::Object(props)
    }
  }
}

fn table_props(rows: &[Vec<String>], id: &str) -> JsonValue {
  let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
  let mut columns = JsonMap::new();
  let mut row_values = JsonMap::new();
  let mut cells = JsonMap::new();
  let column_ids = (0..column_count)
    .map(|index| {
      let column_id = format!("{id}:column:{index}");
      columns.insert(
        column_id.clone(),
        json!({
          "columnId": column_id,
          "order": format!("{index:06}")
        }),
      );
      column_id
    })
    .collect::<Vec<_>>();

  for (row_index, row) in rows.iter().enumerate() {
    let row_id = format!("{id}:row:{row_index}");
    row_values.insert(
      row_id.clone(),
      json!({
        "rowId": row_id,
        "order": format!("{row_index:06}")
      }),
    );
    for (column_index, column_id) in column_ids.iter().enumerate() {
      let cell_id = format!("{row_id}:{column_id}");
      cells.insert(
        cell_id,
        json!({
          "text": text_value(&plain_text_delta(row.get(column_index).map(String::as_str).unwrap_or("")))
        }),
      );
    }
  }

  json!({
    "columns": columns,
    "rows": row_values,
    "cells": cells
  })
}

fn plain_text_delta(text: &str) -> Vec<TextDeltaOp> {
  if text.is_empty() {
    Vec::new()
  } else {
    vec![TextDeltaOp::Insert {
      insert: TextInsert::Text(text.to_string()),
      format: None,
    }]
  }
}

fn text_value(delta: &[TextDeltaOp]) -> JsonValue {
  json!({
    "$blocksuite:internal:text$": true,
    "delta": delta.iter().filter_map(delta_op).collect::<Vec<_>>()
  })
}

fn delta_op(op: &TextDeltaOp) -> Option<JsonValue> {
  let TextDeltaOp::Insert { insert, format } = op else {
    return None;
  };
  let TextInsert::Text(text) = insert else {
    return None;
  };
  let mut value = JsonMap::new();
  value.insert("insert".into(), JsonValue::String(text.clone()));
  if let Some(format) = format.as_ref().and_then(attributes_value) {
    value.insert("attributes".into(), format);
  }
  Some(JsonValue::Object(value))
}

fn attributes_value(attributes: &TextAttributes) -> Option<JsonValue> {
  if attributes.is_empty() {
    return None;
  }
  let mut values = JsonMap::new();
  for (key, value) in attributes {
    if let Ok(value) = serde_json::to_value(value) {
      values.insert(key.clone(), value);
    }
  }
  Some(JsonValue::Object(values))
}