affine_importer 0.1.2

AFFiNE import planning and batching core.
Documentation
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};

use crate::ImportedDocMeta;

#[derive(Debug, Default, Clone)]
pub(super) struct Frontmatter {
  pub title: Option<String>,
  create_date: Option<i64>,
  updated_date: Option<i64>,
  tags: Option<Vec<String>>,
  favorite: Option<bool>,
  trash: Option<bool>,
}

pub(super) fn strip_frontmatter(markdown: &str) -> (String, Frontmatter) {
  let Some(rest) = markdown.strip_prefix("---\n") else {
    return (markdown.to_string(), Frontmatter::default());
  };
  let Some((frontmatter, body)) = rest.split_once("\n---\n") else {
    return (markdown.to_string(), Frontmatter::default());
  };
  (body.to_string(), parse_frontmatter(frontmatter))
}

pub(super) fn frontmatter_meta(frontmatter: &Frontmatter, title: &str) -> Option<ImportedDocMeta> {
  let meta = ImportedDocMeta {
    title: Some(title.to_string()),
    create_date: frontmatter.create_date,
    updated_date: frontmatter.updated_date,
    tags: frontmatter.tags.clone(),
    favorite: frontmatter.favorite,
    trash: frontmatter.trash,
  };
  (meta.create_date.is_some()
    || meta.updated_date.is_some()
    || meta.tags.is_some()
    || meta.favorite.is_some()
    || meta.trash.is_some())
  .then_some(meta)
}

pub(super) fn parse_date_millis(value: &str) -> Option<i64> {
  let value = unquote(value);
  DateTime::parse_from_rfc3339(value)
    .map(|date| date.timestamp_millis())
    .or_else(|_| {
      NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S").map(|date| date.and_utc().timestamp_millis())
    })
    .ok()
    .or_else(|| {
      NaiveDate::parse_from_str(value, "%Y-%m-%d")
        .ok()
        .and_then(|date| date.and_hms_opt(0, 0, 0))
        .map(|date| DateTime::<Utc>::from_naive_utc_and_offset(date, Utc).timestamp_millis())
    })
}

fn parse_frontmatter(frontmatter: &str) -> Frontmatter {
  let mut parsed = Frontmatter::default();
  for line in frontmatter.lines() {
    let Some((key, value)) = line.split_once(':') else {
      continue;
    };
    let value = value.trim();
    match key.trim() {
      "title" if !value.is_empty() => parsed.title = Some(unquote(value).to_string()),
      "createDate" | "created" => parsed.create_date = parse_date_millis(value),
      "updatedDate" | "updated" => parsed.updated_date = parse_date_millis(value),
      "tags" => parsed.tags = parse_tags(value),
      "favorite" => parsed.favorite = parse_bool(value),
      "trash" => parsed.trash = parse_bool(value),
      _ => {}
    }
  }
  parsed
}

fn parse_tags(value: &str) -> Option<Vec<String>> {
  let value = value.trim();
  let tags = if let Some(inner) = value.strip_prefix('[').and_then(|value| value.strip_suffix(']')) {
    inner
      .split(',')
      .map(unquote)
      .map(str::trim)
      .filter(|tag| !tag.is_empty())
      .map(ToString::to_string)
      .collect::<Vec<_>>()
  } else {
    value
      .split(',')
      .map(unquote)
      .map(str::trim)
      .filter(|tag| !tag.is_empty())
      .map(ToString::to_string)
      .collect::<Vec<_>>()
  };
  (!tags.is_empty()).then_some(tags)
}

fn parse_bool(value: &str) -> Option<bool> {
  match unquote(value) {
    "true" => Some(true),
    "false" => Some(false),
    _ => None,
  }
}

fn unquote(value: &str) -> &str {
  value.trim().trim_matches('"').trim_matches('\'')
}