use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use nanoid::nanoid;
use serde_json::{Map as JsonMap, Value as JsonValue};
use sha2::{Digest, Sha256};
#[cfg(test)]
use crate::vfs::Vfs;
use crate::{
ArchiveEntryMeta, ArchiveSource, FolderHierarchyDelta, ImportBatch, ImportError, ImportProgress, ImportResult,
ImportWarning, ImportedAsset, ImportedDocMeta, ImportedDocSnapshot, ImportedIcon, ImportedIconData, ImportedTag,
vfs::{VfsEntry, normalize_import_path},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportFormat {
MarkdownZip,
NotionZip,
NotionMarkdownZip,
NotionHtmlZip,
Obsidian,
BearZip,
}
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
pub cancel: bool,
pub cancel_after_entries: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct ImportBatchLimits {
pub max_docs: usize,
pub max_blobs: usize,
pub max_blob_bytes: u64,
}
impl Default for ImportBatchLimits {
fn default() -> Self {
Self {
max_docs: 20,
max_blobs: 100,
max_blob_bytes: 10 * 1024 * 1024,
}
}
}
pub struct ImportPlanner;
impl ImportPlanner {
pub fn create_cursor<S: ArchiveSource + 'static>(
source: S,
format: ImportFormat,
options: ImportOptions,
limits: ImportBatchLimits,
) -> ImportResult<ImportPlanCursor> {
if options.cancel {
return Err(ImportError::Cancelled);
}
let indexed = IndexedArchive::read(
source,
matches!(
format,
ImportFormat::NotionZip | ImportFormat::NotionMarkdownZip | ImportFormat::NotionHtmlZip
),
)?;
match resolve_import_format(format, &indexed)? {
ImportFormat::MarkdownZip => ImportPlanCursor::markdown(indexed, false, options, limits),
ImportFormat::NotionZip => unreachable!("notion zip format should be resolved before planning"),
ImportFormat::NotionMarkdownZip => ImportPlanCursor::markdown(indexed, true, options, limits),
ImportFormat::NotionHtmlZip => ImportPlanCursor::notion_html(indexed, options, limits),
ImportFormat::Obsidian => ImportPlanCursor::obsidian(indexed, options, limits),
ImportFormat::BearZip => ImportPlanCursor::bear(indexed, options, limits),
}
}
}
fn resolve_import_format(format: ImportFormat, archive: &IndexedArchive) -> ImportResult<ImportFormat> {
if format != ImportFormat::NotionZip {
return Ok(format);
}
if archive
.entries
.iter()
.any(|entry| entry.meta.path.to_lowercase().ends_with(".md"))
{
return Ok(ImportFormat::NotionMarkdownZip);
}
if archive.entries.iter().any(|entry| {
let lower = entry.meta.path.to_lowercase();
lower.ends_with(".html") && !lower.ends_with("/index.html")
}) {
return Ok(ImportFormat::NotionHtmlZip);
}
Err(ImportError::InvalidSource(
"No Notion Markdown or HTML pages found in the archive".to_string(),
))
}
#[cfg(test)]
use crate::ZipBytesSource;
#[cfg(test)]
pub fn plan_import(bytes: impl Into<Vec<u8>>, format: ImportFormat) -> ImportResult<ImportBatch> {
let mut cursor = ImportPlanner::create_cursor(
ZipBytesSource::new(bytes),
format,
ImportOptions::default(),
ImportBatchLimits {
max_docs: usize::MAX,
max_blobs: usize::MAX,
max_blob_bytes: u64::MAX,
},
)?;
let mut merged = empty_batch(0);
while let Some(batch) = cursor.next_batch()? {
merge_batch(&mut merged, batch);
}
merged.done = true;
Ok(merged)
}
#[derive(Debug, Clone)]
struct IndexedEntry {
meta: ArchiveEntryMeta,
bytes: Option<Vec<u8>>,
}
struct IndexedArchive {
source: Box<dyn ArchiveSource>,
entries: Vec<IndexedEntry>,
}
impl IndexedArchive {
fn read<S: ArchiveSource + 'static>(source: S, expand_nested_zips: bool) -> ImportResult<Self> {
let mut archive = Self {
entries: source
.entries()?
.into_iter()
.map(|meta| IndexedEntry { meta, bytes: None })
.collect(),
source: Box::new(source),
};
if expand_nested_zips {
archive.expand_nested_zips()?;
}
Ok(archive)
}
fn expand_nested_zips(&mut self) -> ImportResult<()> {
let mut expanded = Vec::new();
for entry in self.entries.drain(..).collect::<Vec<_>>() {
if entry.meta.path.to_lowercase().ends_with(".zip") {
let base_path = strip_extension(&entry.meta.path).to_string();
let bytes = if let Some(bytes) = entry.bytes {
bytes
} else {
self.source.read_entry(entry.meta.index)?
};
let nested = read_zip_bytes_entries(&bytes, &base_path)?;
expanded.extend(nested);
} else {
expanded.push(entry);
}
}
self.entries = expanded;
Ok(())
}
fn read_entry(&self, entry: &IndexedEntry) -> ImportResult<Vec<u8>> {
if let Some(bytes) = &entry.bytes {
return Ok(bytes.clone());
}
self.source.read_entry(entry.meta.index)
}
}
fn read_zip_bytes_entries(bytes: &[u8], base_path: &str) -> ImportResult<Vec<IndexedEntry>> {
use std::io::{Cursor, Read};
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
let mut entries = Vec::new();
for index in 0..archive.len() {
let mut file = archive.by_index(index)?;
if file.is_dir() {
continue;
}
let Some(path) = file
.enclosed_name()
.map(|path| normalize_import_path(&path.to_string_lossy()))
else {
continue;
};
if path.is_empty() || crate::source::is_system_path(&path) {
continue;
}
let mut bytes = Vec::with_capacity(file.size() as usize);
file.read_to_end(&mut bytes)?;
let path = if base_path.is_empty() {
path
} else {
format!("{base_path}/{path}")
};
entries.push(IndexedEntry {
meta: ArchiveEntryMeta {
index,
path,
compressed_size: file.compressed_size(),
uncompressed_size: file.size(),
},
bytes: Some(bytes),
});
}
Ok(entries)
}
#[derive(Debug, Clone)]
struct ImportAssetRef {
entry_index: usize,
blob_id: String,
}
#[derive(Debug, Clone)]
struct ImportDocRef {
entry_index: usize,
doc_id: String,
title: Option<String>,
icon: Option<ImportedIconData>,
}
pub struct ImportPlanCursor {
archive: IndexedArchive,
kind: ImportPlanKind,
options: ImportOptions,
limits: ImportBatchLimits,
next_doc: usize,
emitted_assets: BTreeSet<usize>,
emitted_final: bool,
completed: usize,
total: usize,
entry_id: Option<String>,
is_workspace_file: bool,
}
#[derive(Clone)]
enum ImportPlanKind {
Markdown {
notion: bool,
docs: Vec<ImportDocRef>,
assets: Vec<ImportAssetRef>,
csv_entries_by_path: BTreeMap<String, usize>,
page_ids_by_path: BTreeMap<String, String>,
blob_ids_by_path: BTreeMap<String, String>,
folders: Vec<FolderHierarchyDelta>,
warnings: Vec<ImportWarning>,
},
NotionHtml {
docs: Vec<ImportDocRef>,
assets: Vec<ImportAssetRef>,
page_ids_by_path: BTreeMap<String, String>,
blob_ids_by_path: BTreeMap<String, String>,
folders: Vec<FolderHierarchyDelta>,
},
Obsidian {
docs: Vec<ImportDocRef>,
assets: Vec<ImportAssetRef>,
page_ids_by_path: BTreeMap<String, String>,
blob_ids_by_path: BTreeMap<String, String>,
},
Bear {
bundles: Vec<BearBundleRef>,
},
}
#[derive(Debug, Clone)]
struct BearBundleRef {
bundle_path: String,
doc_id: String,
markdown: usize,
info: Option<usize>,
assets: Vec<usize>,
}
impl ImportPlanCursor {
pub fn total(&self) -> usize {
self.total
}
fn markdown(
archive: IndexedArchive,
notion: bool,
options: ImportOptions,
limits: ImportBatchLimits,
) -> ImportResult<Self> {
let mut docs = Vec::new();
let mut assets = Vec::new();
let mut csv_entries_by_path = BTreeMap::new();
let mut page_ids_by_path = BTreeMap::new();
let mut blob_ids_by_path = BTreeMap::new();
for (entry_index, entry) in archive.entries.iter().enumerate() {
if entry.meta.path.to_lowercase().ends_with(".md") {
let doc_id = nanoid!();
register_page_path(&mut page_ids_by_path, &entry.meta.path, &doc_id);
docs.push(ImportDocRef {
entry_index,
doc_id,
title: None,
icon: None,
});
}
}
for (entry_index, entry) in archive.entries.iter().enumerate() {
let lower = entry.meta.path.to_lowercase();
if notion && lower.ends_with(".csv") {
register_csv_path(&mut csv_entries_by_path, &entry.meta.path, entry_index);
} else if !lower.ends_with(".md") {
let blob_id = hash_bytes(&archive.read_entry(entry)?);
blob_ids_by_path.insert(entry.meta.path.clone(), blob_id.clone());
assets.push(ImportAssetRef { entry_index, blob_id });
}
}
if docs.is_empty() {
return Err(ImportError::InvalidSource(
"No Markdown pages found in the archive".to_string(),
));
}
let folders = markdown_folders(&docs, &archive, notion);
let total = docs.len();
let entry_id = docs.first().map(|doc| doc.doc_id.clone());
Ok(Self {
archive,
kind: ImportPlanKind::Markdown {
notion,
docs,
assets,
csv_entries_by_path,
page_ids_by_path,
blob_ids_by_path,
folders,
warnings: Vec::new(),
},
options,
limits,
next_doc: 0,
emitted_assets: BTreeSet::new(),
emitted_final: false,
completed: 0,
total,
entry_id,
is_workspace_file: false,
})
}
fn notion_html(archive: IndexedArchive, options: ImportOptions, limits: ImportBatchLimits) -> ImportResult<Self> {
let mut docs = Vec::new();
let mut assets = Vec::new();
let mut page_ids_by_path = BTreeMap::new();
let mut blob_ids_by_path = BTreeMap::new();
let mut is_workspace_file = false;
for (entry_index, entry) in archive.entries.iter().enumerate() {
let lower = entry.meta.path.to_lowercase();
if lower.ends_with("/index.html") {
is_workspace_file = true;
continue;
}
if lower.ends_with(".html") {
let doc_id = nanoid!();
register_page_path(&mut page_ids_by_path, &entry.meta.path, &doc_id);
let html = archive.read_entry(entry)?;
let html = String::from_utf8_lossy(&html);
docs.push(ImportDocRef {
entry_index,
doc_id,
title: extract_html_title(&html),
icon: extract_html_icon(&html),
});
}
}
for (entry_index, entry) in archive.entries.iter().enumerate() {
let lower = entry.meta.path.to_lowercase();
if !lower.ends_with(".html") && !lower.ends_with(".md") {
let blob_id = hash_bytes(&archive.read_entry(entry)?);
blob_ids_by_path.insert(entry.meta.path.clone(), blob_id.clone());
assets.push(ImportAssetRef { entry_index, blob_id });
}
}
if docs.is_empty() {
return Err(ImportError::InvalidSource(
"No Notion HTML pages found in the archive".to_string(),
));
}
let folders = notion_html_cursor_folders(&docs, &archive);
let total = docs.len();
let entry_id = docs.first().map(|doc| doc.doc_id.clone());
Ok(Self {
archive,
kind: ImportPlanKind::NotionHtml {
docs,
assets,
page_ids_by_path,
blob_ids_by_path,
folders,
},
options,
limits,
next_doc: 0,
emitted_assets: BTreeSet::new(),
emitted_final: false,
completed: 0,
total,
entry_id,
is_workspace_file,
})
}
fn obsidian(archive: IndexedArchive, options: ImportOptions, limits: ImportBatchLimits) -> ImportResult<Self> {
let mut docs = Vec::new();
let mut assets = Vec::new();
let mut page_ids_by_path = BTreeMap::new();
let mut blob_ids_by_path = BTreeMap::new();
for (entry_index, entry) in archive.entries.iter().enumerate() {
if entry.meta.path.to_lowercase().ends_with(".md") {
let doc_id = nanoid!();
register_page_path(&mut page_ids_by_path, &entry.meta.path, &doc_id);
register_page_path(&mut page_ids_by_path, file_name(&entry.meta.path), &doc_id);
docs.push(ImportDocRef {
entry_index,
doc_id,
title: None,
icon: None,
});
} else {
let blob_id = hash_bytes(&archive.read_entry(entry)?);
blob_ids_by_path.insert(entry.meta.path.clone(), blob_id.clone());
assets.push(ImportAssetRef { entry_index, blob_id });
}
}
if docs.is_empty() {
return Err(ImportError::InvalidSource(
"No Markdown pages found in the folder".to_string(),
));
}
let total = docs.len();
let entry_id = docs.first().map(|doc| doc.doc_id.clone());
Ok(Self {
archive,
kind: ImportPlanKind::Obsidian {
docs,
assets,
page_ids_by_path,
blob_ids_by_path,
},
options,
limits,
next_doc: 0,
emitted_assets: BTreeSet::new(),
emitted_final: false,
completed: 0,
total,
entry_id,
is_workspace_file: false,
})
}
fn bear(archive: IndexedArchive, options: ImportOptions, limits: ImportBatchLimits) -> ImportResult<Self> {
let mut bundles = BTreeMap::<String, (Option<usize>, Option<usize>, Vec<usize>)>::new();
for (entry_index, entry) in archive.entries.iter().enumerate() {
let Some((bundle_path, inner_path)) = split_textbundle_path(&entry.meta.path) else {
continue;
};
let bundle = bundles.entry(bundle_path.to_string()).or_default();
match inner_path {
"text.md" | "text.txt" => bundle.0 = Some(entry_index),
"info.json" => bundle.1 = Some(entry_index),
path if path.starts_with("assets/") => bundle.2.push(entry_index),
_ => {}
}
}
let mut bundle_refs = Vec::new();
for (bundle_path, (markdown, info, assets)) in bundles {
if let Some(markdown) = markdown {
bundle_refs.push(BearBundleRef {
bundle_path,
doc_id: nanoid!(),
markdown,
info,
assets,
});
}
}
if bundle_refs.is_empty() {
return Err(ImportError::InvalidSource(
"No valid Bear textbundles found in the archive".to_string(),
));
}
let total = bundle_refs.len();
let entry_id = bundle_refs.first().map(|bundle| bundle.doc_id.clone());
Ok(Self {
archive,
kind: ImportPlanKind::Bear { bundles: bundle_refs },
options,
limits,
next_doc: 0,
emitted_assets: BTreeSet::new(),
emitted_final: false,
completed: 0,
total,
entry_id,
is_workspace_file: false,
})
}
pub fn next_batch(&mut self) -> ImportResult<Option<ImportBatch>> {
if self.completed >= self.total && self.emitted_final {
return Ok(None);
}
if self.options.cancel_after_entries == Some(self.completed) {
return Err(ImportError::Cancelled);
}
let mut batch = empty_batch(self.total);
batch.entry_id = self.entry_id.clone();
batch.is_workspace_file = self.is_workspace_file;
let kind = self.kind.clone();
match kind {
ImportPlanKind::Markdown {
notion,
docs,
assets,
csv_entries_by_path,
page_ids_by_path,
blob_ids_by_path,
folders,
warnings,
} => {
let end = (self.next_doc + self.limits.max_docs.max(1)).min(docs.len());
for doc in &docs[self.next_doc..end] {
let entry = &self.archive.entries[doc.entry_index];
let bytes = self.archive.read_entry(entry)?;
let source_path = entry.meta.path.clone();
let markdown = String::from_utf8_lossy(&bytes).to_string();
let entry = entry_from_indexed(entry, bytes);
let prepared = if notion {
prepare_notion_markdown(&entry, &markdown)
} else {
prepare_markdown(&entry, &markdown)
};
let content = if notion {
rewrite_csv_links(
&prepared.content,
&source_path,
&csv_entries_by_path,
&self.archive,
&mut batch.warnings,
)?
} else {
prepared.content.clone()
};
let snapshot = match doc_snapshot(
&doc.doc_id,
&prepared.title,
&content,
&source_path,
&page_ids_by_path,
&blob_ids_by_path,
) {
Ok(snapshot) => snapshot,
Err(ImportError::Document(error)) => {
push_skipped_doc_warning(&mut batch, &source_path, error);
continue;
}
Err(error) => return Err(error),
};
batch.docs.push(ImportedDocSnapshot {
id: doc.doc_id.clone(),
snapshot,
meta: prepared.meta,
});
}
self.add_asset_batch(&mut batch, &assets)?;
if end == docs.len() && self.has_emitted_assets(&assets) {
batch.folders.extend(folders.clone());
batch.warnings.extend(warnings.clone());
self.emitted_final = true;
}
self.next_doc = end;
self.completed = end;
}
ImportPlanKind::NotionHtml {
docs,
assets,
page_ids_by_path,
blob_ids_by_path,
folders,
} => {
let end = (self.next_doc + self.limits.max_docs.max(1)).min(docs.len());
for doc in &docs[self.next_doc..end] {
let entry = &self.archive.entries[doc.entry_index];
let bytes = self.archive.read_entry(entry)?;
let html = String::from_utf8_lossy(&bytes);
let markdown = html_to_markdown(&html);
let title = doc
.title
.clone()
.unwrap_or_else(|| strip_extension(file_name(&entry.meta.path)).to_string());
let snapshot = match doc_snapshot(
&doc.doc_id,
&title,
&markdown,
&entry.meta.path,
&page_ids_by_path,
&blob_ids_by_path,
) {
Ok(snapshot) => snapshot,
Err(ImportError::Document(error)) => {
push_skipped_doc_warning(&mut batch, &entry.meta.path, error);
continue;
}
Err(error) => return Err(error),
};
batch.docs.push(ImportedDocSnapshot {
id: doc.doc_id.clone(),
snapshot,
meta: Some(ImportedDocMeta {
title: Some(title),
create_date: None,
updated_date: None,
tags: None,
favorite: None,
trash: None,
}),
});
if let Some(icon) = doc.icon.clone() {
batch.icons.push(ImportedIcon {
doc_id: doc.doc_id.clone(),
icon,
});
}
}
self.add_asset_batch(&mut batch, &assets)?;
if end == docs.len() && self.has_emitted_assets(&assets) {
batch.folders.extend(folders.clone());
self.emitted_final = true;
}
self.next_doc = end;
self.completed = end;
}
ImportPlanKind::Obsidian {
docs,
assets,
page_ids_by_path,
blob_ids_by_path,
} => {
let end = (self.next_doc + self.limits.max_docs.max(1)).min(docs.len());
let mut all_blobs = Vec::new();
for asset in &assets {
let entry = &self.archive.entries[asset.entry_index];
all_blobs.push(asset_from_indexed(&self.archive, entry, asset)?);
}
for doc in &docs[self.next_doc..end] {
let entry = &self.archive.entries[doc.entry_index];
let bytes = self.archive.read_entry(entry)?;
let markdown = String::from_utf8_lossy(&bytes);
let (content, frontmatter) = strip_frontmatter(&markdown);
let raw_title = frontmatter
.title
.clone()
.unwrap_or_else(|| strip_extension(file_name(&entry.meta.path)).to_string());
let (title, icon) = split_leading_emoji(&raw_title);
let content = rewrite_obsidian_wikilinks(&content);
let mut snapshot = match doc_snapshot(
&doc.doc_id,
&title,
&content,
&entry.meta.path,
&page_ids_by_path,
&blob_ids_by_path,
) {
Ok(snapshot) => snapshot,
Err(ImportError::Document(error)) => {
push_skipped_doc_warning(&mut batch, &entry.meta.path, error);
continue;
}
Err(error) => return Err(error),
};
rewrite_non_image_embeds_to_attachments(&mut snapshot, &all_blobs);
batch.docs.push(ImportedDocSnapshot {
id: doc.doc_id.clone(),
snapshot,
meta: frontmatter_meta(&frontmatter, &title),
});
if let Some(icon) = icon {
batch.icons.push(ImportedIcon {
doc_id: doc.doc_id.clone(),
icon: ImportedIconData {
kind: "emoji".to_string(),
unicode: icon,
},
});
}
batch
.folders
.extend(folders_for_doc_path(&entry.meta.path, &doc.doc_id, false, false, None));
}
self.add_asset_batch(&mut batch, &assets)?;
if end == docs.len() && self.has_emitted_assets(&assets) {
self.emitted_final = true;
}
self.next_doc = end;
self.completed = end;
}
ImportPlanKind::Bear { bundles } => {
let end = (self.next_doc + self.limits.max_docs.max(1)).min(bundles.len());
let mut tags = BTreeMap::<String, Vec<String>>::new();
for bundle in &bundles[self.next_doc..end] {
self.plan_bear_bundle(&mut batch, bundle, &mut tags)?;
}
if end == bundles.len() {
batch.tags = tags
.into_iter()
.map(|(name, doc_ids)| ImportedTag { name, doc_ids })
.collect();
self.emitted_final = true;
}
self.next_doc = end;
self.completed = end;
}
}
batch.progress = ImportProgress {
completed: self.completed,
total: self.total,
};
batch.done = self.completed >= self.total && self.emitted_final;
Ok(Some(batch))
}
fn add_asset_batch(&mut self, batch: &mut ImportBatch, assets: &[ImportAssetRef]) -> ImportResult<()> {
let mut bytes_in_batch = 0u64;
let max_blobs = self.limits.max_blobs.max(1);
for (asset_index, asset) in assets.iter().enumerate() {
if self.emitted_assets.contains(&asset_index) || batch.blobs.len() >= max_blobs {
continue;
}
let entry = &self.archive.entries[asset.entry_index];
if entry.meta.uncompressed_size > self.limits.max_blob_bytes {
batch.warnings.push(ImportWarning {
code: "skipped_asset".to_string(),
source_path: Some(entry.meta.path.clone()),
message: format!(
"Skipped {}: asset is larger than the batch import support",
entry.meta.path
),
});
self.emitted_assets.insert(asset_index);
continue;
}
if !batch.blobs.is_empty() && bytes_in_batch + entry.meta.uncompressed_size > self.limits.max_blob_bytes {
continue;
}
let imported = asset_from_indexed(&self.archive, entry, asset)?;
bytes_in_batch += entry.meta.uncompressed_size;
batch.blobs.push(imported);
self.emitted_assets.insert(asset_index);
}
Ok(())
}
fn has_emitted_assets(&self, assets: &[ImportAssetRef]) -> bool {
self.emitted_assets.len() >= assets.len()
}
fn plan_bear_bundle(
&self,
batch: &mut ImportBatch,
bundle: &BearBundleRef,
tags: &mut BTreeMap<String, Vec<String>>,
) -> ImportResult<()> {
let markdown_entry = &self.archive.entries[bundle.markdown];
let markdown_bytes = self.archive.read_entry(markdown_entry)?;
let info = if let Some(info_index) = bundle.info {
let info_entry = &self.archive.entries[info_index];
serde_json::from_slice::<JsonValue>(&self.archive.read_entry(info_entry)?).ok()
} else {
None
};
if info
.as_ref()
.and_then(|value| value.pointer("/net.shinyfrog.bear/trashed"))
.and_then(JsonValue::as_i64)
== Some(1)
{
return Ok(());
}
let raw_markdown = String::from_utf8_lossy(&markdown_bytes);
if raw_markdown.trim().is_empty() {
return Ok(());
}
let (tag_names, content) = parse_bear_tags(&raw_markdown);
for tag in &tag_names {
tags.entry(tag.clone()).or_default().push(bundle.doc_id.clone());
}
let title = first_h1(&content).unwrap_or_else(|| {
strip_extension(file_name(bundle.bundle_path.trim_end_matches('/')))
.trim_end_matches(".textbundle")
.to_string()
});
let mut blob_ids_by_path = BTreeMap::new();
for asset_index in &bundle.assets {
let entry = &self.archive.entries[*asset_index];
let bytes = self.archive.read_entry(entry)?;
let blob_id = hash_bytes(&bytes);
let imported = ImportedAsset {
blob_id: blob_id.clone(),
source_path: entry.meta.path.clone(),
file_name: file_name(&entry.meta.path).to_string(),
mime: mime_from_path(&entry.meta.path).to_string(),
bytes,
};
blob_ids_by_path.insert(entry.meta.path.clone(), blob_id.clone());
if let Some((_, relative)) = split_textbundle_path(&entry.meta.path) {
blob_ids_by_path.insert(relative.to_string(), blob_id);
}
batch.blobs.push(imported);
}
let content = convert_bear_markdown(&content);
let snapshot = doc_snapshot(
&bundle.doc_id,
&title,
&content,
&markdown_entry.meta.path,
&BTreeMap::new(),
&blob_ids_by_path,
)?;
batch.docs.push(ImportedDocSnapshot {
id: bundle.doc_id.clone(),
snapshot,
meta: Some(ImportedDocMeta {
title: Some(title),
create_date: bear_date(&info, "creationDate"),
updated_date: bear_date(&info, "modificationDate"),
tags: (!tag_names.is_empty()).then_some(tag_names),
favorite: None,
trash: None,
}),
});
batch.folders.extend(bear_folders(&bundle.doc_id, tags));
Ok(())
}
}
fn empty_batch(total: usize) -> ImportBatch {
ImportBatch {
docs: Vec::new(),
blobs: Vec::new(),
folders: Vec::new(),
tags: Vec::new(),
icons: Vec::new(),
warnings: Vec::new(),
progress: ImportProgress { completed: 0, total },
entry_id: None,
is_workspace_file: false,
done: false,
}
}
#[cfg(test)]
fn merge_batch(target: &mut ImportBatch, batch: ImportBatch) {
target.docs.extend(batch.docs);
target.blobs.extend(batch.blobs);
target.folders.extend(batch.folders);
target.tags.extend(batch.tags);
target.icons.extend(batch.icons);
target.warnings.extend(batch.warnings);
target.progress = batch.progress;
target.entry_id = target.entry_id.take().or(batch.entry_id);
target.is_workspace_file |= batch.is_workspace_file;
}
fn hash_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn entry_from_indexed(entry: &IndexedEntry, bytes: Vec<u8>) -> VfsEntry {
VfsEntry {
path: entry.meta.path.clone(),
bytes,
}
}
fn asset_from_indexed(
archive: &IndexedArchive,
entry: &IndexedEntry,
asset: &ImportAssetRef,
) -> ImportResult<ImportedAsset> {
Ok(ImportedAsset {
blob_id: asset.blob_id.clone(),
source_path: entry.meta.path.clone(),
file_name: file_name(&entry.meta.path).to_string(),
mime: mime_from_path(&entry.meta.path).to_string(),
bytes: archive.read_entry(entry)?,
})
}
fn markdown_folders(docs: &[ImportDocRef], archive: &IndexedArchive, notion: bool) -> Vec<FolderHierarchyDelta> {
let mut folders = Vec::new();
for doc in docs {
let entry = &archive.entries[doc.entry_index];
folders.extend(folders_for_doc_path(
&entry.meta.path,
&doc.doc_id,
notion,
notion,
None,
));
}
folders
}
fn notion_html_cursor_folders(docs: &[ImportDocRef], archive: &IndexedArchive) -> Vec<FolderHierarchyDelta> {
let doc_paths = docs
.iter()
.map(|doc| {
(
archive.entries[doc.entry_index].meta.path.clone(),
doc.doc_id.clone(),
doc.icon.clone(),
)
})
.collect::<Vec<_>>();
notion_html_folders(&doc_paths)
}
fn folders_for_doc_path(
path: &str,
doc_id: &str,
preserve_common_root: bool,
root_for_top_level_doc: bool,
icon: Option<ImportedIconData>,
) -> Vec<FolderHierarchyDelta> {
let mut folders = Vec::new();
let mut current_path = String::new();
for folder in folder_parts(path, preserve_common_root, root_for_top_level_doc) {
let parent_path = (!current_path.is_empty()).then(|| current_path.clone());
current_path = if current_path.is_empty() {
folder.clone()
} else {
format!("{current_path}/{folder}")
};
folders.push(FolderHierarchyDelta {
path: current_path.clone(),
name: if preserve_common_root {
strip_notion_hash(&folder)
} else {
folder
},
parent_path,
page_id: None,
icon: None,
});
}
if !current_path.is_empty() {
folders.push(FolderHierarchyDelta {
path: format!("{current_path}/__doc__{doc_id}"),
name: format!("__doc__{doc_id}"),
parent_path: Some(current_path),
page_id: Some(doc_id.to_string()),
icon,
});
}
folders
}
fn notion_html_folders(doc_paths: &[(String, String, Option<ImportedIconData>)]) -> Vec<FolderHierarchyDelta> {
let mut page_by_folder = BTreeMap::<String, (String, Option<ImportedIconData>)>::new();
for (path, doc_id, icon) in doc_paths {
page_by_folder.insert(
strip_extension(&normalize_import_path(path)).to_string(),
(doc_id.clone(), icon.clone()),
);
}
let mut folders = BTreeMap::<String, FolderHierarchyDelta>::new();
for (path, doc_id, icon) in doc_paths {
let normalized = normalize_import_path(path);
let mut parts = normalized.split('/').map(ToString::to_string).collect::<Vec<_>>();
let Some(file_name) = parts.pop() else {
continue;
};
let leaf_name = strip_notion_hash(strip_extension(&file_name));
let mut current_path = String::new();
for folder in parts {
let parent_path = (!current_path.is_empty()).then(|| current_path.clone());
current_path = if current_path.is_empty() {
folder.clone()
} else {
format!("{current_path}/{folder}")
};
let page = page_by_folder.get(¤t_path);
folders
.entry(current_path.clone())
.or_insert_with(|| FolderHierarchyDelta {
path: current_path.clone(),
name: strip_notion_hash(&folder),
parent_path,
page_id: page.map(|(page_id, _)| page_id.clone()),
icon: page.and_then(|(_, icon)| icon.clone()),
});
}
let parent_path = (!current_path.is_empty()).then(|| current_path.clone());
current_path = if current_path.is_empty() {
leaf_name.clone()
} else {
format!("{current_path}/{leaf_name}")
};
folders.insert(
current_path.clone(),
FolderHierarchyDelta {
path: current_path,
name: leaf_name,
parent_path,
page_id: Some(doc_id.clone()),
icon: icon.clone(),
},
);
}
folders.into_values().collect()
}
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\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))
}
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(" ", " ")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
}
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,
})
}
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
)
}
fn rewrite_obsidian_wikilinks(markdown: &str) -> String {
let mut output = String::with_capacity(markdown.len());
let mut rest = markdown;
while let Some(start) = rest.find("[[") {
let embed = start > 0 && rest.as_bytes()[start - 1] == b'!';
if embed {
output.push_str(&rest[..start - 1]);
} else {
output.push_str(&rest[..start]);
}
let after_start = &rest[start + 2..];
let Some(end) = after_start.find("]]") else {
output.push_str(&rest[start..]);
return output;
};
let target = &after_start[..end];
let (link, label) = target.split_once('|').unwrap_or((target, target));
if embed {
output.push_str(&format!(""));
} else {
output.push_str(&format!("[{label}]({link}.md)"));
}
rest = &after_start[end + 2..];
}
output.push_str(rest);
output
}
fn split_textbundle_path(path: &str) -> Option<(&str, &str)> {
let marker = ".textbundle/";
let index = path.to_lowercase().find(marker)?;
let split = index + ".textbundle".len();
Some((&path[..split], &path[split + 1..]))
}
fn parse_bear_tags(markdown: &str) -> (Vec<String>, String) {
let mut tags = BTreeSet::new();
let mut lines = Vec::new();
for line in markdown.lines() {
let trimmed = line.trim();
if let Some(tag) = trimmed
.strip_prefix('#')
.filter(|tag| !tag.starts_with(' ') && !tag.is_empty())
.map(|tag| tag.trim_end_matches('#').trim())
.filter(|tag| !tag.is_empty())
{
tags.insert(tag.to_string());
continue;
}
lines.push(line);
}
(tags.into_iter().collect(), lines.join("\n"))
}
fn first_h1(markdown: &str) -> Option<String> {
markdown.lines().find_map(|line| {
line
.strip_prefix("# ")
.map(str::trim)
.filter(|title| !title.is_empty())
.map(ToString::to_string)
})
}
fn convert_bear_markdown(markdown: &str) -> String {
markdown.replace("==", "")
}
fn bear_date(info: &Option<JsonValue>, key: &str) -> Option<i64> {
info
.as_ref()
.and_then(|value| value.pointer(&format!("/net.shinyfrog.bear/{key}")))
.and_then(JsonValue::as_str)
.and_then(parse_date_millis)
}
fn bear_folders(doc_id: &str, tags: &BTreeMap<String, Vec<String>>) -> Vec<FolderHierarchyDelta> {
let mut folders = Vec::new();
for tag in tags.keys() {
let mut current_path = String::new();
for part in tag.split('/').filter(|part| !part.is_empty()) {
let parent_path = (!current_path.is_empty()).then(|| current_path.clone());
current_path = if current_path.is_empty() {
part.to_string()
} else {
format!("{current_path}/{part}")
};
folders.push(FolderHierarchyDelta {
path: current_path.clone(),
name: part.to_string(),
parent_path,
page_id: None,
icon: None,
});
}
if !current_path.is_empty() {
folders.push(FolderHierarchyDelta {
path: format!("{current_path}/__doc__{doc_id}"),
name: format!("__doc__{doc_id}"),
parent_path: Some(current_path),
page_id: Some(doc_id.to_string()),
icon: None,
});
}
}
folders
}
struct PreparedMarkdown {
title: String,
content: String,
meta: Option<ImportedDocMeta>,
}
fn prepare_markdown(entry: &VfsEntry, markdown: &str) -> PreparedMarkdown {
let (content, frontmatter) = strip_frontmatter(markdown);
let title = frontmatter
.title
.clone()
.unwrap_or_else(|| strip_extension(file_name(&entry.path)).to_string());
PreparedMarkdown {
meta: frontmatter_meta(&frontmatter, &title),
title,
content,
}
}
fn prepare_notion_markdown(entry: &VfsEntry, markdown: &str) -> PreparedMarkdown {
let (content, frontmatter) = strip_frontmatter(markdown);
let content = strip_notion_reference_footer(&content);
if let Some((title, content)) = content.strip_prefix("# ").and_then(|rest| {
let (title, body) = rest.split_once('\n').unwrap_or((rest, ""));
let title = title.trim();
(!title.is_empty()).then(|| (title.to_string(), body.to_string()))
}) {
return PreparedMarkdown {
meta: frontmatter_meta(&frontmatter, &title),
title,
content,
};
}
let title = frontmatter
.title
.clone()
.unwrap_or_else(|| strip_notion_hash(strip_extension(file_name(&entry.path))));
PreparedMarkdown {
meta: frontmatter_meta(&frontmatter, &title),
title,
content,
}
}
fn strip_notion_reference_footer(markdown: &str) -> String {
let lines = markdown.lines().collect::<Vec<_>>();
let Some(separator) = lines.iter().rposition(|line| line.trim() == "---") else {
return markdown.to_string();
};
let footer = lines[separator + 1..].join("\n");
if !looks_like_notion_reference_footer(&footer) {
return markdown.to_string();
}
lines[..separator].join("\n").trim_end().to_string()
}
fn looks_like_notion_reference_footer(footer: &str) -> bool {
let mut non_empty = 0usize;
let mut link_lines = 0usize;
let mut csv_links = 0usize;
let mut page_links = 0usize;
for line in footer.lines().map(str::trim).filter(|line| !line.is_empty()) {
non_empty += 1;
let Some((_, target, consumed)) = parse_link_span(line) else {
continue;
};
let rest = line[consumed..].trim();
let target = target.to_ascii_lowercase();
if rest.is_empty() {
link_lines += 1;
if target.ends_with(".csv") {
csv_links += 1;
} else if target.ends_with(".md") {
page_links += 1;
}
}
}
non_empty >= 4 && link_lines >= 3 && page_links + csv_links >= 3 && link_lines * 4 >= non_empty * 3
}
fn rewrite_csv_links(
markdown: &str,
current_path: &str,
csv_entries_by_path: &BTreeMap<String, usize>,
archive: &IndexedArchive,
warnings: &mut Vec<ImportWarning>,
) -> ImportResult<String> {
let mut output = Vec::new();
for line in markdown.lines() {
let trimmed = line.trim();
let Some((label, target, consumed)) = parse_link_span(trimmed) else {
output.push(line.to_string());
continue;
};
if consumed != trimmed.len() {
output.push(line.to_string());
continue;
}
let resolved = resolve_path(current_path, &target);
if !resolved.to_ascii_lowercase().ends_with(".csv") {
output.push(line.to_string());
continue;
}
let Some(entry_index) = csv_entries_by_path.get(&resolved) else {
output.push(line.to_string());
continue;
};
let entry = &archive.entries[*entry_index];
let bytes = archive.read_entry(entry)?;
let csv = String::from_utf8_lossy(&bytes);
let table = match csv_to_markdown_table(&csv) {
Some(table) => table,
None => {
warnings.push(ImportWarning {
code: "skipped_csv_table".to_string(),
source_path: Some(entry.meta.path.clone()),
message: format!("Skipped CSV table with no rows: {}", entry.meta.path),
});
continue;
}
};
let label = label.trim();
if !label.is_empty() {
output.push(format!("**{}**", escape_markdown_cell(label)));
output.push(String::new());
}
output.extend(table.lines().map(ToString::to_string));
}
Ok(output.join("\n"))
}
fn csv_to_markdown_table(csv: &str) -> Option<String> {
let mut rows = parse_csv_rows(csv);
rows.retain(|row| row.iter().any(|cell| !cell.trim().is_empty()));
if rows.is_empty() {
return None;
}
let width = rows.iter().map(Vec::len).max().unwrap_or(0).max(1);
for row in &mut rows {
row.resize(width, String::new());
}
let mut table = String::new();
table.push_str(&markdown_table_row(&rows[0]));
table.push('\n');
table.push_str(&markdown_table_row(&vec!["---".to_string(); width]));
for row in rows.iter().skip(1) {
table.push('\n');
table.push_str(&markdown_table_row(row));
}
Some(table)
}
fn markdown_table_row(row: &[String]) -> String {
format!(
"| {} |",
row
.iter()
.map(|cell| escape_markdown_cell(cell))
.collect::<Vec<_>>()
.join(" | ")
)
}
fn escape_markdown_cell(cell: &str) -> String {
cell
.replace('\\', "\\\\")
.replace('|', "\\|")
.replace('\r', " ")
.replace('\n', " ")
}
fn parse_csv_rows(csv: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
let mut row = Vec::new();
let mut cell = String::new();
let mut chars = csv.chars().peekable();
let mut quoted = false;
while let Some(ch) = chars.next() {
match ch {
'"' if quoted && chars.peek() == Some(&'"') => {
cell.push('"');
chars.next();
}
'"' => quoted = !quoted,
',' if !quoted => {
row.push(std::mem::take(&mut cell));
}
'\n' if !quoted => {
row.push(std::mem::take(&mut cell));
rows.push(std::mem::take(&mut row));
}
'\r' if !quoted => {
if chars.peek() == Some(&'\n') {
chars.next();
}
row.push(std::mem::take(&mut cell));
rows.push(std::mem::take(&mut row));
}
_ => cell.push(ch),
}
}
if !cell.is_empty() || !row.is_empty() {
row.push(cell);
rows.push(row);
}
rows
}
fn doc_snapshot(
doc_id: &str,
title: &str,
markdown: &str,
current_path: &str,
page_ids_by_path: &BTreeMap<String, String>,
blob_ids_by_path: &BTreeMap<String, String>,
) -> ImportResult<serde_json::Value> {
let rewritten = rewrite_markdown_link_destinations(markdown, current_path, page_ids_by_path);
let rewritten = rewrite_bare_page_references(&rewritten, current_path, page_ids_by_path);
let rewritten = rewrite_asset_references(&rewritten, current_path, blob_ids_by_path);
let mut snapshot = affine_doc_loader::build_doc_snapshot(title, &rewritten, doc_id)?;
rewrite_page_references(&mut snapshot, current_path, page_ids_by_path);
Ok(snapshot)
}
fn push_skipped_doc_warning(batch: &mut ImportBatch, source_path: &str, error: affine_doc_loader::ParseError) {
batch.warnings.push(ImportWarning {
code: "skipped_doc".to_string(),
source_path: Some(source_path.to_string()),
message: format!("Skipped {source_path}: {error}"),
});
}
fn folder_parts(path: &str, preserve_common_root: bool, root_for_top_level_doc: bool) -> Vec<String> {
let mut parts = normalize_import_path(path)
.split('/')
.map(ToString::to_string)
.collect::<Vec<_>>();
let file_name = parts.pop();
if !preserve_common_root && parts.len() > 1 {
parts.remove(0);
}
if parts.is_empty()
&& root_for_top_level_doc
&& let Some(file_name) = file_name
{
parts.push(strip_notion_hash(strip_extension(&file_name)));
}
parts
}
fn rewrite_asset_references(markdown: &str, current_path: &str, blob_ids_by_path: &BTreeMap<String, String>) -> String {
markdown
.lines()
.map(|line| rewrite_line_asset_references(line, current_path, blob_ids_by_path))
.collect::<Vec<_>>()
.join("\n")
}
fn rewrite_markdown_link_destinations(
markdown: &str,
current_path: &str,
page_ids_by_path: &BTreeMap<String, String>,
) -> String {
markdown
.lines()
.map(|line| rewrite_line_link_destinations(line, current_path, page_ids_by_path))
.collect::<Vec<_>>()
.join("\n")
}
fn rewrite_line_link_destinations(
line: &str,
current_path: &str,
page_ids_by_path: &BTreeMap<String, String>,
) -> String {
let mut output = String::with_capacity(line.len());
let mut rest = line;
loop {
let Some(start) = rest.find('[') else {
output.push_str(rest);
break;
};
output.push_str(&rest[..start]);
if start > 0 && rest.as_bytes().get(start - 1) == Some(&b'!') {
output.push('[');
rest = &rest[start + 1..];
continue;
}
let link = &rest[start..];
let Some((label, target, consumed)) = parse_link_span(link) else {
output.push('[');
rest = &rest[start + 1..];
continue;
};
if page_ids_by_path.contains_key(&resolve_path(current_path, &target)) {
output.push_str(&format!("[{label}](<{target}>)"));
} else {
output.push_str(&link[..consumed]);
}
rest = &link[consumed..];
}
output
}
fn rewrite_bare_page_references(
markdown: &str,
current_path: &str,
page_ids_by_path: &BTreeMap<String, String>,
) -> String {
markdown
.lines()
.map(|line| rewrite_line_bare_page_references(line, current_path, page_ids_by_path))
.collect::<Vec<_>>()
.join("\n")
}
fn rewrite_line_bare_page_references(
line: &str,
current_path: &str,
page_ids_by_path: &BTreeMap<String, String>,
) -> String {
let mut output = String::with_capacity(line.len());
let mut rest = line;
while let Some(open) = rest.find('(') {
let Some(close) = rest[open + 1..].find(')').map(|index| open + 1 + index) else {
break;
};
let target = &rest[open + 1..close];
if !target.to_ascii_lowercase().ends_with(".md")
|| !page_ids_by_path.contains_key(&resolve_path(current_path, target))
{
output.push_str(&rest[..=close]);
rest = &rest[close + 1..];
continue;
}
if open > 0 && rest.as_bytes().get(open - 1) == Some(&b']') {
output.push_str(&rest[..=close]);
rest = &rest[close + 1..];
continue;
}
let label_start = bare_reference_label_start(&rest[..open]);
let label = rest[label_start..open].trim();
if label.is_empty() {
output.push_str(&rest[..=close]);
rest = &rest[close + 1..];
continue;
}
output.push_str(&rest[..label_start]);
output.push_str(&format!("[{label}](<{target}>)"));
rest = &rest[close + 1..];
}
output.push_str(rest);
output
}
fn bare_reference_label_start(prefix: &str) -> usize {
[", ", ": "]
.iter()
.filter_map(|delimiter| prefix.rfind(delimiter).map(|index| index + delimiter.len()))
.max()
.unwrap_or(0)
}
fn rewrite_line_asset_references(
line: &str,
current_path: &str,
blob_ids_by_path: &BTreeMap<String, String>,
) -> String {
let mut output = String::with_capacity(line.len());
let mut rest = line;
loop {
let Some(start) = rest.find("![") else {
output.push_str(rest);
break;
};
output.push_str(&rest[..start]);
let image = &rest[start..];
let Some((alt, target, consumed)) = parse_image_span(image) else {
output.push_str(&rest[start..start + 2]);
rest = &rest[start + 2..];
continue;
};
if let Some(source_id) = blob_ids_by_path.get(&resolve_path(current_path, &target)) {
output.push_str(&format!(""));
} else {
output.push_str(&image[..consumed]);
}
rest = &image[consumed..];
}
output
}
fn parse_image_span(value: &str) -> Option<(String, String, usize)> {
if value.starts_with("![") {
return parse_link_like_span(value, 2);
}
None
}
fn parse_link_span(value: &str) -> Option<(String, String, usize)> {
if value.starts_with('[') {
return parse_link_like_span(value, 1);
}
None
}
fn parse_link_like_span(value: &str, label_start: usize) -> Option<(String, String, usize)> {
let bytes = value.as_bytes();
let mut index = label_start;
let mut escaped = false;
while index < bytes.len() {
let byte = bytes[index];
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b']' {
break;
}
index += 1;
}
if index >= bytes.len() || bytes.get(index + 1) != Some(&b'(') {
return None;
}
let label = value[label_start..index].to_string();
index += 2;
let target_start = index;
let mut depth = 0usize;
let mut escaped = false;
let mut last_close = None;
while index < bytes.len() {
let byte = bytes[index];
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'(' {
depth += 1;
} else if byte == b')' {
last_close = Some(index);
if depth == 0 {
let target = value[target_start..index].to_string();
return Some((label, target, index + 1));
}
depth -= 1;
}
index += 1;
}
let close = last_close?;
let target = value[target_start..close].to_string();
Some((label, target, close + 1))
}
fn rewrite_page_references(value: &mut JsonValue, current_path: &str, page_ids_by_path: &BTreeMap<String, String>) {
match value {
JsonValue::Object(map) => {
rewrite_delta_reference(map, current_path, page_ids_by_path);
for value in map.values_mut() {
rewrite_page_references(value, current_path, page_ids_by_path);
}
}
JsonValue::Array(values) => {
for value in values {
rewrite_page_references(value, current_path, page_ids_by_path);
}
}
_ => {}
}
}
fn rewrite_non_image_embeds_to_attachments(value: &mut JsonValue, blobs: &[ImportedAsset]) {
let blob_by_id = blobs
.iter()
.map(|blob| (blob.blob_id.as_str(), blob))
.collect::<BTreeMap<_, _>>();
rewrite_attachment_blocks(value, &blob_by_id);
}
fn rewrite_attachment_blocks(value: &mut JsonValue, blob_by_id: &BTreeMap<&str, &ImportedAsset>) {
match value {
JsonValue::Object(map) => {
let attachment = map
.get("flavour")
.and_then(JsonValue::as_str)
.filter(|flavour| *flavour == "affine:image")
.and_then(|_| map.get("props"))
.and_then(JsonValue::as_object)
.and_then(|props| props.get("sourceId"))
.and_then(JsonValue::as_str)
.and_then(|source_id| blob_by_id.get(source_id))
.filter(|blob| !blob.mime.starts_with("image/"));
if let Some(blob) = attachment {
map.insert(
"flavour".to_string(),
JsonValue::String("affine:attachment".to_string()),
);
if let Some(props) = map.get_mut("props").and_then(JsonValue::as_object_mut) {
props.insert("name".to_string(), JsonValue::String(blob.file_name.clone()));
props.insert("size".to_string(), JsonValue::Number(blob.bytes.len().into()));
props.insert("type".to_string(), JsonValue::String(blob.mime.clone()));
props.insert("embed".to_string(), JsonValue::Bool(false));
props.insert("style".to_string(), JsonValue::String("horizontalThin".to_string()));
props.insert("footnoteIdentifier".to_string(), JsonValue::Null);
}
}
for value in map.values_mut() {
rewrite_attachment_blocks(value, blob_by_id);
}
}
JsonValue::Array(values) => {
for value in values {
rewrite_attachment_blocks(value, blob_by_id);
}
}
_ => {}
}
}
fn rewrite_delta_reference(
map: &mut JsonMap<String, JsonValue>,
current_path: &str,
page_ids_by_path: &BTreeMap<String, String>,
) {
let title = map.get("insert").and_then(JsonValue::as_str).unwrap_or("").to_string();
let Some(attributes) = map.get_mut("attributes").and_then(JsonValue::as_object_mut) else {
return;
};
let Some(target) = attributes
.get("link")
.and_then(JsonValue::as_str)
.map(ToString::to_string)
else {
return;
};
let resolved = resolve_path(current_path, &target);
let Some(page_id) = page_ids_by_path.get(&resolved) else {
return;
};
attributes.remove("link");
attributes.insert(
"reference".to_string(),
serde_json::json!({
"type": "LinkedPage",
"pageId": page_id,
"title": title,
}),
);
map.insert("insert".to_string(), JsonValue::String(" ".to_string()));
}
fn resolve_path(current_path: &str, target: &str) -> String {
let target = target.trim().trim_start_matches('<').trim_end_matches('>');
if target.contains("://") {
return target.to_string();
}
let target = percent_decode(target.split(['?', '#']).next().unwrap_or(target));
let base = current_path.rsplit_once('/').map(|(base, _)| base).unwrap_or("");
normalize_import_path(&format!("{base}/{target}"))
}
fn register_page_path(page_ids_by_path: &mut BTreeMap<String, String>, path: &str, doc_id: &str) {
let path = normalize_import_path(path);
page_ids_by_path.insert(path.clone(), doc_id.to_string());
page_ids_by_path.insert(strip_extension(&path).to_string(), doc_id.to_string());
}
fn register_csv_path(csv_entries_by_path: &mut BTreeMap<String, usize>, path: &str, entry_index: usize) {
let path = normalize_import_path(path);
csv_entries_by_path.insert(path, entry_index);
}
#[derive(Default)]
struct Frontmatter {
title: Option<String>,
create_date: Option<i64>,
updated_date: Option<i64>,
tags: Option<Vec<String>>,
favorite: Option<bool>,
trash: Option<bool>,
}
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))
}
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 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)
}
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_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('\'')
}
fn percent_decode(value: &str) -> String {
let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& index + 2 < bytes.len()
&& let (Some(high), Some(low)) = (hex_value(bytes[index + 1]), hex_value(bytes[index + 2]))
{
output.push(high * 16 + low);
index += 3;
continue;
}
output.push(bytes[index]);
index += 1;
}
String::from_utf8(output).unwrap_or_else(|_| value.to_string())
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn file_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
fn strip_extension(value: &str) -> &str {
value.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(value)
}
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())
}
fn mime_from_path(path: &str) -> &'static str {
match path.rsplit('.').next().unwrap_or("").to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"zip" => "application/zip",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use std::{
fs,
io::{Cursor, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use zip::{ZipWriter, write::SimpleFileOptions};
use super::*;
static NEXT_TEST_DIR_ID: AtomicU64 = AtomicU64::new(1);
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut cursor = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut cursor);
for (path, bytes) in entries {
writer.start_file(path, SimpleFileOptions::default()).unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap();
}
cursor.into_inner()
}
fn temp_dir() -> PathBuf {
let path = std::env::temp_dir().join(format!(
"affine-importer-dir-{}-{}",
std::process::id(),
NEXT_TEST_DIR_ID.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
path
}
fn write_file(root: &Path, path: &str, bytes: &[u8]) {
let path = root.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
#[test]
fn markdown_zip_plans_docs_assets_folders_and_progress() {
let bytes = zip(&[
("Export/entry.md", b"entry body"),
("Export/folder/target.md", b"target body"),
("Export/assets/logo.png", &[137, 80, 78, 71]),
]);
let batch = plan_import(bytes, ImportFormat::MarkdownZip).unwrap();
assert_eq!(batch.docs.len(), 2);
assert_eq!(batch.blobs.len(), 1);
assert_eq!(batch.progress.completed, 2);
assert!(batch.done);
assert!(
batch
.docs
.iter()
.any(|doc| doc.snapshot["meta"]["title"] == "entry" && doc.snapshot["type"] == "page")
);
assert!(batch.folders.iter().any(|folder| folder.path == "folder"));
}
#[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_import(bytes, ImportFormat::NotionMarkdownZip).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_import(bytes, ImportFormat::NotionMarkdownZip).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_import(bytes, ImportFormat::NotionZip).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_import(bytes, ImportFormat::NotionHtmlZip).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_import(bytes, ImportFormat::NotionZip).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_import(bytes, ImportFormat::NotionZip);
assert!(matches!(result, Err(ImportError::InvalidSource(_))));
}
#[test]
fn markdown_cursor_drains_assets_after_final_doc_batch() {
let bytes = zip(&[
("entry.md", b"\n"),
("a.png", &[1]),
("b.png", &[2]),
]);
let mut cursor = ImportPlanner::create_cursor(
ZipBytesSource::new(bytes),
ImportFormat::MarkdownZip,
ImportOptions::default(),
ImportBatchLimits {
max_docs: 1,
max_blobs: 1,
max_blob_bytes: u64::MAX,
},
)
.unwrap();
let first = cursor.next_batch().unwrap().unwrap();
let second = cursor.next_batch().unwrap().unwrap();
assert_eq!(first.docs.len(), 1);
assert_eq!(first.blobs.len(), 1);
assert!(!first.done);
assert_eq!(second.docs.len(), 0);
assert_eq!(second.blobs.len(), 1);
assert!(second.done);
assert!(cursor.next_batch().unwrap().is_none());
}
#[test]
fn obsidian_zip_plans_wikilinks_assets_and_title_emoji() {
let bytes = zip(&[
(
"Vault/😀 Entry.md",
b"[[Target]]\n![[archive.zip]]\n",
),
("Vault/Target.md", b"target"),
("Vault/assets/logo.png", &[137, 80, 78, 71]),
("Vault/archive.zip", b"zip"),
]);
let batch = plan_import(bytes, ImportFormat::Obsidian).unwrap();
let snapshot = serde_json::to_string(&batch.docs[0].snapshot).unwrap();
assert_eq!(batch.docs.len(), 2);
assert_eq!(batch.blobs.len(), 2);
assert_eq!(batch.icons[0].icon.unicode, "😀");
assert!(snapshot.contains("\"reference\""));
assert!(snapshot.contains("\"sourceId\""));
assert!(snapshot.contains("\"flavour\":\"affine:attachment\""));
assert!(snapshot.contains("archive.zip"));
}
#[test]
fn obsidian_directory_source_plans_without_zipping_in_memory() {
let root = temp_dir();
write_file(&root, "Vault/Entry.md", b"[[Target]]\n");
write_file(&root, "Vault/Target.md", b"target");
write_file(&root, "Vault/assets/logo.png", &[137, 80, 78, 71]);
let batch = ImportPlanner::create_cursor(
crate::DirectoryPathSource::new(root.clone()),
ImportFormat::Obsidian,
ImportOptions::default(),
ImportBatchLimits {
max_docs: usize::MAX,
max_blobs: usize::MAX,
max_blob_bytes: u64::MAX,
},
)
.and_then(|mut cursor| {
let mut merged = empty_batch(0);
while let Some(batch) = cursor.next_batch()? {
merge_batch(&mut merged, batch);
}
Ok(merged)
})
.unwrap();
assert_eq!(batch.docs.len(), 2);
assert_eq!(batch.blobs.len(), 1);
let snapshot = serde_json::to_string(&batch.docs[0].snapshot).unwrap();
assert!(snapshot.contains("\"reference\""));
assert!(snapshot.contains("\"sourceId\""));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn bear_zip_plans_textbundle_meta_tags_and_assets() {
let bytes = zip(&[
(
"Notes/Idea.textbundle/text.md",
b"# Bear Title\nbody\n\n\n\n==green highlight==\n\n#work/project\n#Blue Tag#",
),
(
"Notes/Idea.textbundle/info.json",
br#"{"net.shinyfrog.bear":{"creationDate":"2018-04-12T09:51:00.000Z","modificationDate":"2018-04-12T10:00:00.000Z"}}"#,
),
("Notes/Idea.textbundle/assets/photo.png", &[137, 80, 78, 71]),
]);
let batch = plan_import(bytes, ImportFormat::BearZip).unwrap();
let snapshot = serde_json::to_string(&batch.docs[0].snapshot).unwrap();
assert_eq!(batch.docs[0].snapshot["meta"]["title"], "Bear Title");
assert_eq!(batch.docs[0].meta.as_ref().unwrap().create_date, Some(1523526660000));
assert_eq!(
batch.tags.iter().map(|tag| tag.name.as_str()).collect::<Vec<_>>(),
["Blue Tag", "work/project"]
);
assert!(snapshot.contains("\"sourceId\""));
assert!(snapshot.contains("green highlight"));
}
#[test]
fn cancel_stops_before_reading_source() {
let bytes = zip(&[("entry.md", b"entry")]);
let result = ImportPlanner::create_cursor(
ZipBytesSource::new(bytes),
ImportFormat::MarkdownZip,
ImportOptions {
cancel: true,
cancel_after_entries: None,
},
ImportBatchLimits::default(),
);
assert!(matches!(result, Err(ImportError::Cancelled)));
}
#[test]
fn markdown_zip_resolves_links_assets_and_frontmatter() {
let bytes = zip(&[
(
"entry.md",
b"---\ntitle: Frontmatter Entry\ncreateDate: 2018-04-12T09:51:00.000Z\nupdatedDate: 2018-04-12T10:00:00.000Z\ntags: [a, b]\nfavorite: true\n---\naaa [go](./folder/target.md) ccc\n\n\n| A | B |\n| --- | --- |\n| 1 | 2 |",
),
("folder/target.md", b"target"),
("assets/logo.png", &[137, 80, 78, 71]),
]);
let batch = plan_import(bytes, ImportFormat::MarkdownZip).unwrap();
let entry = batch
.docs
.iter()
.find(|doc| doc.snapshot["meta"]["title"] == "Frontmatter Entry")
.unwrap();
let snapshot = serde_json::to_string(&entry.snapshot).unwrap();
assert!(snapshot.contains("\"reference\""));
assert!(snapshot.contains("\"insert\":\"aaa \""));
assert!(snapshot.contains("\"title\":\"go\""));
assert!(snapshot.contains("\"insert\":\" \""));
assert!(snapshot.contains("\"insert\":\" ccc\""));
assert!(snapshot.contains("\"sourceId\""));
assert!(snapshot.contains("\"affine:table\""));
assert_eq!(entry.meta.as_ref().unwrap().create_date, Some(1523526660000));
assert_eq!(entry.meta.as_ref().unwrap().updated_date, Some(1523527200000));
assert_eq!(
entry.meta.as_ref().unwrap().tags.as_ref().unwrap(),
&vec!["a".to_string(), "b".to_string()]
);
assert_eq!(entry.meta.as_ref().unwrap().favorite, Some(true));
}
#[test]
fn markdown_zip_resolves_parent_directory_refs() {
let bytes = zip(&[
("folder/entry.md", b"[root](../target.md)\n"),
("target.md", b"target"),
("assets/logo.png", &[137, 80, 78, 71]),
]);
let batch = plan_import(bytes, ImportFormat::MarkdownZip).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("\"sourceId\""));
}
#[test]
fn markdown_zip_resolves_link_paths_with_parentheses() {
let bytes = zip(&[
(
"Entry.md",
b"[Linked item](Folder%20(Archive/Linked%20item%2011111111111111111111111111111111.md)",
),
(
"Folder (Archive/Linked item 11111111111111111111111111111111.md",
b"target",
),
]);
let batch = plan_import(bytes, ImportFormat::MarkdownZip).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("\"insert\":\" \""));
assert!(!snapshot.contains("\"insert\":\"Linked item\""));
assert!(!snapshot.contains("Folder%20"));
}
#[test]
fn markdown_zip_resolves_bare_relation_references() {
let bytes = zip(&[
(
"Objectives/Entry.md",
b"Related: Alpha task (../Targets/Alpha%20task%2011111111111111111111111111111111.md), Beta task (../Targets/Beta%20task%2022222222222222222222222222222222.md)",
),
(
"Targets/Alpha task 11111111111111111111111111111111.md",
b"target",
),
(
"Targets/Beta task 22222222222222222222222222222222.md",
b"target",
),
]);
let batch = plan_import(bytes, ImportFormat::MarkdownZip).unwrap();
let entry = batch
.docs
.iter()
.find(|doc| doc.snapshot["meta"]["title"] == "Entry")
.unwrap();
let snapshot = serde_json::to_string(&entry.snapshot).unwrap();
assert_eq!(snapshot.matches("\"reference\"").count(), 2);
assert!(!snapshot.contains("../Targets"));
}
#[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_import(bytes, ImportFormat::NotionMarkdownZip).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_import(bytes, ImportFormat::NotionMarkdownZip).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_import(bytes, ImportFormat::NotionMarkdownZip).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"));
}
#[test]
fn markdown_zip_resolves_asset_paths_with_parentheses() {
let bytes = zip(&[
("Export/Page.md", b"Before %20abc.png) after"),
("Export/Page/image (1) abc.png", &[137, 80, 78, 71]),
]);
let batch = plan_import(bytes, ImportFormat::MarkdownZip).unwrap();
let snapshot = serde_json::to_string(&batch.docs[0].snapshot).unwrap();
assert_eq!(batch.blobs.len(), 1);
assert!(snapshot.contains("\"sourceId\""));
}
#[test]
fn markdown_zip_skips_too_large_doc_with_warning() {
let markdown = "a".repeat(1_001_000);
let bytes = zip(&[("large.md", markdown.as_bytes())]);
let batch = plan_import(bytes, ImportFormat::MarkdownZip).unwrap();
assert!(batch.docs.is_empty());
assert_eq!(batch.warnings.len(), 1);
assert_eq!(batch.warnings[0].code, "skipped_doc");
assert_eq!(batch.warnings[0].source_path.as_deref(), Some("large.md"));
}
#[test]
fn markdown_zip_skips_too_large_asset_with_warning() {
let bytes = zip(&[("entry.md", b""), ("logo.png", &[1, 2, 3, 4])]);
let mut cursor = ImportPlanner::create_cursor(
ZipBytesSource::new(bytes),
ImportFormat::MarkdownZip,
ImportOptions::default(),
ImportBatchLimits {
max_docs: usize::MAX,
max_blobs: usize::MAX,
max_blob_bytes: 2,
},
)
.unwrap();
let mut merged = empty_batch(0);
while let Some(batch) = cursor.next_batch().unwrap() {
merge_batch(&mut merged, batch);
}
assert!(merged.blobs.is_empty());
assert_eq!(merged.warnings.len(), 1);
assert_eq!(merged.warnings[0].code, "skipped_asset");
assert_eq!(merged.warnings[0].source_path.as_deref(), Some("logo.png"));
}
#[test]
fn markdown_zip_zero_blob_limit_still_drains_assets() {
let bytes = zip(&[("entry.md", b""), ("logo.png", &[1, 2, 3, 4])]);
let mut cursor = ImportPlanner::create_cursor(
ZipBytesSource::new(bytes),
ImportFormat::MarkdownZip,
ImportOptions::default(),
ImportBatchLimits {
max_docs: 1,
max_blobs: 0,
max_blob_bytes: u64::MAX,
},
)
.unwrap();
let mut merged = empty_batch(0);
let mut batch_count = 0;
let mut done = false;
while let Some(batch) = cursor.next_batch().unwrap() {
batch_count += 1;
done = batch.done;
merge_batch(&mut merged, batch);
assert!(batch_count <= 3);
}
assert_eq!(merged.docs.len(), 1);
assert_eq!(merged.blobs.len(), 1);
assert!(done);
}
#[test]
fn cancel_stops_during_planning() {
let bytes = zip(&[("a.md", b"a"), ("b.md", b"b")]);
let mut cursor = ImportPlanner::create_cursor(
ZipBytesSource::new(bytes),
ImportFormat::MarkdownZip,
ImportOptions {
cancel: false,
cancel_after_entries: Some(1),
},
ImportBatchLimits {
max_docs: 1,
max_blobs: usize::MAX,
max_blob_bytes: u64::MAX,
},
)
.unwrap();
assert!(cursor.next_batch().unwrap().is_some());
assert!(matches!(cursor.next_batch(), Err(ImportError::Cancelled)));
}
#[test]
fn vfs_rejects_parent_segments() {
let mut vfs = Vfs::default();
let result = vfs.insert("../escape.md", b"bad".to_vec());
assert!(matches!(result, Err(ImportError::InvalidSource(_))));
}
}