affine_importer 0.1.2

AFFiNE import planning and batching core.
Documentation
mod html;
mod markdown;

use html::NotionHtmlImportCursor;
use markdown::NotionMarkdownImportCursor;

pub(in crate::planner::providers::notion) use super::folder_hierarchy_deltas;
use super::{
  ImportAssetRef, ImportDocRef, IndexedArchive, add_asset_batch, doc_snapshot, empty_batch, entry_from_indexed,
  extract_html_icon, extract_html_title, file_name, frontmatter_meta, has_emitted_assets, hash_bytes, html_to_markdown,
  normalize_import_path, parse_link_span, push_skipped_doc_warning, read_archive, register_csv_path,
  register_page_path, resolve_path, strip_extension, strip_frontmatter,
};
use crate::{
  ImportBatch, ImportBatchLimits, ImportCursor, ImportError, ImportOptions, ImportProgress, ImportProvider,
  ImportResult, ImportSource, ImportWarning, ImportedDocMeta, ImportedDocSnapshot, ImportedIcon,
};

pub(super) struct NotionZipProvider;
pub(super) struct NotionMarkdownZipProvider;
pub(super) struct NotionHtmlZipProvider;

impl ImportProvider for NotionZipProvider {
  fn format(&self) -> &'static str {
    "notionZip"
  }

  fn create_cursor(
    &self,
    source: ImportSource,
    options: ImportOptions,
    limits: ImportBatchLimits,
  ) -> ImportResult<Box<dyn ImportCursor>> {
    let archive = read_archive(source, true, &options)?;
    if has_markdown_pages(&archive) {
      return Ok(Box::new(NotionMarkdownImportCursor::new(archive, options, limits)?));
    }
    if has_notion_html_pages(&archive) {
      return Ok(Box::new(NotionHtmlImportCursor::new(archive, options, limits)?));
    }
    Err(ImportError::InvalidSource(
      "No Notion Markdown or HTML pages found in the archive".to_string(),
    ))
  }
}

impl ImportProvider for NotionMarkdownZipProvider {
  fn format(&self) -> &'static str {
    "notionMarkdownZip"
  }

  fn create_cursor(
    &self,
    source: ImportSource,
    options: ImportOptions,
    limits: ImportBatchLimits,
  ) -> ImportResult<Box<dyn ImportCursor>> {
    let archive = read_archive(source, true, &options)?;
    Ok(Box::new(NotionMarkdownImportCursor::new(archive, options, limits)?))
  }
}

impl ImportProvider for NotionHtmlZipProvider {
  fn format(&self) -> &'static str {
    "notionHtmlZip"
  }

  fn create_cursor(
    &self,
    source: ImportSource,
    options: ImportOptions,
    limits: ImportBatchLimits,
  ) -> ImportResult<Box<dyn ImportCursor>> {
    let archive = read_archive(source, true, &options)?;
    Ok(Box::new(NotionHtmlImportCursor::new(archive, options, limits)?))
  }
}

fn has_markdown_pages(archive: &IndexedArchive) -> bool {
  archive
    .entries
    .iter()
    .any(|entry| entry.meta.path.to_lowercase().ends_with(".md"))
}

fn has_notion_html_pages(archive: &IndexedArchive) -> bool {
  archive.entries.iter().any(|entry| {
    let lower = entry.meta.path.to_lowercase();
    lower.ends_with(".html") && !lower.ends_with("/index.html")
  })
}

fn strip_notion_hash(name: &str) -> String {
  let trimmed = name.trim();
  let without_uuid = trimmed
    .rsplit_once(' ')
    .and_then(|(prefix, suffix)| is_notion_hash(suffix).then_some(prefix))
    .unwrap_or(trimmed);
  without_uuid.to_string()
}

fn is_notion_hash(value: &str) -> bool {
  let hex = value.replace('-', "");
  hex.len() == 32 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
}

#[cfg(test)]
mod tests {
  use std::collections::BTreeSet;

  use super::{
    super::tests::{plan_zip, zip},
    *,
  };

  fn plan_notion_markdown_zip(bytes: impl AsRef<[u8]>) -> ImportResult<ImportBatch> {
    plan_zip("notionMarkdownZip", bytes)
  }

  fn plan_notion_html_zip(bytes: impl AsRef<[u8]>) -> ImportResult<ImportBatch> {
    plan_zip("notionHtmlZip", bytes)
  }

