affine_importer 0.1.2

AFFiNE import planning and batching core.
Documentation
use crate::ImportedIconData;

pub(super) fn html_to_markdown(html: &str) -> String {
  let body = html
    .split_once("page-body")
    .and_then(|(_, rest)| rest.split_once('>').map(|(_, rest)| rest))
    .unwrap_or(html);
  let mut text = html_images_to_markdown(body)
    .replace("<br>", "\n")
    .replace("<br/>", "\n")
    .replace("<br />", "\n")
    .replace("</p>", "\n\n")
    .replace("</div>", "\n")
    .replace("</li>", "\n");
  text = strip_html_tags(&text);
  html_unescape(&text).trim().to_string()
}

fn html_images_to_markdown(html: &str) -> String {
  let mut output = String::with_capacity(html.len());
  let mut rest = html;
  loop {
    let Some(img_start) = rest.find("<img") else {
      output.push_str(rest);
      break;
    };
    output.push_str(&rest[..img_start]);
    let img_rest = &rest[img_start..];
    let Some(tag_end) = img_rest.find('>') else {
      output.push_str(img_rest);
      break;
    };
    let tag = &img_rest[..=tag_end];
    if let Some(src) = html_attr(tag, "src") {
      output.push_str(&format!("\n![]({src})\n"));
    }
    rest = &img_rest[tag_end + 1..];
  }
  output
}

fn html_attr(tag: &str, name: &str) -> Option<String> {
  let pattern = format!("{name}=\"");
  let (_, rest) = tag.split_once(&pattern)?;
  let (value, _) = rest.split_once('"')?;
  Some(html_unescape(value))
}

pub(super) fn extract_html_title(html: &str) -> Option<String> {
  extract_html_class_text(html, "page-title")
    .or_else(|| extract_html_tag_text(html, "h1"))
    .map(|title| title.trim().to_string())
    .filter(|title| !title.is_empty())
}

fn extract_html_class_text(html: &str, class_name: &str) -> Option<String> {
  let marker = format!("class=\"{class_name}\"");
  let (_, rest) = html.split_once(&marker)?;
  let (_, rest) = rest.split_once('>')?;
  let (content, _) = rest.split_once('<')?;
  Some(html_unescape(content))
}

fn extract_html_tag_text(html: &str, tag_name: &str) -> Option<String> {
  let marker = format!("<{tag_name}");
  let (_, rest) = html.split_once(&marker)?;
  let (_, rest) = rest.split_once('>')?;
  let (content, _) = rest.split_once(&format!("</{tag_name}>"))?;
  Some(html_unescape(&strip_html_tags(content)))
}

fn strip_html_tags(value: &str) -> String {
  let mut output = String::with_capacity(value.len());
  let mut in_tag = false;
  for ch in value.chars() {
    match ch {
      '<' => in_tag = true,
      '>' => in_tag = false,
      _ if !in_tag => output.push(ch),
      _ => {}
    }
  }
  output
}

fn html_unescape(value: &str) -> String {
  value
    .replace("&nbsp;", " ")
    .replace("&amp;", "&")
    .replace("&lt;", "<")
    .replace("&gt;", ">")
    .replace("&quot;", "\"")
    .replace("&#39;", "'")
}

pub(super) fn extract_html_icon(html: &str) -> Option<ImportedIconData> {
  let marker = "class=\"icon\"";
  let (_, rest) = html.split_once(marker)?;
  let (_, rest) = rest.split_once('>')?;
  let (content, _) = rest.split_once('<')?;
  let content = html_unescape(content).trim().to_string();
  (!content.is_empty()).then_some(ImportedIconData {
    kind: "emoji".to_string(),
    unicode: content,
  })
}

pub(super) fn split_leading_emoji(title: &str) -> (String, Option<String>) {
  let trimmed = title.trim();
  let Some(first) = trimmed.chars().next() else {
    return (String::new(), None);
  };
  if is_emoji(first) {
    let rest = trimmed[first.len_utf8()..].trim().to_string();
    (rest, Some(first.to_string()))
  } else {
    (trimmed.to_string(), None)
  }
}

fn is_emoji(ch: char) -> bool {
  matches!(
    ch as u32,
    0x1F300..=0x1FAFF | 0x2600..=0x27BF
  )
}