affine_importer 0.1.2

AFFiNE import planning and batching core.
Documentation
use std::collections::BTreeMap;

use super::{file_name, normalize_import_path, strip_extension};

#[derive(Debug, Clone)]
enum LookupValue {
  Found(String),
  Ambiguous,
}

#[derive(Debug, Default)]
struct PathLookup {
  exact: BTreeMap<String, String>,
  names: BTreeMap<String, LookupValue>,
}

impl PathLookup {
  fn register(&mut self, path: &str, value: &str, extension_optional: bool) {
    let path = lookup_key(path);
    self.exact.insert(path.clone(), value.to_string());
    self.register_name(file_name(&path), value);
    if extension_optional {
      let path = strip_extension(&path);
      self.exact.insert(path.to_string(), value.to_string());
      self.register_name(file_name(path), value);
    }
  }

  fn register_alias(&mut self, alias: &str, value: &str) {
    self.register_name(&lookup_key(alias), value);
  }

  fn register_name(&mut self, name: &str, value: &str) {
    let key = lookup_key(name);
    match self.names.get(&key) {
      Some(LookupValue::Found(existing)) if existing != value => {
        self.names.insert(key, LookupValue::Ambiguous);
      }
      None => {
        self.names.insert(key, LookupValue::Found(value.to_string()));
      }
      _ => {}
    }
  }

  fn resolve(
    &self,
    target: &str,
    current_path: &str,
    extension: Option<&str>,
    preferred_root: Option<&str>,
  ) -> Option<&str> {
    let target = lookup_key(target);
    if target.is_empty() {
      return None;
    }
    let with_extension = extension
      .filter(|extension| !target.to_lowercase().ends_with(extension))
      .map(|extension| format!("{target}{extension}"));
    let base = current_path.rsplit_once('/').map(|(base, _)| base).unwrap_or("");
    let relative = lookup_key(&format!("{base}/{target}"));
    let relative_with_extension = with_extension
      .as_ref()
      .map(|target| lookup_key(&format!("{base}/{target}")));

    let explicit_path = target.contains('/');
    let candidates = if explicit_path {
      [
        Some(target.as_str()),
        with_extension.as_deref(),
        Some(relative.as_str()),
        relative_with_extension.as_deref(),
      ]
    } else {
      [
        Some(relative.as_str()),
        relative_with_extension.as_deref(),
        Some(target.as_str()),
        with_extension.as_deref(),
      ]
    };
    for candidate in candidates.into_iter().flatten() {
      if let Some(value) = self.exact.get(candidate) {
        return Some(value);
      }
    }

    if let Some(root) = preferred_root {
      for candidate in [Some(target.as_str()), with_extension.as_deref()].into_iter().flatten() {
        let candidate = lookup_key(&format!("{root}/{candidate}"));
        if let Some(value) = self.exact.get(&candidate) {
          return Some(value);
        }
      }
    }

    let name = file_name(&target);
    match self.names.get(name) {
      Some(LookupValue::Found(value)) => Some(value),
      _ => with_extension
        .as_deref()
        .and_then(|path| self.names.get(file_name(path)))
        .and_then(|value| match value {
          LookupValue::Found(value) => Some(value.as_str()),
          LookupValue::Ambiguous => None,
        }),
    }
  }
}

#[derive(Debug, Default)]
pub(super) struct VaultIndex {
  pages: PathLookup,
  assets: PathLookup,
  anchors: BTreeMap<String, BTreeMap<String, String>>,
  attachment_folder: Option<String>,
}

impl VaultIndex {
  pub(super) fn register_page(&mut self, path: &str, doc_id: &str) {
    self.pages.register(path, doc_id, true);
  }

  pub(super) fn register_page_alias(&mut self, alias: &str, doc_id: &str) {
    self.pages.register_alias(alias, doc_id);
  }

  pub(super) fn register_asset(&mut self, path: &str, blob_id: &str) {
    self.assets.register(path, blob_id, false);
  }

  pub(super) fn register_anchors(&mut self, doc_id: &str, anchors: BTreeMap<String, String>) {
    self.anchors.insert(doc_id.to_string(), anchors);
  }

  pub(super) fn set_attachment_folder(&mut self, path: Option<String>) {
    self.attachment_folder = path.filter(|path| !path.is_empty() && path != ".");
  }

  pub(super) fn resolve_page(&self, target: &str, current_path: &str) -> Option<&str> {
    self.pages.resolve(target, current_path, Some(".md"), None)
  }

  pub(super) fn resolve_asset(&self, target: &str, current_path: &str) -> Option<&str> {
    self
      .assets
      .resolve(target, current_path, None, self.attachment_folder.as_deref())
  }

  pub(super) fn resolve_anchor(&self, doc_id: &str, fragment: &str) -> Option<&str> {
    self
      .anchors
      .get(doc_id)
      .and_then(|anchors| anchors.get(&fragment_key(fragment)))
      .map(String::as_str)
  }
}

pub(super) fn fragment_key(fragment: &str) -> String {
  fragment.trim().trim_start_matches('#').to_lowercase()
}

fn lookup_key(path: &str) -> String {
  normalize_import_path(path.trim().trim_start_matches('/')).to_lowercase()
}