  fn plan_notion_zip(bytes: impl AsRef<[u8]>) -> ImportResult<ImportBatch> {
    plan_zip("notionZip", bytes)
  }

  #[test]
  fn notion_markdown_zip_strips_hashes_and_expands_nested_zip() {
    let nested = zip(&[("Nested 22222222222222222222222222222222.md", b"# Nested\nnested body")]);
    let bytes = zip(&[
      (
        "Workspace 11111111111111111111111111111111.md",
        b"# Workspace\nroot body",
      ),
      ("Part.zip", nested.as_slice()),
    ]);

    let batch = plan_notion_markdown_zip(bytes).unwrap();
    let titles = batch
      .docs
      .iter()
      .filter_map(|doc| doc.snapshot["meta"]["title"].as_str())
      .collect::<BTreeSet<_>>();

    assert_eq!(titles, BTreeSet::from(["Nested", "Workspace"]));
    assert!(batch.docs.iter().all(|doc| doc.snapshot["meta"]["createDate"] == 0));
    assert!(batch.folders.iter().any(|folder| folder.name == "Workspace"));
    assert!(batch.folders.iter().any(|folder| folder.path == "Part"));
  }

  #[test]
  fn notion_markdown_zip_decodes_links_and_prefers_h1_over_frontmatter() {
    let bytes = zip(&[
      (
        "Workspace/Entry 11111111111111111111111111111111.md",
        b"---\ntitle: Frontmatter\n---\n# Entry\n[go](./Target%2022222222222222222222222222222222.md)",
      ),
      (
        "Workspace/Target 22222222222222222222222222222222.md",
        b"# Target\ntarget body",
      ),
    ]);

    let batch = plan_notion_markdown_zip(bytes).unwrap();
    let entry = batch
      .docs
      .iter()
      .find(|doc| doc.snapshot["meta"]["title"] == "Entry")
      .unwrap();
    let snapshot = serde_json::to_string(&entry.snapshot).unwrap();

    assert!(snapshot.contains("\"reference\""));
    assert!(!snapshot.contains("Frontmatter"));
  }

  #[test]
  fn notion_zip_detects_markdown_export() {
    let bytes = zip(&[("Export/Page 11111111111111111111111111111111.md", b"# Page\nbody")]);

    let batch = plan_notion_zip(bytes).unwrap();

    assert_eq!(batch.docs.len(), 1);
    assert_eq!(batch.docs[0].snapshot["meta"]["title"], "Page");
  }

  #[test]
  fn notion_html_zip_plans_workspace_pages_icons_and_assets() {
    let bytes = zip(&[
      ("Export/index.html", b"<html></html>"),
      (
        "Export/Project.html",
        r#"<html><body><h1>Project Title</h1><div class="icon">✅</div><div class="page-body"><p>Project body</p><img src="assets/logo.png"></div></body></html>"#.as_bytes(),
      ),
      (
        "Export/Project/Nested.html",
        r#"<html><body><div class="page-body"><p>Nested body</p></div></body></html>"#.as_bytes(),
      ),
      ("Export/assets/logo.png", &[137, 80, 78, 71]),
    ]);

    let batch = plan_notion_html_zip(bytes).unwrap();
    let project = batch
      .docs
      .iter()
      .find(|doc| serde_json::to_string(&doc.snapshot).unwrap().contains("Project body"))
      .unwrap();
    let project_snapshot = serde_json::to_string(&project.snapshot).unwrap();

    assert!(batch.is_workspace_file);
    assert_eq!(batch.docs.len(), 2);
    assert_eq!(batch.blobs.len(), 1);
    assert_eq!(project.snapshot["meta"]["title"], "Project Title");
    assert_eq!(project.meta.as_ref().unwrap().title.as_deref(), Some("Project Title"));
    assert_eq!(batch.icons[0].icon.unicode, "✅");
    assert!(project_snapshot.contains("\"sourceId\""));
    assert!(batch.folders.iter().any(|folder| folder.name == "Export"));
    assert!(
      batch
        .folders
        .iter()
        .any(|folder| { folder.name == "Project" && folder.page_id.as_deref() == Some(project.id.as_str()) })
    );
    assert!(
      batch
        .folders
        .iter()
        .any(|folder| { folder.name == "Nested" && folder.parent_path.as_deref() == Some("Export/Project") })
    );
  }

