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"));
}
}