feat: impl text delta support (#14132)

This commit is contained in:
DarkSky
2025-12-22 03:16:16 +08:00
committed by GitHub
parent efbdee5508
commit 321965a424
31 changed files with 2299 additions and 415 deletions
@@ -0,0 +1,886 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as JsonValue};
use thiserror::Error;
use y_octo::{Any, DocOptions, JwstCodecError, Map, Value};
use super::{
blocksuite::{
collect_child_ids, get_block_id, get_flavour, get_list_depth, get_string, nearest_by_flavour,
DocContext,
},
delta_markdown::{
delta_value_to_inline_markdown, extract_inline_references, text_to_inline_markdown,
text_to_markdown, DeltaToMdOptions,
},
value::{
any_as_string, any_truthy, build_reference_payload, params_value_to_json, value_to_string,
},
};
const SUMMARY_LIMIT: usize = 1000;
const PAGE_FLAVOUR: &str = "affine:page";
const NOTE_FLAVOUR: &str = "affine:note";
const BOOKMARK_FLAVOURS: [&str; 5] = [
"affine:bookmark",
"affine:embed-youtube",
"affine:embed-figma",
"affine:embed-github",
"affine:embed-loom",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockInfo {
pub block_id: String,
pub flavour: String,
pub content: Option<Vec<String>>,
pub blob: Option<Vec<String>>,
pub ref_doc_id: Option<Vec<String>>,
pub ref_info: Option<Vec<String>>,
pub parent_flavour: Option<String>,
pub parent_block_id: Option<String>,
pub additional: Option<String>,
}
impl BlockInfo {
fn base(
block_id: &str,
flavour: &str,
parent_flavour: Option<&String>,
parent_block_id: Option<&String>,
additional: Option<String>,
) -> Self {
Self {
block_id: block_id.to_string(),
flavour: flavour.to_string(),
content: None,
blob: None,
ref_doc_id: None,
ref_info: None,
parent_flavour: parent_flavour.cloned(),
parent_block_id: parent_block_id.cloned(),
additional,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlResult {
pub blocks: Vec<BlockInfo>,
pub title: String,
pub summary: String,
}
#[derive(Error, Debug, Serialize, Deserialize)]
pub enum ParseError {
#[error("doc_not_found")]
DocNotFound,
#[error("invalid_binary")]
InvalidBinary,
#[error("sqlite_error: {0}")]
SqliteError(String),
#[error("parser_error: {0}")]
ParserError(String),
#[error("unknown: {0}")]
Unknown(String),
}
impl From<JwstCodecError> for ParseError {
fn from(value: JwstCodecError) -> Self {
Self::ParserError(value.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarkdownResult {
pub title: String,
pub markdown: String,
}
pub fn parse_doc_to_markdown(
doc_bin: Vec<u8>,
doc_id: String,
ai_editable: bool,
doc_url_prefix: Option<String>,
) -> Result<MarkdownResult, ParseError> {
if doc_bin.is_empty() || doc_bin == [0, 0] {
return Err(ParseError::InvalidBinary);
}
let mut doc = DocOptions::new().with_guid(doc_id.clone()).build();
doc
.apply_update_from_binary_v1(&doc_bin)
.map_err(|_| ParseError::InvalidBinary)?;
let blocks_map = doc.get_map("blocks")?;
if blocks_map.is_empty() {
return Ok(MarkdownResult {
title: "".into(),
markdown: "".into(),
});
}
let context = DocContext::from_blocks_map(&blocks_map, PAGE_FLAVOUR)
.ok_or_else(|| ParseError::ParserError("root block not found".into()))?;
let root_block_id = context.root_block_id.clone();
let mut walker = context.walker();
let mut doc_title = String::from("Untitled");
let mut markdown = String::new();
let md_options = DeltaToMdOptions::new(doc_url_prefix);
while let Some((parent_block_id, block_id)) = walker.next() {
let block = match context.block_pool.get(&block_id) {
Some(block) => block,
None => continue,
};
let flavour = match get_flavour(block) {
Some(flavour) => flavour,
None => continue,
};
let parent_id = context.parent_lookup.get(&block_id);
let parent_flavour = parent_id
.and_then(|id| context.block_pool.get(id))
.and_then(get_flavour);
if parent_flavour.as_deref() == Some("affine:database") {
continue;
}
// enqueue children first to keep traversal order similar to JS implementation
walker.enqueue_children(&block_id, block);
if flavour == PAGE_FLAVOUR {
let title = get_string(block, "prop:title").unwrap_or_default();
doc_title = title.clone();
continue;
}
if flavour == "affine:database" {
let title = get_string(block, "prop:title").unwrap_or_default();
markdown.push_str(&format!("\n### {title}\n"));
let columns = parse_database_columns(block);
let cells_map = block.get("prop:cells").and_then(|v| v.to_map());
if let (Some(columns), Some(cells_map)) = (columns, cells_map) {
let escape_table = |s: &str| s.replace('|', "\\|").replace('\n', "<br>");
let mut table = String::new();
table.push('|');
for column in &columns {
table.push_str(&escape_table(column.name.as_deref().unwrap_or_default()));
table.push('|');
}
table.push('\n');
table.push('|');
for _ in &columns {
table.push_str("---|");
}
table.push('\n');
let child_ids = collect_child_ids(block);
for child_id in child_ids {
table.push('|');
let row_cells = cells_map.get(&child_id).and_then(|v| v.to_map());
for column in &columns {
let mut cell_text = String::new();
if column.col_type == "title" {
if let Some(child_block) = context.block_pool.get(&child_id) {
if let Some(text_md) =
text_to_inline_markdown(child_block, "prop:text", &md_options)
{
cell_text = text_md;
} else if let Some((text, _)) = text_content(child_block, "prop:text") {
cell_text = text;
}
}
} else if let Some(row_cells) = &row_cells {
if let Some(cell_val) = row_cells.get(&column.id).and_then(|v| v.to_map()) {
if let Some(value) = cell_val.get("value") {
if let Some(text_md) = delta_value_to_inline_markdown(&value, &md_options) {
cell_text = text_md;
} else {
cell_text = format_cell_value(&value, column);
}
}
}
}
table.push_str(&escape_table(&cell_text));
table.push('|');
}
table.push('\n');
}
append_table_block(&mut markdown, &table);
}
continue;
}
if flavour == "affine:table" {
let contents = gather_table_contents(block);
let table = contents.join("|");
append_table_block(&mut markdown, &table);
continue;
}
if ai_editable && parent_block_id.as_ref() == Some(&root_block_id) {
markdown.push_str(&format!("<!-- block_id={block_id} flavour={flavour} -->\n"));
}
if flavour == "affine:paragraph" {
let type_ = get_string(block, "prop:type").unwrap_or_default();
let prefix = paragraph_prefix(type_.as_str());
if let Some(text_md) = text_to_markdown(block, "prop:text", &md_options) {
append_paragraph(&mut markdown, prefix, &text_md);
} else if let Some((text, _)) = text_content(block, "prop:text") {
append_paragraph(&mut markdown, prefix, &text);
}
continue;
}
if flavour == "affine:list" {
let type_ = get_string(block, "prop:type").unwrap_or_default();
let checked = block
.get("prop:checked")
.and_then(|value| value.to_any())
.as_ref()
.map(any_truthy)
.unwrap_or(false);
let prefix = list_prefix(type_.as_str(), checked);
let depth = get_list_depth(&block_id, &context.parent_lookup, &context.block_pool);
let indent = list_indent(depth);
if let Some(text_md) = text_to_markdown(block, "prop:text", &md_options) {
append_list_item(&mut markdown, &indent, prefix, &text_md);
} else if let Some((text, _)) = text_content(block, "prop:text") {
append_list_item(&mut markdown, &indent, prefix, &text);
}
continue;
}
if flavour == "affine:code" {
if let Some((text, _)) = text_content(block, "prop:text") {
let lang = get_string(block, "prop:language").unwrap_or_default();
append_code_block(&mut markdown, &lang, &text);
}
continue;
}
}
Ok(MarkdownResult {
title: doc_title,
markdown,
})
}
pub fn parse_doc_from_binary(doc_bin: Vec<u8>, doc_id: String) -> Result<CrawlResult, ParseError> {
if doc_bin.is_empty() || doc_bin == [0, 0] {
return Err(ParseError::InvalidBinary);
}
let mut doc = DocOptions::new().with_guid(doc_id.clone()).build();
doc
.apply_update_from_binary_v1(&doc_bin)
.map_err(|_| ParseError::InvalidBinary)?;
let blocks_map = doc.get_map("blocks")?;
if blocks_map.is_empty() {
return Err(ParseError::ParserError("blocks map is empty".into()));
}
let context = DocContext::from_blocks_map(&blocks_map, PAGE_FLAVOUR)
.ok_or_else(|| ParseError::ParserError("root block not found".into()))?;
let mut walker = context.walker();
let mut blocks: Vec<BlockInfo> = Vec::with_capacity(context.block_pool.len());
let mut doc_title = String::new();
let mut summary = String::new();
let mut summary_remaining = SUMMARY_LIMIT as isize;
while let Some((parent_block_id, block_id)) = walker.next() {
let block = match context.block_pool.get(&block_id) {
Some(block) => block,
None => continue,
};
let flavour = match get_flavour(block) {
Some(flavour) => flavour,
None => continue,
};
let parent_block = parent_block_id
.as_ref()
.and_then(|id| context.block_pool.get(id));
let parent_flavour = parent_block.and_then(get_flavour);
let note_block = nearest_by_flavour(
&block_id,
NOTE_FLAVOUR,
&context.parent_lookup,
&context.block_pool,
);
let note_block_id = note_block.as_ref().and_then(get_block_id);
let display_mode = determine_display_mode(note_block.as_ref());
// enqueue children first to keep traversal order similar to JS implementation
walker.enqueue_children(&block_id, block);
let build_block = |database_name: Option<&String>| {
BlockInfo::base(
&block_id,
&flavour,
parent_flavour.as_ref(),
parent_block_id.as_ref(),
compose_additional(&display_mode, note_block_id.as_ref(), database_name),
)
};
if flavour == PAGE_FLAVOUR {
let title = get_string(block, "prop:title").unwrap_or_default();
doc_title = title.clone();
let mut info = build_block(None);
info.content = Some(vec![title]);
blocks.push(info);
continue;
}
if matches!(
flavour.as_str(),
"affine:paragraph" | "affine:list" | "affine:code"
) {
if let Some(text) = block.get("prop:text").and_then(|value| value.to_text()) {
let database_name = if flavour == "affine:paragraph"
&& parent_flavour.as_deref() == Some("affine:database")
{
parent_block.and_then(|map| get_string(map, "prop:title"))
} else {
None
};
let content = text.to_string();
let text_len = text.len() as usize;
let refs = extract_inline_references(&text.to_delta());
let mut info = build_block(database_name.as_ref());
info.content = Some(vec![content.clone()]);
if !refs.is_empty() {
info.ref_doc_id = Some(refs.iter().map(|r| r.doc_id.clone()).collect());
info.ref_info = Some(refs.into_iter().map(|r| r.payload).collect());
}
blocks.push(info);
append_summary(&mut summary, &mut summary_remaining, text_len, &content);
}
continue;
}
if matches!(
flavour.as_str(),
"affine:embed-linked-doc" | "affine:embed-synced-doc"
) {
if let Some(page_id) = get_string(block, "prop:pageId") {
let mut info = build_block(None);
let payload = embed_ref_payload(block, &page_id);
apply_doc_ref(&mut info, page_id, payload);
blocks.push(info);
}
continue;
}
if flavour == "affine:attachment" {
if let Some(blob_id) = get_string(block, "prop:sourceId") {
let mut info = build_block(None);
let name = get_string(block, "prop:name").unwrap_or_default();
apply_blob_info(&mut info, blob_id, name);
blocks.push(info);
}
continue;
}
if flavour == "affine:image" {
if let Some(blob_id) = get_string(block, "prop:sourceId") {
let mut info = build_block(None);
let caption = get_string(block, "prop:caption").unwrap_or_default();
apply_blob_info(&mut info, blob_id, caption);
blocks.push(info);
}
continue;
}
if flavour == "affine:surface" {
let texts = gather_surface_texts(block);
let mut info = build_block(None);
info.content = Some(texts);
blocks.push(info);
continue;
}
if flavour == "affine:database" {
let (texts, database_name) = gather_database_texts(block);
let mut info = BlockInfo::base(
&block_id,
&flavour,
parent_flavour.as_ref(),
parent_block_id.as_ref(),
compose_additional(
&display_mode,
note_block_id.as_ref(),
database_name.as_ref(),
),
);
info.content = Some(texts);
blocks.push(info);
continue;
}
if flavour == "affine:latex" {
if let Some(content) = get_string(block, "prop:latex") {
let mut info = build_block(None);
info.content = Some(vec![content]);
blocks.push(info);
}
continue;
}
if flavour == "affine:table" {
let contents = gather_table_contents(block);
let mut info = build_block(None);
info.content = Some(contents);
blocks.push(info);
continue;
}
if BOOKMARK_FLAVOURS.contains(&flavour.as_str()) {
blocks.push(build_block(None));
}
}
if doc_title.is_empty() {
doc_title = "Untitled".into();
}
Ok(CrawlResult {
blocks,
title: doc_title,
summary,
})
}
pub fn get_doc_ids_from_binary(
doc_bin: Vec<u8>,
include_trash: bool,
) -> Result<Vec<String>, ParseError> {
if doc_bin.is_empty() || doc_bin == [0, 0] {
return Err(ParseError::InvalidBinary);
}
let mut doc = DocOptions::new().build();
doc
.apply_update_from_binary_v1(&doc_bin)
.map_err(|_| ParseError::InvalidBinary)?;
let meta = doc.get_map("meta")?;
let pages = match meta.get("pages").and_then(|v| v.to_array()) {
Some(arr) => arr,
None => return Ok(vec![]),
};
let mut doc_ids = Vec::new();
for page_val in pages.iter() {
if let Some(page) = page_val.to_map() {
let id = get_string(&page, "id");
if let Some(id) = id {
let trash = page
.get("trash")
.and_then(|v| match v.to_any() {
Some(Any::True) => Some(true),
Some(Any::False) => Some(false),
_ => None,
})
.unwrap_or(false);
if include_trash || !trash {
doc_ids.push(id);
}
}
}
}
Ok(doc_ids)
}
fn paragraph_prefix(type_: &str) -> &'static str {
match type_ {
"h1" => "# ",
"h2" => "## ",
"h3" => "### ",
"h4" => "#### ",
"h5" => "##### ",
"h6" => "###### ",
"quote" => "> ",
_ => "",
}
}
fn list_prefix(type_: &str, checked: bool) -> &'static str {
match type_ {
"bulleted" => "* ",
"todo" => {
if checked {
"- [x] "
} else {
"- [ ] "
}
}
_ => "1. ",
}
}
fn list_indent(depth: usize) -> String {
" ".repeat(depth)
}
fn append_paragraph(markdown: &mut String, prefix: &str, text: &str) {
markdown.push_str(prefix);
markdown.push_str(text);
if !text.ends_with('\n') {
markdown.push('\n');
}
markdown.push('\n');
}
fn append_list_item(markdown: &mut String, indent: &str, prefix: &str, text: &str) {
markdown.push_str(indent);
markdown.push_str(prefix);
markdown.push_str(text);
if !text.ends_with('\n') {
markdown.push('\n');
}
}
fn append_code_block(markdown: &mut String, lang: &str, text: &str) {
markdown.push_str("```");
markdown.push_str(lang);
markdown.push('\n');
markdown.push_str(text);
markdown.push_str("\n```\n\n");
}
fn append_table_block(markdown: &mut String, table: &str) {
if table.is_empty() {
markdown.push('\n');
return;
}
markdown.push_str(table);
if !table.ends_with('\n') {
markdown.push('\n');
}
markdown.push('\n');
}
fn text_content(block: &Map, key: &str) -> Option<(String, usize)> {
block.get(key).and_then(|value| {
value.to_text().map(|text| {
let content = text.to_string();
let len = text.len() as usize;
(content, len)
})
})
}
fn determine_display_mode(note_block: Option<&Map>) -> String {
match note_block.and_then(|block| get_string(block, "prop:displayMode")) {
Some(mode) if mode == "both" => "page".into(),
Some(mode) => mode,
None => "edgeless".into(),
}
}
fn compose_additional(
display_mode: &str,
note_block_id: Option<&String>,
database_name: Option<&String>,
) -> Option<String> {
let mut payload = JsonMap::new();
payload.insert(
"displayMode".into(),
JsonValue::String(display_mode.to_string()),
);
if let Some(note_id) = note_block_id {
payload.insert("noteBlockId".into(), JsonValue::String(note_id.clone()));
}
if let Some(name) = database_name {
payload.insert("databaseName".into(), JsonValue::String(name.clone()));
}
Some(JsonValue::Object(payload).to_string())
}
fn apply_blob_info(info: &mut BlockInfo, blob_id: String, content: String) {
info.blob = Some(vec![blob_id]);
info.content = Some(vec![content]);
}
fn apply_doc_ref(info: &mut BlockInfo, page_id: String, payload: Option<String>) {
info.ref_doc_id = Some(vec![page_id]);
if let Some(payload) = payload {
info.ref_info = Some(vec![payload]);
}
}
fn embed_ref_payload(block: &Map, page_id: &str) -> Option<String> {
let params = block
.get("prop:params")
.as_ref()
.and_then(params_value_to_json);
Some(build_reference_payload(page_id, params))
}
fn gather_surface_texts(block: &Map) -> Vec<String> {
let mut texts = Vec::new();
let elements = match block.get("prop:elements").and_then(|value| value.to_map()) {
Some(map) => map,
None => return texts,
};
if elements
.get("type")
.and_then(|value| value_to_string(&value))
.as_deref()
!= Some("$blocksuite:internal:native$")
{
return texts;
}
if let Some(value_map) = elements.get("value").and_then(|value| value.to_map()) {
for value in value_map.values() {
if let Some(element) = value.to_map() {
if let Some(text) = element.get("text").and_then(|value| value.to_text()) {
texts.push(text.to_string());
}
}
}
}
texts.sort();
texts
}
fn gather_database_texts(block: &Map) -> (Vec<String>, Option<String>) {
let mut texts = Vec::new();
let database_title = get_string(block, "prop:title");
if let Some(title) = &database_title {
texts.push(title.clone());
}
if let Some(columns) = parse_database_columns(block) {
for column in columns.iter() {
if let Some(name) = column.name.as_ref() {
texts.push(name.clone());
}
for option in column.options.iter() {
if let Some(value) = option.value.as_ref() {
texts.push(value.clone());
}
}
}
}
(texts, database_title)
}
fn gather_table_contents(block: &Map) -> Vec<String> {
let mut contents = Vec::new();
for key in block.keys() {
if key.starts_with("prop:cells.") && key.ends_with(".text") {
if let Some(value) = block.get(key).and_then(|value| value_to_string(&value)) {
if !value.is_empty() {
contents.push(value);
}
}
}
}
contents
}
struct DatabaseOption {
id: Option<String>,
value: Option<String>,
color: Option<String>,
}
struct DatabaseColumn {
id: String,
name: Option<String>,
col_type: String,
options: Vec<DatabaseOption>,
}
fn parse_database_columns(block: &Map) -> Option<Vec<DatabaseColumn>> {
let columns = block
.get("prop:columns")
.and_then(|value| value.to_array())?;
let mut parsed = Vec::new();
for column_value in columns.iter() {
if let Some(column) = column_value.to_map() {
let id = get_string(&column, "id").unwrap_or_default();
let name = get_string(&column, "name");
let col_type = get_string(&column, "type").unwrap_or_default();
let options = parse_database_options(&column);
parsed.push(DatabaseColumn {
id,
name,
col_type,
options,
});
}
}
Some(parsed)
}
fn parse_database_options(column: &Map) -> Vec<DatabaseOption> {
let Some(data) = column.get("data").and_then(|value| value.to_map()) else {
return Vec::new();
};
let Some(options) = data.get("options").and_then(|value| value.to_array()) else {
return Vec::new();
};
let mut parsed = Vec::new();
for option_value in options.iter() {
if let Some(option) = option_value.to_map() {
parsed.push(DatabaseOption {
id: get_string(&option, "id"),
value: get_string(&option, "value"),
color: get_string(&option, "color"),
});
}
}
parsed
}
fn format_option_tag(option: &DatabaseOption) -> String {
let id = option.id.as_deref().unwrap_or_default();
let value = option.value.as_deref().unwrap_or_default();
let color = option.color.as_deref().unwrap_or_default();
format!(
"<span data-affine-option data-value=\"{id}\" data-option-color=\"{color}\">{value}</span>"
)
}
fn format_cell_value(value: &Value, column: &DatabaseColumn) -> String {
match column.col_type.as_str() {
"select" => {
let id = match value {
Value::Any(any) => any_as_string(any).map(str::to_string),
Value::Text(text) => Some(text.to_string()),
_ => None,
};
if let Some(id) = id {
for option in column.options.iter() {
if option.id.as_deref() == Some(id.as_str()) {
return format_option_tag(option);
}
}
}
String::new()
}
"multi-select" => {
let ids: Vec<String> = match value {
Value::Any(Any::Array(ids)) => ids
.iter()
.filter_map(any_as_string)
.map(str::to_string)
.collect(),
Value::Array(array) => array
.iter()
.filter_map(|id_val| value_to_string(&id_val))
.collect(),
_ => Vec::new(),
};
if ids.is_empty() {
return String::new();
}
let mut selected = Vec::new();
for id in ids.iter() {
for option in column.options.iter() {
if option.id.as_deref() == Some(id.as_str()) {
selected.push(format_option_tag(option));
}
}
}
selected.join("")
}
_ => value_to_string(value).unwrap_or_default(),
}
}
fn append_summary(summary: &mut String, remaining: &mut isize, text_len: usize, text: &str) {
if *remaining > 0 {
summary.push_str(text);
*remaining -= text_len as isize;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_doc_from_binary() {
let json = include_bytes!("../../fixtures/demo.ydoc.json");
let doc_bin = include_bytes!("../../fixtures/demo.ydoc").to_vec();
let doc_id = "dYpV7PPhk8amRkY5IAcVO".to_string();
let result = parse_doc_from_binary(doc_bin, doc_id).unwrap();
let config = assert_json_diff::Config::new(assert_json_diff::CompareMode::Strict)
.numeric_mode(assert_json_diff::NumericMode::AssumeFloat);
assert_json_diff::assert_json_matches!(
serde_json::from_slice::<serde_json::Value>(json).unwrap(),
serde_json::json!(result),
config
);
}
#[test]
fn test_paragraph_newlines() {
let mut markdown = String::new();
append_paragraph(&mut markdown, "# ", "Title\n");
assert_eq!(markdown, "# Title\n\n");
markdown.clear();
append_paragraph(&mut markdown, "", "Plain");
assert_eq!(markdown, "Plain\n\n");
}
#[test]
fn test_list_newlines() {
let mut markdown = String::new();
append_list_item(&mut markdown, " ", "* ", "Item\n");
assert_eq!(markdown, " * Item\n");
markdown.clear();
append_list_item(&mut markdown, "", "- [ ] ", "Task");
assert_eq!(markdown, "- [ ] Task\n");
}
#[test]
fn test_code_block_newlines() {
let mut markdown = String::new();
append_code_block(&mut markdown, "rs", "fn main() {}");
assert_eq!(markdown, "```rs\nfn main() {}\n```\n\n");
}
#[test]
fn test_table_newlines() {
let mut markdown = String::new();
append_table_block(&mut markdown, "|a|b|\n|---|---|\n|1|2|\n");
assert_eq!(markdown, "|a|b|\n|---|---|\n|1|2|\n\n");
markdown.clear();
append_table_block(&mut markdown, "|a|b|");
assert_eq!(markdown, "|a|b|\n\n");
}
}
@@ -0,0 +1,164 @@
use std::collections::{HashMap, HashSet};
use y_octo::Map;
use super::value::value_to_string;
pub(super) struct BlockIndex {
pub(super) block_pool: HashMap<String, Map>,
pub(super) parent_lookup: HashMap<String, String>,
}
pub(super) struct DocContext {
pub(super) block_pool: HashMap<String, Map>,
pub(super) parent_lookup: HashMap<String, String>,
pub(super) root_block_id: String,
}
pub(super) struct BlockWalker {
queue: Vec<(Option<String>, String)>,
visited: HashSet<String>,
}
pub(super) fn build_block_index(blocks_map: &Map) -> BlockIndex {
let mut block_pool: HashMap<String, Map> = HashMap::new();
let mut parent_lookup: HashMap<String, String> = HashMap::new();
for (_, value) in blocks_map.iter() {
if let Some(block_map) = value.to_map() {
if let Some(block_id) = get_block_id(&block_map) {
for child_id in collect_child_ids(&block_map) {
parent_lookup.insert(child_id, block_id.clone());
}
block_pool.insert(block_id, block_map);
}
}
}
BlockIndex {
block_pool,
parent_lookup,
}
}
impl DocContext {
pub(super) fn from_blocks_map(blocks_map: &Map, root_flavour: &str) -> Option<Self> {
let BlockIndex {
block_pool,
parent_lookup,
} = build_block_index(blocks_map);
let root_block_id = find_block_id_by_flavour(&block_pool, root_flavour)?;
Some(Self {
block_pool,
parent_lookup,
root_block_id,
})
}
pub(super) fn walker(&self) -> BlockWalker {
BlockWalker::new(&self.root_block_id)
}
}
impl BlockWalker {
fn new(root_block_id: &str) -> Self {
let mut visited = HashSet::new();
visited.insert(root_block_id.to_string());
Self {
queue: vec![(None, root_block_id.to_string())],
visited,
}
}
pub(super) fn next(&mut self) -> Option<(Option<String>, String)> {
self.queue.pop()
}
pub(super) fn enqueue_children(&mut self, parent_block_id: &str, block: &Map) {
let mut child_ids = collect_child_ids(block);
for child_id in child_ids.drain(..).rev() {
if self.visited.insert(child_id.clone()) {
self
.queue
.push((Some(parent_block_id.to_string()), child_id));
}
}
}
}
pub(super) fn find_block_id_by_flavour(
block_pool: &HashMap<String, Map>,
flavour: &str,
) -> Option<String> {
block_pool.iter().find_map(|(id, block)| {
get_flavour(block)
.filter(|block_flavour| block_flavour == flavour)
.map(|_| id.clone())
})
}
pub(super) fn collect_child_ids(block: &Map) -> Vec<String> {
block
.get("sys:children")
.and_then(|value| value.to_array())
.map(|array| {
array
.iter()
.filter_map(|value| value_to_string(&value))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub(super) fn get_block_id(block: &Map) -> Option<String> {
get_string(block, "sys:id")
}
pub(super) fn get_flavour(block: &Map) -> Option<String> {
get_string(block, "sys:flavour")
}
pub(super) fn get_string(block: &Map, key: &str) -> Option<String> {
block.get(key).and_then(|value| value_to_string(&value))
}
pub(super) fn get_list_depth(
block_id: &str,
parent_lookup: &HashMap<String, String>,
blocks: &HashMap<String, Map>,
) -> usize {
let mut depth = 0;
let mut current_id = block_id.to_string();
while let Some(parent_id) = parent_lookup.get(&current_id) {
if let Some(parent_block) = blocks.get(parent_id) {
if get_flavour(parent_block).as_deref() == Some("affine:list") {
depth += 1;
current_id = parent_id.clone();
continue;
}
}
break;
}
depth
}
pub(super) fn nearest_by_flavour(
start: &str,
flavour: &str,
parent_lookup: &HashMap<String, String>,
blocks: &HashMap<String, Map>,
) -> Option<Map> {
let mut cursor = Some(start.to_string());
while let Some(node) = cursor {
if let Some(block) = blocks.get(&node) {
if get_flavour(block).as_deref() == Some(flavour) {
return Some(block.clone());
}
}
cursor = parent_lookup.get(&node).cloned();
}
None
}
@@ -0,0 +1,791 @@
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
rc::{Rc, Weak},
};
use y_octo::{AHashMap, Any, Map, Text, TextAttributes, TextDeltaOp, TextInsert, Value};
use super::value::{
any_as_string, any_as_u64, any_truthy, build_reference_payload, params_any_map_to_json,
value_to_any,
};
#[derive(Debug, Clone)]
struct InlineReference {
ref_type: Option<String>,
page_id: String,
title: Option<String>,
params: Option<AHashMap<String, Any>>,
mode: Option<String>,
}
#[derive(Debug, Clone)]
pub(super) struct InlineReferencePayload {
pub(super) doc_id: String,
pub(super) payload: String,
}
#[derive(Debug, Clone)]
pub(super) struct DeltaToMdOptions {
doc_url_prefix: Option<String>,
}
impl DeltaToMdOptions {
pub(super) fn new(doc_url_prefix: Option<String>) -> Self {
Self { doc_url_prefix }
}
fn build_reference_link(&self, reference: &InlineReference) -> (String, String) {
let title = reference.title.clone().unwrap_or_default();
if let Some(prefix) = self.doc_url_prefix.as_deref() {
let prefix = prefix.trim_end_matches('/');
return (title, format!("{}/{}", prefix, reference.page_id));
}
let mut parts = Vec::new();
parts.push(
reference
.ref_type
.clone()
.unwrap_or_else(|| "LinkedPage".into()),
);
parts.push(reference.page_id.clone());
if let Some(mode) = reference.mode.as_ref() {
parts.push(mode.clone());
}
(title, parts.join(":"))
}
}
pub(super) fn text_to_markdown(
block: &Map,
key: &str,
options: &DeltaToMdOptions,
) -> Option<String> {
block
.get(key)
.and_then(|value| value.to_text())
.map(|text| delta_to_markdown(&text, options))
}
pub(super) fn text_to_inline_markdown(
block: &Map,
key: &str,
options: &DeltaToMdOptions,
) -> Option<String> {
block
.get(key)
.and_then(|value| value.to_text())
.map(|text| delta_to_inline_markdown(&text, options))
}
pub(super) fn extract_inline_references(delta: &[TextDeltaOp]) -> Vec<InlineReferencePayload> {
let mut refs = Vec::new();
let mut seen: HashSet<(String, String)> = HashSet::new();
for op in delta {
let attrs = match op {
TextDeltaOp::Insert {
format: Some(format),
..
} => format,
_ => continue,
};
let reference = match attrs.get("reference").and_then(parse_inline_reference) {
Some(reference) => reference,
None => continue,
};
let payload = match inline_reference_payload(&reference) {
Some(payload) => payload,
None => continue,
};
let key = (reference.page_id.clone(), payload.clone());
if seen.insert(key.clone()) {
refs.push(InlineReferencePayload {
doc_id: key.0,
payload: key.1,
});
}
}
refs
}
fn parse_inline_reference(value: &Any) -> Option<InlineReference> {
let map = match value {
Any::Object(map) => map,
_ => return None,
};
let page_id = map
.get("pageId")
.and_then(any_as_string)
.map(str::to_string)?;
let title = map.get("title").and_then(any_as_string).map(str::to_string);
let ref_type = map.get("type").and_then(any_as_string).map(str::to_string);
let params = map.get("params").and_then(|value| match value {
Any::Object(map) => Some(map.clone()),
_ => None,
});
let mode = params
.as_ref()
.and_then(|params| params.get("mode"))
.and_then(any_as_string)
.map(str::to_string);
Some(InlineReference {
ref_type,
page_id,
title,
params,
mode,
})
}
fn inline_reference_payload(reference: &InlineReference) -> Option<String> {
let params = reference.params.as_ref().map(params_any_map_to_json);
Some(build_reference_payload(&reference.page_id, params))
}
fn delta_to_markdown(text: &Text, options: &DeltaToMdOptions) -> String {
delta_to_markdown_with_options(&text.to_delta(), options, true)
}
fn delta_to_inline_markdown(text: &Text, options: &DeltaToMdOptions) -> String {
delta_to_markdown_with_options(&text.to_delta(), options, false)
}
fn delta_to_markdown_with_options(
delta: &[TextDeltaOp],
options: &DeltaToMdOptions,
trailing_newline: bool,
) -> String {
let ops = build_delta_ops(delta);
delta_ops_to_markdown_with_options(&ops, options, trailing_newline)
}
fn delta_ops_to_markdown_with_options(
ops: &[DeltaOp],
options: &DeltaToMdOptions,
trailing_newline: bool,
) -> String {
let root = convert_delta_ops(ops, options);
let mut rendered = render_node(&root);
rendered = rendered.trim_end().to_string();
if trailing_newline {
rendered.push('\n');
}
rendered
}
#[derive(Debug, Clone)]
struct DeltaOp {
insert: DeltaInsert,
attributes: TextAttributes,
}
#[derive(Debug, Clone)]
enum DeltaInsert {
Text(String),
Embed(Vec<Any>),
}
fn delta_ops_from_any(value: &Any) -> Option<Vec<DeltaOp>> {
let map = match value {
Any::Object(map) => map,
_ => return None,
};
match map.get("$blocksuite:internal:text$") {
Some(Any::True) => {}
_ => return None,
}
let delta = map.get("delta")?;
let entries = match delta {
Any::Array(entries) => entries,
_ => return None,
};
let mut ops = Vec::new();
for entry in entries {
if let Some(op) = delta_op_from_any(entry) {
ops.push(op);
}
}
Some(ops)
}
fn delta_op_from_any(value: &Any) -> Option<DeltaOp> {
let map = match value {
Any::Object(map) => map,
_ => return None,
};
let insert_value = map.get("insert")?;
let insert = match insert_value {
Any::String(text) => DeltaInsert::Text(text.clone()),
Any::Array(values) => DeltaInsert::Embed(values.clone()),
_ => DeltaInsert::Embed(vec![insert_value.clone()]),
};
let attributes = map
.get("attributes")
.and_then(any_to_attributes)
.unwrap_or_default();
Some(DeltaOp { insert, attributes })
}
fn any_to_attributes(value: &Any) -> Option<TextAttributes> {
let map = match value {
Any::Object(map) => map,
_ => return None,
};
let mut attrs = TextAttributes::new();
for (key, value) in map.iter() {
attrs.insert(key.clone(), value.clone());
}
Some(attrs)
}
fn delta_any_to_inline_markdown(value: &Any, options: &DeltaToMdOptions) -> Option<String> {
delta_ops_from_any(value).map(|ops| delta_ops_to_markdown_with_options(&ops, options, false))
}
pub(super) fn delta_value_to_inline_markdown(
value: &Value,
options: &DeltaToMdOptions,
) -> Option<String> {
if let Some(text) = value.to_text() {
return Some(delta_to_inline_markdown(&text, options));
}
let any = value_to_any(value)?;
delta_any_to_inline_markdown(&any, options)
}
fn build_delta_ops(delta: &[TextDeltaOp]) -> Vec<DeltaOp> {
let mut ops = Vec::new();
for op in delta {
let (insert, attrs) = match op {
TextDeltaOp::Insert { insert, format } => (insert, format.clone().unwrap_or_default()),
_ => continue,
};
match insert {
TextInsert::Text(text) => ops.push(DeltaOp {
insert: DeltaInsert::Text(text.clone()),
attributes: attrs,
}),
TextInsert::Embed(values) => ops.push(DeltaOp {
insert: DeltaInsert::Embed(values.clone()),
attributes: attrs,
}),
}
}
ops
}
#[derive(Debug)]
struct Group {
node: Rc<RefCell<Node>>,
kind: String,
distance: usize,
count: usize,
}
fn convert_delta_ops(ops: &[DeltaOp], options: &DeltaToMdOptions) -> Rc<RefCell<Node>> {
let root = Node::new_root();
let mut group: Option<Group> = None;
let mut active_inline: HashMap<String, Any> = HashMap::new();
let mut beginning_of_line = false;
let mut line = Node::new_line();
let mut el = line.clone();
Node::append(&root, line.clone());
for index in 0..ops.len() {
let op = &ops[index];
let next_attrs = ops.get(index + 1).map(|next| &next.attributes);
match &op.insert {
DeltaInsert::Embed(values) => {
apply_inline_attributes(&mut el, &op.attributes, None, &mut active_inline, options);
for value in values {
match value {
Any::Object(map) => {
for (key, value) in map.iter() {
match key.as_str() {
"image" => {
if let Some(src) = any_as_string(value) {
let url = encode_link(src);
Node::append(&el, Node::new_text(&format!("![]({url})")));
}
}
"thematic_break" => {
let current_open = el.borrow().open.clone();
el.borrow_mut().open = format!("\n---\n{current_open}");
}
_ => {}
}
}
}
Any::String(value) => {
Node::append(&el, Node::new_text(value));
}
_ => {}
}
}
}
DeltaInsert::Text(text) => {
let lines: Vec<&str> = text.split('\n').collect();
if has_block_level_attribute(&op.attributes) {
for _ in 1..lines.len() {
for (attr, value) in op.attributes.iter() {
match attr.as_str() {
"header" => {
if let Some(level) = any_as_u64(value) {
let prefix = "#".repeat(level as usize);
let current_open = line.borrow().open.clone();
line.borrow_mut().open = format!("{prefix} {current_open}");
new_line(&root, &mut line, &mut el, &mut active_inline);
break;
}
}
"blockquote" => {
let current_open = line.borrow().open.clone();
line.borrow_mut().open = format!("> {current_open}");
new_line(&root, &mut line, &mut el, &mut active_inline);
break;
}
"list" => {
if group.as_ref().is_some_and(|g| g.kind != attr.as_str()) {
group = None;
}
if group.is_none() {
let group_node = Node::new_line();
Node::append(&root, group_node.clone());
group = Some(Group {
node: group_node,
kind: attr.to_string(),
distance: 0,
count: 0,
});
}
if let Some(group) = group.as_mut() {
Node::append(&group.node, line.clone());
group.distance = 0;
match any_as_string(value) {
Some("bullet") => {
let current_open = line.borrow().open.clone();
line.borrow_mut().open = format!("- {current_open}");
}
Some("checked") => {
let current_open = line.borrow().open.clone();
line.borrow_mut().open = format!("- [x] {current_open}");
}
Some("unchecked") => {
let current_open = line.borrow().open.clone();
line.borrow_mut().open = format!("- [ ] {current_open}");
}
Some("ordered") => {
group.count += 1;
let current_open = line.borrow().open.clone();
line.borrow_mut().open = format!("{}. {}", group.count, current_open);
}
_ => {}
}
}
new_line(&root, &mut line, &mut el, &mut active_inline);
break;
}
_ => {}
}
}
}
beginning_of_line = true;
} else {
for (line_index, segment) in lines.iter().enumerate() {
if (line_index > 0 || beginning_of_line) && group.is_some() {
let reset_group = group.as_mut().map(|group| {
group.distance += 1;
group.distance >= 2
});
if reset_group == Some(true) {
group = None;
}
}
apply_inline_attributes(
&mut el,
&op.attributes,
next_attrs,
&mut active_inline,
options,
);
Node::append(&el, Node::new_text(segment));
if line_index + 1 < lines.len() {
new_line(&root, &mut line, &mut el, &mut active_inline);
}
}
beginning_of_line = false;
}
}
}
}
root
}
fn apply_inline_attributes(
el: &mut Rc<RefCell<Node>>,
attrs: &TextAttributes,
next: Option<&TextAttributes>,
active_inline: &mut HashMap<String, Any>,
options: &DeltaToMdOptions,
) {
let mut first = Vec::new();
let mut then = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut tag = el.clone();
loop {
let format = match tag.borrow().format.clone() {
Some(format) => format,
None => break,
};
seen.insert(format.clone());
let should_close = match attrs.get(&format) {
Some(value) => !any_truthy(value) || tag.borrow().open != tag.borrow().close,
None => true,
};
if should_close {
for key in seen.iter() {
active_inline.remove(key);
}
let parent = {
let tag_ref = tag.borrow();
tag_ref.parent.as_ref().and_then(|p| p.upgrade())
};
if let Some(parent) = parent {
*el = parent.clone();
tag = parent;
continue;
}
break;
}
let parent = {
let tag_ref = tag.borrow();
tag_ref.parent.as_ref().and_then(|p| p.upgrade())
};
if let Some(parent) = parent {
tag = parent;
} else {
break;
}
}
for (attr, value) in attrs.iter() {
if !is_inline_attribute(attr) || !any_truthy(value) {
continue;
}
if let Some(active) = active_inline.get(attr) {
if active == value {
continue;
}
}
let next_matches = next
.and_then(|next_attrs| next_attrs.get(attr))
.map(|next_value| next_value == value)
.unwrap_or(false);
if next_matches {
first.push(attr.clone());
} else {
then.push(attr.clone());
}
active_inline.insert(attr.clone(), value.clone());
}
for attr in first.into_iter().chain(then) {
if let Some(node) = inline_node_for_attr(&attr, attrs, options) {
node.borrow_mut().format = Some(attr.clone());
Node::append(el, node.clone());
*el = node;
}
}
}
fn inline_node_for_attr(
attr: &str,
attrs: &TextAttributes,
options: &DeltaToMdOptions,
) -> Option<Rc<RefCell<Node>>> {
match attr {
"italic" => Some(Node::new_inline("_", "_")),
"bold" => Some(Node::new_inline("**", "**")),
"link" => attrs
.get(attr)
.and_then(any_as_string)
.map(|url| Node::new_inline("[", &format!("]({url})"))),
"reference" => attrs
.get(attr)
.and_then(parse_inline_reference)
.map(|reference| {
let (title, link) = options.build_reference_link(&reference);
Node::new_inline("[", &format!("{title}]({link})"))
}),
"strike" => Some(Node::new_inline("~~", "~~")),
"code" => Some(Node::new_inline("`", "`")),
_ => None,
}
}
fn has_block_level_attribute(attrs: &TextAttributes) -> bool {
attrs.contains_key("header") || attrs.contains_key("blockquote") || attrs.contains_key("list")
}
fn is_inline_attribute(attr: &str) -> bool {
matches!(
attr,
"italic" | "bold" | "link" | "reference" | "strike" | "code"
)
}
fn encode_link(link: &str) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
#[inline]
fn push_pct(out: &mut String, b: u8) {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
#[inline]
fn is_allowed(b: u8) -> bool {
matches!(
b,
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'!'
| b'~'
| b'*'
| b'\''
| b';'
| b','
| b'/'
| b'?'
| b':'
| b'@'
| b'&'
| b'='
| b'+'
| b'$'
| b'#'
)
}
let mut out = String::with_capacity(link.len());
for &b in link.as_bytes() {
match b {
b'(' | b')' => push_pct(&mut out, b),
b if is_allowed(b) => out.push(b as char),
b => push_pct(&mut out, b),
}
}
if let Some(i) = out.find("?response-content-disposition=attachment") {
out.truncate(i);
} else if let Some(i) = out.find("&response-content-disposition=attachment") {
out.truncate(i);
}
out
}
#[derive(Debug)]
struct Node {
open: String,
close: String,
text: String,
children: Vec<Rc<RefCell<Node>>>,
parent: Option<Weak<RefCell<Node>>>,
format: Option<String>,
}
impl Node {
fn new_root() -> Rc<RefCell<Node>> {
Rc::new(RefCell::new(Node {
open: String::new(),
close: String::new(),
text: String::new(),
children: Vec::new(),
parent: None,
format: None,
}))
}
fn new_inline(open: &str, close: &str) -> Rc<RefCell<Node>> {
Rc::new(RefCell::new(Node {
open: open.to_string(),
close: close.to_string(),
text: String::new(),
children: Vec::new(),
parent: None,
format: None,
}))
}
fn new_text(text: &str) -> Rc<RefCell<Node>> {
Rc::new(RefCell::new(Node {
open: String::new(),
close: String::new(),
text: text.to_string(),
children: Vec::new(),
parent: None,
format: None,
}))
}
fn new_line() -> Rc<RefCell<Node>> {
Rc::new(RefCell::new(Node {
open: String::new(),
close: "\n".to_string(),
text: String::new(),
children: Vec::new(),
parent: None,
format: None,
}))
}
fn append(parent: &Rc<RefCell<Node>>, child: Rc<RefCell<Node>>) {
if let Some(old_parent) = child.borrow().parent.as_ref().and_then(|p| p.upgrade()) {
let mut old_parent = old_parent.borrow_mut();
old_parent
.children
.retain(|existing| !Rc::ptr_eq(existing, &child));
}
child.borrow_mut().parent = Some(Rc::downgrade(parent));
parent.borrow_mut().children.push(child);
}
}
fn render_node(node: &Rc<RefCell<Node>>) -> String {
let node_ref = node.borrow();
let mut inner = node_ref.text.clone();
for child in node_ref.children.iter() {
inner.push_str(&render_node(child));
}
if inner.trim().is_empty()
&& node_ref.open == node_ref.close
&& !node_ref.open.is_empty()
&& !node_ref.close.is_empty()
{
return String::new();
}
let wrapped = !node_ref.open.is_empty() && !node_ref.close.is_empty();
let empty_inner = inner.trim().is_empty();
let mut fragments = Vec::new();
if inner.starts_with(' ') && !empty_inner && wrapped {
fragments.push(" ".to_string());
}
if !node_ref.open.is_empty() {
fragments.push(node_ref.open.clone());
}
fragments.push(if wrapped {
inner.trim().to_string()
} else {
inner.clone()
});
if !node_ref.close.is_empty() {
fragments.push(node_ref.close.clone());
}
if inner.ends_with(' ') && !empty_inner && wrapped {
fragments.push(" ".to_string());
}
fragments.join("")
}
fn new_line(
root: &Rc<RefCell<Node>>,
line: &mut Rc<RefCell<Node>>,
el: &mut Rc<RefCell<Node>>,
active_inline: &mut HashMap<String, Any>,
) {
*line = Node::new_line();
*el = line.clone();
Node::append(root, line.clone());
active_inline.clear();
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
#[test]
fn test_delta_to_inline_markdown_link() {
let mut attrs = TextAttributes::new();
attrs.insert("link".into(), Any::String("https://example.com".into()));
let delta = vec![TextDeltaOp::Insert {
insert: TextInsert::Text("AFFiNE".into()),
format: Some(attrs),
}];
let options = DeltaToMdOptions::new(None);
let rendered = delta_to_markdown_with_options(&delta, &options, false);
assert_eq!(rendered, "[AFFiNE](https://example.com)");
}
#[test]
fn test_extract_inline_references_payload() {
let mut ref_map = AHashMap::default();
ref_map.insert("pageId".into(), Any::String("doc123".into()));
ref_map.insert("title".into(), Any::String("Doc Title".into()));
ref_map.insert("type".into(), Any::String("LinkedPage".into()));
let mut attrs = TextAttributes::new();
attrs.insert("reference".into(), Any::Object(ref_map));
let delta = vec![TextDeltaOp::Insert {
insert: TextInsert::Text("Doc Title".into()),
format: Some(attrs),
}];
let refs = extract_inline_references(&delta);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].doc_id, "doc123");
let payload: Value = serde_json::from_str(&refs[0].payload).unwrap();
assert_eq!(payload, serde_json::json!({ "docId": "doc123" }));
}
}
@@ -0,0 +1,9 @@
mod affine;
mod blocksuite;
mod delta_markdown;
mod value;
pub use affine::{
get_doc_ids_from_binary, parse_doc_from_binary, parse_doc_to_markdown, BlockInfo, CrawlResult,
MarkdownResult, ParseError,
};
@@ -0,0 +1,121 @@
use serde_json::{Map as JsonMap, Value as JsonValue};
use y_octo::{AHashMap, Any, Value};
pub(super) fn any_truthy(value: &Any) -> bool {
match value {
Any::True => true,
Any::False | Any::Null | Any::Undefined => false,
Any::String(value) => !value.is_empty(),
Any::Integer(value) => *value != 0,
Any::Float32(value) => value.0 != 0.0,
Any::Float64(value) => value.0 != 0.0,
Any::BigInt64(value) => *value != 0,
Any::Object(_) | Any::Array(_) | Any::Binary(_) => true,
}
}
pub(super) fn any_as_string(value: &Any) -> Option<&str> {
match value {
Any::String(value) => Some(value),
_ => None,
}
}
pub(super) fn any_as_u64(value: &Any) -> Option<u64> {
match value {
Any::Integer(value) if *value >= 0 => Some(*value as u64),
Any::Float32(value) if value.0 >= 0.0 => Some(value.0 as u64),
Any::Float64(value) if value.0 >= 0.0 => Some(value.0 as u64),
Any::BigInt64(value) if *value >= 0 => Some(*value as u64),
_ => None,
}
}
pub(super) fn value_to_string(value: &Value) -> Option<String> {
if let Some(text) = value.to_text() {
return Some(text.to_string());
}
if let Some(any) = value.to_any() {
return any_to_string(&any);
}
None
}
pub(super) fn value_to_any(value: &Value) -> Option<Any> {
if let Some(any) = value.to_any() {
return Some(any);
}
if let Some(text) = value.to_text() {
return Some(Any::String(text.to_string()));
}
if let Some(array) = value.to_array() {
let mut values = Vec::new();
for item in array.iter() {
if let Some(any) = value_to_any(&item) {
values.push(any);
} else if let Some(text) = value_to_string(&item) {
values.push(Any::String(text));
}
}
return Some(Any::Array(values));
}
if let Some(map) = value.to_map() {
let mut values = AHashMap::default();
for key in map.keys() {
if let Some(entry) = map.get(key) {
if let Some(any) = value_to_any(&entry) {
values.insert(key.to_string(), any);
} else if let Some(text) = value_to_string(&entry) {
values.insert(key.to_string(), Any::String(text));
}
}
}
return Some(Any::Object(values));
}
None
}
pub(super) fn any_to_string(any: &Any) -> Option<String> {
match any {
Any::String(value) => Some(value.to_string()),
Any::Integer(value) => Some(value.to_string()),
Any::Float32(value) => Some(value.0.to_string()),
Any::Float64(value) => Some(value.0.to_string()),
Any::BigInt64(value) => Some(value.to_string()),
Any::True => Some("true".into()),
Any::False => Some("false".into()),
Any::Null | Any::Undefined => None,
Any::Array(_) | Any::Object(_) | Any::Binary(_) => serde_json::to_string(any).ok(),
}
}
pub(super) fn params_any_map_to_json(params: &AHashMap<String, Any>) -> JsonValue {
let mut values = JsonMap::new();
for (key, value) in params.iter() {
if let Ok(value) = serde_json::to_value(value) {
values.insert(key.clone(), value);
}
}
JsonValue::Object(values)
}
pub(super) fn params_value_to_json(params: &Value) -> Option<JsonValue> {
serde_json::to_value(params).ok()
}
pub(super) fn build_reference_payload(doc_id: &str, params: Option<JsonValue>) -> String {
let mut payload = JsonMap::new();
payload.insert("docId".into(), JsonValue::String(doc_id.to_string()));
if let Some(JsonValue::Object(params)) = params {
for (key, value) in params.into_iter() {
payload.insert(key, value);
}
}
JsonValue::Object(payload).to_string()
}