  #[test]
  fn notion_zip_detects_html_export() {
    let bytes = zip(&[
      ("Export/index.html", b"<html></html>"),
      (
        "Export/Page.html",
        r#"<html><body><h1>HTML Page</h1><div class="page-body"><p>body</p></div></body></html>"#.as_bytes(),
      ),
    ]);

    let batch = plan_notion_zip(bytes).unwrap();

    assert_eq!(batch.docs.len(), 1);
    assert_eq!(batch.docs[0].snapshot["meta"]["title"], "HTML Page");
  }

  #[test]
  fn notion_zip_rejects_archive_without_pages() {
    let bytes = zip(&[("Export/assets/logo.png", &[137, 80, 78, 71])]);

    let result = plan_notion_zip(bytes);

    assert!(matches!(result, Err(ImportError::InvalidSource(_))));
  }

  #[test]
  fn notion_markdown_zip_imports_csv_links_as_tables_without_blobs() {
    let bytes = zip(&[
      (
        "Workspace/Entry.md",
        b"# Entry\n\n[Tasks](<Entry/Tasks%2011111111111111111111111111111111.csv?view=table#main>)",
      ),
      (
        "Workspace/Entry/Tasks 11111111111111111111111111111111.csv",
        b"Name,Status\nAlpha,Todo\nBeta,Done",
      ),
    ]);

    let batch = plan_notion_markdown_zip(bytes).unwrap();
    let snapshot = serde_json::to_string(&batch.docs[0].snapshot).unwrap();

    assert!(batch.blobs.is_empty());
    assert!(snapshot.contains("affine:table"));
    assert!(snapshot.contains("Alpha"));
    assert!(!snapshot.contains(".csv"));
  }

  #[test]
  fn notion_markdown_zip_strips_trailing_reference_footer() {
    let bytes = zip(&[
      (
        "Workspace/Entry.md",
        b"# Entry\n\nBody\n\n---\n\n[Page A](Page%20A%2011111111111111111111111111111111.md)\n\n[Page B](Page%20B%2022222222222222222222222222222222.md)\n\n[Table](Table%2033333333333333333333333333333333.csv)\n\n[Page C](Page%20C%2044444444444444444444444444444444.md)",
      ),
      ("Workspace/Page A 11111111111111111111111111111111.md", b"target"),
      ("Workspace/Page B 22222222222222222222222222222222.md", b"target"),
      ("Workspace/Table 33333333333333333333333333333333.csv", b"Name\nA"),
      ("Workspace/Page C 44444444444444444444444444444444.md", b"target"),
    ]);

    let batch = plan_notion_markdown_zip(bytes).unwrap();
    let entry = batch
      .docs
      .iter()
      .find(|doc| doc.snapshot["meta"]["title"] == "Entry")
      .unwrap();
    let snapshot = serde_json::to_string(&entry.snapshot).unwrap();

    assert!(snapshot.contains("Body"));
    assert!(!snapshot.contains("Page B"));
    assert!(!snapshot.contains("Table"));
  }

  #[test]
  fn notion_markdown_zip_keeps_reference_section_with_body_text() {
    let bytes = zip(&[
      (
        "Workspace/Entry.md",
        b"# Entry\n\nBody\n\n---\n\nThis section explains the references.\n\n[Page A](Page%20A%2011111111111111111111111111111111.md)\n\n[Page B](Page%20B%2022222222222222222222222222222222.md)\n\n[Table](Table%2033333333333333333333333333333333.csv)\n\nFinal conclusion.",
      ),
      ("Workspace/Page A 11111111111111111111111111111111.md", b"target"),
      ("Workspace/Page B 22222222222222222222222222222222.md", b"target"),
      ("Workspace/Table 33333333333333333333333333333333.csv", b"Name\nA"),
    ]);

    let batch = plan_notion_markdown_zip(bytes).unwrap();
    let entry = batch
      .docs
      .iter()
      .find(|doc| doc.snapshot["meta"]["title"] == "Entry")
      .unwrap();
    let snapshot = serde_json::to_string(&entry.snapshot).unwrap();

    assert!(snapshot.contains("This section explains the references."));
    assert!(snapshot.contains("Final conclusion."));
    assert!(snapshot.contains("affine:table"));
  }
}