From a278bb34ec49ab382d9b3dcece4bfe0c255c2b1f Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 00:54:24 -0700 Subject: [PATCH 01/18] Expand Notion cyclic e2e coverage --- .github/workflows/notion-live-e2e.yml | 2 +- crates/afs-cli/tests/e2e_push_workflow.rs | 482 +++++++++++++++++++++- crates/afs-core/src/diff.rs | 43 +- crates/afs-core/tests/block_diff.rs | 49 ++- crates/afs-notion/src/apply.rs | 73 +++- crates/afs-notion/src/render.rs | 50 ++- crates/afs-notion/tests/apply.rs | 19 +- crates/afs-notion/tests/fetch_render.rs | 8 +- crates/afs-notion/tests/live_integrity.rs | 7 +- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 4 +- docs/notion-cyclic-bug-journal.md | 47 +++ docs/notion-cyclic-support-journal.md | 33 ++ docs/notion-object-support.md | 6 +- 14 files changed, 785 insertions(+), 40 deletions(-) create mode 100644 docs/notion-cyclic-bug-journal.md create mode 100644 docs/notion-cyclic-support-journal.md diff --git a/.github/workflows/notion-live-e2e.yml b/.github/workflows/notion-live-e2e.yml index 7a4bc5b..dfa86dd 100644 --- a/.github/workflows/notion-live-e2e.yml +++ b/.github/workflows/notion-live-e2e.yml @@ -30,4 +30,4 @@ jobs: - name: Run connector live integrity tests run: cargo test -p afs-notion --test live_integrity -- --ignored --test-threads=1 - name: Run mounted workflow live e2e - run: cargo test -p afs-cli --test e2e_push_workflow live_scratch_page_mount_edit_push_verifies_notion -- --ignored --exact --test-threads=1 + run: cargo test -p afs-cli --test e2e_push_workflow live_ -- --ignored --test-threads=1 diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index f30ffba..c89c1e0 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -14,8 +14,9 @@ use afs_connector::{Connector, FetchRequest}; use afs_core::model::{MountId, RemoteId}; use afs_notion::client::{HttpNotionApi, NotionApi}; use afs_notion::dto::{ - BlockDto, BlockListDto, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, - RichTextBlockDto, RichTextDto, SyncedBlockDto, SyncedFromDto, TextRichTextDto, + BlockDto, BlockListDto, NotionPageBundle, PageDto, PageListDto, PagePropertyDto, + PaginatedListDto, RichTextBlockDto, RichTextDto, SyncedBlockDto, SyncedFromDto, + TextRichTextDto, }; use afs_notion::{NotionConfig, NotionConnector}; use afs_store::{ConnectionId, InMemoryStateStore, ProjectionMode}; @@ -187,7 +188,7 @@ fn live_scratch_page_mount_edit_push_verifies_notion() { let clean_status = run_status( &store, StatusOptions { - path: Some(fixture.root.clone()), + path: Some(page_path.clone()), ..StatusOptions::default() }, ) @@ -209,6 +210,191 @@ fn live_scratch_page_mount_edit_push_verifies_notion() { ); } +#[test] +#[ignore = "requires NOTION_TOKEN and AFS_NOTION_LIVE_PARENT_PAGE; creates and archives scratch Notion content"] +fn live_cyclic_diverse_page_read_noop_preserves_notion() { + let env = LiveEnv::from_env(); + let api = HttpNotionApi::new(NotionConfig::default()); + let mut cleanup = LiveCleanup::new(api); + let target = cleanup.create_page( + &env.parent_page_id, + &format!("AFS cyclic link target {}", unique_suffix()), + vec![paragraph_child("Target page for live link checks.")], + ); + let source = cleanup.create_page( + &env.parent_page_id, + &format!("AFS cyclic diverse read {}", unique_suffix()), + diverse_page_children(&target.id), + ); + cleanup.create_page( + &source.id, + &format!("AFS cyclic nested child {}", unique_suffix()), + vec![paragraph_child( + "Nested child page for directory projection checks.", + )], + ); + + let connector = NotionConnector::new(NotionConfig::default()); + let before = live_block_snapshot(&connector, &source.id); + let (_fixture, mut store, page_path, markdown) = pull_live_page(&connector, &source.id); + + for expected in [ + "Cyclic paragraph", + "# Cyclic heading one", + "## Cyclic heading two", + "### Cyclic heading three", + "#### Cyclic heading four", + "- Cyclic bullet", + "1. Cyclic number", + "- [ ] Cyclic todo", + "> Cyclic quote", + "> [!NOTE]\n> Cyclic callout", + "```rust\nfn cyclic() {}\n```", + "$$\na^2+b^2=c^2\n$$", + "| Left | Right |", + "[Linked page](https://www.notion.so/", + "target mention [AFS cyclic link target", + "![Cyclic image](https://www.w3.org/Icons/w3c_home.png)", + "[Cyclic video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", + "[Cyclic file](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + "[Cyclic PDF](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + ] { + assert!( + markdown.contains(expected), + "missing {expected:?}\n{markdown}" + ); + } + assert!( + !markdown.contains("type=link_to_page"), + "link_to_page should render as a Markdown link, not a directive:\n{markdown}" + ); + + let clean_status = run_status( + &store, + StatusOptions { + path: Some(page_path.clone()), + ..StatusOptions::default() + }, + ) + .expect("clean status"); + assert!(clean_status.clean, "{clean_status:#?}"); + + let push = run_push_with_daemon( + &mut store, + &connector, + &page_path, + PushOptions { + assume_yes: true, + confirm_dangerous: false, + }, + ) + .expect("noop push"); + assert!(push.ok, "{push:#?}"); + assert_eq!(push.action, "noop", "{push:#?}"); + + let after = live_block_snapshot(&connector, &source.id); + assert_eq!( + after, before, + "read/noop cyclic path must not modify Notion block JSON" + ); +} + +#[test] +#[ignore = "requires NOTION_TOKEN and AFS_NOTION_LIVE_PARENT_PAGE; creates and archives scratch Notion content"] +fn live_cyclic_supported_block_edits_push_and_verify_notion() { + let env = LiveEnv::from_env(); + let api = HttpNotionApi::new(NotionConfig::default()); + let mut cleanup = LiveCleanup::new(api); + let source = cleanup.create_page( + &env.parent_page_id, + &format!("AFS cyclic supported edits {}", unique_suffix()), + supported_edit_children(), + ); + + let connector = NotionConnector::new(NotionConfig::default()); + let (fixture, mut store, page_path, original) = pull_live_page(&connector, &source.id); + let edited = original + .replace( + "Editable paragraph original.", + "Editable paragraph changed.", + ) + .replace("# Editable heading one", "# Editable heading one changed") + .replace("## Editable heading two", "## Editable heading two changed") + .replace( + "### Editable heading three", + "### Editable heading three changed", + ) + .replace( + "#### Editable heading four", + "#### Editable heading four changed", + ) + .replace("- Editable bullet", "- Editable bullet changed") + .replace("1. Editable number", "1. Editable number changed") + .replace("- [ ] Editable todo", "- [x] Editable todo changed") + .replace("> Editable quote", "> Editable quote changed") + .replace( + "> [!NOTE]\n> Editable callout", + "> [!NOTE]\n> Editable callout changed", + ) + .replace("fn editable() {}", "fn editable_changed() {}") + .replace("x+y=z", "x-y=z"); + fs::write(&page_path, edited).expect("write cyclic edits"); + + let dirty_status = run_status( + &store, + StatusOptions { + path: Some(page_path.clone()), + ..StatusOptions::default() + }, + ) + .expect("dirty status"); + assert!(!dirty_status.clean, "{dirty_status:#?}"); + + let push = run_push_with_daemon( + &mut store, + &connector, + &page_path, + PushOptions { + assume_yes: true, + confirm_dangerous: false, + }, + ) + .expect("push cyclic edits"); + assert!(push.ok, "{push:#?}"); + assert_eq!(push.action, "reconciled", "{push:#?}"); + + let clean_status = run_status( + &store, + StatusOptions { + path: Some(fixture.root.clone()), + ..StatusOptions::default() + }, + ) + .expect("clean status"); + assert!(clean_status.clean, "{clean_status:#?}"); + + let verified = render_live_page(&connector, &source.id, &page_path); + for expected in [ + "Editable paragraph changed.", + "# Editable heading one changed", + "## Editable heading two changed", + "### Editable heading three changed", + "#### Editable heading four changed", + "- Editable bullet changed", + "1. Editable number changed", + "- [x] Editable todo changed", + "> Editable quote changed", + "> [!NOTE]\n> Editable callout changed", + "fn editable_changed() {}", + "x-y=z", + ] { + assert!( + verified.contains(expected), + "missing {expected:?}\n{verified}" + ); + } +} + struct E2eFixture { root: PathBuf, mount_id: MountId, @@ -304,6 +490,296 @@ impl Drop for LiveCleanup { } } +#[derive(Debug)] +struct LiveEnv { + parent_page_id: String, +} + +impl LiveEnv { + fn from_env() -> Self { + std::env::var(TOKEN_ENV).expect("NOTION_TOKEN"); + let parent_page = std::env::var(LIVE_PARENT_ENV) + .unwrap_or_else(|_| panic!("set {LIVE_PARENT_ENV} to a writable page ID or URL")); + Self { + parent_page_id: normalize_notion_id(&parent_page), + } + } +} + +fn pull_live_page( + connector: &NotionConnector, + page_id: &str, +) -> (E2eFixture, InMemoryStateStore, PathBuf, String) { + let fixture = E2eFixture::new(); + let mut store = InMemoryStateStore::new(); + + run_mount( + &mut store, + MountOptions { + mount_id: fixture.mount_id.clone(), + connector: "notion".to_string(), + root: fixture.root.clone(), + remote_root_id: Some(RemoteId::new(page_id.to_string())), + connection_id: None, + read_only: false, + projection: ProjectionMode::PlainFiles, + }, + ) + .expect("mount live page"); + run_pull(&mut store, connector, &fixture.root).expect("pull live page"); + let page_path = fixture.page_file(); + let markdown = fs::read_to_string(&page_path).expect("read live page markdown"); + (fixture, store, page_path, markdown) +} + +fn live_block_snapshot(connector: &NotionConnector, page_id: &str) -> Value { + let native = connector + .fetch(FetchRequest { + remote_id: RemoteId::new(page_id.to_string()), + }) + .expect("fetch live snapshot"); + let bundle: NotionPageBundle = serde_json::from_slice(&native.raw).expect("snapshot bundle"); + serde_json::to_value(bundle.blocks).expect("snapshot json") +} + +fn render_live_page(connector: &NotionConnector, page_id: &str, page_path: &Path) -> String { + let native = connector + .fetch(FetchRequest { + remote_id: RemoteId::new(page_id.to_string()), + }) + .expect("fetch live page"); + connector + .render_native_entity_for_path(&native, page_path) + .expect("render live page") + .document + .body +} + +fn diverse_page_children(target_page_id: &str) -> Vec { + vec![ + json!({ + "object": "block", + "type": "paragraph", + "paragraph": { + "rich_text": [ + text_part("Cyclic paragraph with "), + annotated_text("bold", "bold"), + text_part(" and a target mention "), + page_mention_part("Target page", target_page_id), + text_part(" plus inline math "), + equation_part("a^2+b^2=c^2") + ] + } + }), + rich_text_child("heading_1", "Cyclic heading one"), + rich_text_child("heading_2", "Cyclic heading two"), + rich_text_child("heading_3", "Cyclic heading three"), + rich_text_child("heading_4", "Cyclic heading four"), + rich_text_child("bulleted_list_item", "Cyclic bullet"), + rich_text_child("numbered_list_item", "Cyclic number"), + json!({ + "object": "block", + "type": "to_do", + "to_do": { "rich_text": rich_text_json("Cyclic todo"), "checked": false } + }), + rich_text_child("quote", "Cyclic quote"), + rich_text_child("callout", "Cyclic callout"), + json!({ + "object": "block", + "type": "toggle", + "toggle": { + "rich_text": rich_text_json("Cyclic toggle"), + "children": [paragraph_child("Cyclic toggle child")] + } + }), + json!({ + "object": "block", + "type": "code", + "code": { "rich_text": rich_text_json("fn cyclic() {}"), "language": "rust" } + }), + json!({ "object": "block", "type": "divider", "divider": {} }), + json!({ + "object": "block", + "type": "equation", + "equation": { "expression": "a^2+b^2=c^2" } + }), + json!({ + "object": "block", + "type": "bookmark", + "bookmark": { "url": "https://example.com/cyclic-bookmark", "caption": rich_text_json("Cyclic bookmark") } + }), + json!({ + "object": "block", + "type": "embed", + "embed": { "url": "https://example.com/cyclic-embed", "caption": rich_text_json("Cyclic embed") } + }), + json!({ + "object": "block", + "type": "table", + "table": { + "table_width": 2, + "has_column_header": true, + "has_row_header": false, + "children": [ + table_row_child("Left", "Right"), + table_row_child("Cell A", "Cell B") + ] + } + }), + json!({ + "object": "block", + "type": "column_list", + "column_list": { + "children": [ + { "object": "block", "type": "column", "column": { "children": [paragraph_child("Cyclic column one")] } }, + { "object": "block", "type": "column", "column": { "children": [paragraph_child("Cyclic column two")] } } + ] + } + }), + json!({ + "object": "block", + "type": "table_of_contents", + "table_of_contents": { "color": "default" } + }), + json!({ "object": "block", "type": "breadcrumb", "breadcrumb": {} }), + json!({ + "object": "block", + "type": "link_to_page", + "link_to_page": { "type": "page_id", "page_id": target_page_id } + }), + media_child( + "image", + "https://www.w3.org/Icons/w3c_home.png", + "Cyclic image", + ), + media_child( + "video", + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "Cyclic video", + ), + media_child( + "file", + "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", + "Cyclic file", + ), + media_child( + "pdf", + "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", + "Cyclic PDF", + ), + media_child( + "audio", + "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", + "Cyclic audio", + ), + ] +} + +fn supported_edit_children() -> Vec { + vec![ + paragraph_child("Editable paragraph original."), + rich_text_child("heading_1", "Editable heading one"), + rich_text_child("heading_2", "Editable heading two"), + rich_text_child("heading_3", "Editable heading three"), + rich_text_child("heading_4", "Editable heading four"), + rich_text_child("bulleted_list_item", "Editable bullet"), + rich_text_child("numbered_list_item", "Editable number"), + json!({ + "object": "block", + "type": "to_do", + "to_do": { "rich_text": rich_text_json("Editable todo"), "checked": false } + }), + rich_text_child("quote", "Editable quote"), + rich_text_child("callout", "Editable callout"), + json!({ + "object": "block", + "type": "code", + "code": { "rich_text": rich_text_json("fn editable() {}"), "language": "rust" } + }), + json!({ "object": "block", "type": "divider", "divider": {} }), + json!({ + "object": "block", + "type": "equation", + "equation": { "expression": "x+y=z" } + }), + ] +} + +fn paragraph_child(text: &str) -> Value { + rich_text_child("paragraph", text) +} + +fn rich_text_child(kind: &str, text: &str) -> Value { + let mut block = json!({ + "object": "block", + "type": kind + }); + block[kind] = json!({ "rich_text": rich_text_json(text) }); + block +} + +fn table_row_child(left: &str, right: &str) -> Value { + json!({ + "object": "block", + "type": "table_row", + "table_row": { + "cells": [rich_text_json(left), rich_text_json(right)] + } + }) +} + +fn media_child(kind: &str, url: &str, caption: &str) -> Value { + let mut block = json!({ + "object": "block", + "type": kind + }); + block[kind] = json!({ + "type": "external", + "external": { "url": url }, + "caption": rich_text_json(caption) + }); + block +} + +fn rich_text_json(text: &str) -> Vec { + vec![text_part(text)] +} + +fn text_part(text: &str) -> Value { + json!({ + "type": "text", + "text": { "content": text } + }) +} + +fn annotated_text(text: &str, annotation: &str) -> Value { + let mut annotations = serde_json::Map::new(); + annotations.insert(annotation.to_string(), json!(true)); + json!({ + "type": "text", + "text": { "content": text }, + "annotations": Value::Object(annotations) + }) +} + +fn equation_part(expression: &str) -> Value { + json!({ + "type": "equation", + "equation": { "expression": expression } + }) +} + +fn page_mention_part(label: &str, page_id: &str) -> Value { + json!({ + "type": "mention", + "mention": { + "type": "page", + "page": { "id": page_id } + }, + "plain_text": label + }) +} + #[derive(Debug)] struct MutableNotionApi { page: PageDto, diff --git a/crates/afs-core/src/diff.rs b/crates/afs-core/src/diff.rs index b706788..bfd252d 100644 --- a/crates/afs-core/src/diff.rs +++ b/crates/afs-core/src/diff.rs @@ -392,7 +392,10 @@ fn align_residual_by_order( .map(|(index, _)| index) .collect(); - if residual_edited.len() > 1 && residual_shadow.len() > 1 { + if residual_edited.len() > 1 + && residual_shadow.len() > 1 + && !residual_kinds_match_in_order(shadow, edited_blocks, &residual_shadow, &residual_edited) + { return Some(PlanDegradation::new( PlanDegradationKind::AmbiguousBlockAlignment, "multiple edited and synced blocks could not be aligned safely; unmatched edited blocks will be appended and unmatched synced blocks archived", @@ -407,6 +410,44 @@ fn align_residual_by_order( None } +fn residual_kinds_match_in_order( + shadow: &ShadowDocument, + edited_blocks: &[SegmentedBlock], + residual_shadow: &[usize], + residual_edited: &[usize], +) -> bool { + residual_shadow.len() == residual_edited.len() + && residual_shadow + .iter() + .zip(residual_edited) + .all(|(shadow_index, edited_index)| { + same_alignment_kind( + &shadow.blocks[*shadow_index].kind, + &edited_blocks[*edited_index].kind, + ) + }) +} + +fn same_alignment_kind(left: &MarkdownBlockKind, right: &MarkdownBlockKind) -> bool { + match (left, right) { + ( + MarkdownBlockKind::TableWithRows { + has_column_header: left_column, + has_row_header: left_row, + .. + }, + MarkdownBlockKind::TableWithRows { + has_column_header: right_column, + has_row_header: right_row, + .. + }, + ) => left_column == right_column && left_row == right_row, + (MarkdownBlockKind::TableWithRows { .. }, MarkdownBlockKind::Table) + | (MarkdownBlockKind::Table, MarkdownBlockKind::TableWithRows { .. }) => true, + (left, right) => left == right, + } +} + fn should_move_block( shadow_index: usize, edited_index: usize, diff --git a/crates/afs-core/tests/block_diff.rs b/crates/afs-core/tests/block_diff.rs index 7cd3ccb..f0f43b3 100644 --- a/crates/afs-core/tests/block_diff.rs +++ b/crates/afs-core/tests/block_diff.rs @@ -138,11 +138,8 @@ fn editing_a_directive_fails_validation_instead_of_planning() { #[test] fn ambiguous_residual_alignment_is_explicitly_degraded() { - let shadow = shadow( - "First paragraph.\n\nSecond paragraph.", - ["block-1", "block-2"], - ); - let edited = CanonicalDocument::new("", "First rewrite.\n\nSecond rewrite."); + let shadow = shadow("First paragraph.\n\n- Second item", ["block-1", "block-2"]); + let edited = CanonicalDocument::new("", "- First rewrite.\n\nSecond rewrite."); let plan = BlockDiffEngine::new() .plan_push(&shadow, &edited) @@ -158,6 +155,48 @@ fn ambiguous_residual_alignment_is_explicitly_degraded() { ); } +#[test] +fn residual_alignment_updates_same_kind_sequence_without_archive_recreate() { + let shadow = shadow( + "# Heading\n\nParagraph.\n\n- Item\n\n```rust\nfn old() {}\n```", + ["heading-1", "paragraph-1", "list-1", "code-1"], + ); + let edited = CanonicalDocument::new( + "", + "# Heading changed\n\nParagraph changed.\n\n- Item changed\n\n```rust\nfn new() {}\n```", + ); + + let plan = BlockDiffEngine::new() + .plan_push(&shadow, &edited) + .expect("plan"); + + assert_eq!(plan.summary.blocks_updated, 4); + assert_eq!(plan.summary.blocks_created, 0); + assert_eq!(plan.summary.blocks_archived, 0); + assert!(plan.degradations.is_empty()); + assert_eq!( + plan.operations, + vec![ + PushOperation::UpdateBlock { + block_id: RemoteId::new("heading-1"), + content: "# Heading changed".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("paragraph-1"), + content: "Paragraph changed.".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("list-1"), + content: "- Item changed".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("code-1"), + content: "```rust\nfn new() {}\n```".to_string(), + }, + ] + ); +} + fn shadow(body: &str, ids: [&str; N]) -> ShadowDocument { ShadowDocument::from_synced_body( RemoteId::new("page-1"), diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index ccf30ee..5e8d0aa 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -1167,10 +1167,10 @@ impl InlineParser<'_> { if rest.starts_with('[') && let Some((label, href, consumed)) = parse_markdown_link(rest) { - if let Some(id) = href.strip_prefix("afs://") { + if let Some(id) = notion_page_id_from_href(href) { return Ok(Some(( vec![RichTextWritePart::PageMention { - id: id.to_string(), + id, annotations: InlineAnnotations::default(), }], consumed, @@ -1250,6 +1250,59 @@ fn parse_markdown_link(input: &str) -> Option<(&str, &str, usize)> { )) } +fn notion_page_id_from_href(href: &str) -> Option { + if let Some(id) = href.strip_prefix("afs://") { + return Some(id.to_string()); + } + + let trimmed = href.trim(); + if !is_notion_url(trimmed) { + return None; + } + + let without_query = trimmed + .split(['?', '#']) + .next() + .unwrap_or(trimmed) + .trim_end_matches('/'); + without_query + .rsplit('/') + .find_map(notion_id_from_url_segment) +} + +fn is_notion_url(href: &str) -> bool { + let lower = href.to_ascii_lowercase(); + lower.starts_with("https://www.notion.so/") + || lower.starts_with("https://notion.so/") + || lower.starts_with("https://app.notion.com/") +} + +fn notion_id_from_url_segment(segment: &str) -> Option { + if segment.is_empty() { + return None; + } + + let without_hyphens = segment.replace('-', ""); + if without_hyphens.len() == 32 + && without_hyphens + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Some(without_hyphens); + } + + let trailing_hex = segment + .chars() + .rev() + .take_while(|character| character.is_ascii_hexdigit()) + .collect::>(); + if trailing_hex.len() >= 32 { + return Some(trailing_hex.iter().take(32).rev().copied().collect()); + } + + None +} + fn unescape_markdown_text(value: &str) -> String { value.replace("\\\\", "\\") } @@ -1415,7 +1468,7 @@ fn mention_to_markdown(part: &RichTextDto) -> (String, bool) { ( markdown_link_preserving_whitespace( &mention_label(part), - &format!("afs://{}", page.id), + ¬ion_object_url(&page.id), ), true, ) @@ -1428,7 +1481,7 @@ fn mention_to_markdown(part: &RichTextDto) -> (String, bool) { ( markdown_link_preserving_whitespace( &mention_label(part), - &format!("afs://{}", database.id), + ¬ion_object_url(&database.id), ), true, ) @@ -1557,6 +1610,18 @@ fn markdown_link_preserving_whitespace(label: &str, href: &str) -> String { }) } +fn notion_object_url(id: &str) -> String { + format!("https://www.notion.so/{}", notion_url_id(id)) +} + +fn notion_url_id(id: &str) -> String { + let hex = id + .chars() + .filter(|character| character.is_ascii_hexdigit()) + .collect::(); + if hex.len() == 32 { hex } else { id.to_string() } +} + fn wrap_preserving_whitespace(value: &str, wrap: impl FnOnce(&str) -> String) -> String { let Some(start) = value .char_indices() diff --git a/crates/afs-notion/src/render.rs b/crates/afs-notion/src/render.rs index c8c9e08..c10cf88 100644 --- a/crates/afs-notion/src/render.rs +++ b/crates/afs-notion/src/render.rs @@ -406,17 +406,29 @@ fn synced_block_directive(block: &BlockDto, payload: Option<&SyncedBlockDto>) -> } fn link_to_page_directive(block: &BlockDto, payload: Option<&LinkToPageBlockDto>) -> RenderedBlock { - let attrs = payload - .and_then(|payload| match payload.kind.as_str() { - "page_id" => payload.page_id.clone().map(|id| vec![("page_id", id)]), - "database_id" => payload - .database_id - .clone() - .map(|id| vec![("database_id", id)]), - _ => None, - }) - .unwrap_or_default(); - directive_block_with_attrs(block, "link_to_page", attrs) + let Some(payload) = payload else { + return directive_block(block, "malformed_link_to_page", None); + }; + + let link = match payload.kind.as_str() { + "page_id" => payload + .page_id + .as_deref() + .map(|id| ("Linked page", notion_object_url(id))), + "database_id" => payload + .database_id + .as_deref() + .map(|id| ("Linked database", notion_object_url(id))), + _ => None, + }; + + match link { + Some((label, href)) => rendered_block( + markdown_link_preserving_whitespace(label, &href), + Some(RemoteId::new(block.id.clone())), + ), + None => directive_block(block, "malformed_link_to_page", None), + } } fn titled_directive( @@ -695,7 +707,7 @@ fn mention_to_markdown(part: &RichTextDto) -> (String, bool) { ( markdown_link_preserving_whitespace( &mention_label(part), - &format!("afs://{}", page.id), + ¬ion_object_url(&page.id), ), true, ) @@ -708,7 +720,7 @@ fn mention_to_markdown(part: &RichTextDto) -> (String, bool) { ( markdown_link_preserving_whitespace( &mention_label(part), - &format!("afs://{}", database.id), + ¬ion_object_url(&database.id), ), true, ) @@ -832,6 +844,18 @@ fn markdown_link_preserving_whitespace(label: &str, href: &str) -> String { }) } +fn notion_object_url(id: &str) -> String { + format!("https://www.notion.so/{}", notion_url_id(id)) +} + +fn notion_url_id(id: &str) -> String { + let hex = id + .chars() + .filter(|character| character.is_ascii_hexdigit()) + .collect::(); + if hex.len() == 32 { hex } else { id.to_string() } +} + fn wrap_preserving_whitespace(value: &str, wrap: impl FnOnce(&str) -> String) -> String { let Some(start) = value .char_indices() diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index cbdaaf4..7475f73 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -605,7 +605,7 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { vec![RemoteId::new("page-1")], vec![PushOperation::UpdateBlock { block_id: RemoteId::new("paragraph-1"), - content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and $E=mc^2$ [Roadmap](afs://page-2)".to_string(), + content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), }], ); let push_id = PushId("push-1".to_string()); @@ -693,12 +693,27 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { "content": " ", }, }, + { + "type": "text", + "text": { + "content": "Hex docs", + "link": { + "url": "https://example.com/22222222222222222222222222222222", + }, + }, + }, + { + "type": "text", + "text": { + "content": " ", + }, + }, { "type": "mention", "mention": { "type": "page", "page": { - "id": "page-2", + "id": "22222222222222222222222222222222", }, }, }, diff --git a/crates/afs-notion/tests/fetch_render.rs b/crates/afs-notion/tests/fetch_render.rs index bd5b690..ff705ff 100644 --- a/crates/afs-notion/tests/fetch_render.rs +++ b/crates/afs-notion/tests/fetch_render.rs @@ -257,7 +257,7 @@ fn render_richer_notion_block_coverage() { "::afs{id=bookmark-1 type=bookmark title=\"Bookmark caption\" url=\"https://example.com/bookmark\"}\n\n", "![Image caption](https://example.com/image.png)\n\n", "::afs{id=synced-1 type=synced_block source_block_id=\"source-block-1\"}\n\n", - "::afs{id=link-to-page-1 type=link_to_page page_id=\"target-page-1\"}\n\n", + "[Linked page](https://www.notion.so/target-page-1)\n\n", "::afs{id=toc-1 type=table_of_contents color=\"default\"}\n\n", "::afs{id=breadcrumb-1 type=breadcrumb}\n\n", "::afs{id=column-list-1 type=column_list}\n\n", @@ -560,8 +560,8 @@ fn render_all_known_notion_block_objects_into_markdown_or_directives() { "[Audio](https://example.com/audio.mp3)", "::afs{id=synced-original-1 type=synced_block}", "::afs{id=synced-copy-1 type=synced_block source_block_id=\"source-block-1\"}", - "::afs{id=link-page-1 type=link_to_page page_id=\"target-page-1\"}", - "::afs{id=link-db-1 type=link_to_page database_id=\"target-db-1\"}", + "[Linked page](https://www.notion.so/target-page-1)", + "[Linked database](https://www.notion.so/target-db-1)", "::afs{id=toc-1 type=table_of_contents color=\"default\"}", "::afs{id=breadcrumb-1 type=breadcrumb}", "::afs{id=column-list-1 type=column_list}", @@ -832,7 +832,7 @@ fn render_rich_text_annotations_links_mentions_and_equations() { assert_eq!( rendered.document.body, - "**Bold** _italic_ ~~strike~~ underline `code` [external link](https://example.com/) after link. 2026-06-10 and inline equation $E=mc^2$ plus page mention [Roadmap](afs://page-1) database mention [Tasks](afs://database-1) user mention @Ada preview [Example](https://example.com/preview) unknown Fallback\n" + "**Bold** _italic_ ~~strike~~ underline `code` [external link](https://example.com/) after link. 2026-06-10 and inline equation $E=mc^2$ plus page mention [Roadmap](https://www.notion.so/page-1) database mention [Tasks](https://www.notion.so/database-1) user mention @Ada preview [Example](https://example.com/preview) unknown Fallback\n" ); } diff --git a/crates/afs-notion/tests/live_integrity.rs b/crates/afs-notion/tests/live_integrity.rs index c52edff..49de471 100644 --- a/crates/afs-notion/tests/live_integrity.rs +++ b/crates/afs-notion/tests/live_integrity.rs @@ -84,7 +84,12 @@ fn live_page_read_edit_write_verify_integrity_with_media_download() { assert!(rendered.document.body.contains("type=table_of_contents")); assert!(rendered.document.body.contains("type=breadcrumb")); assert!(rendered.document.body.contains("type=column_list")); - assert!(rendered.document.body.contains("type=link_to_page")); + assert!( + rendered + .document + .body + .contains("[Linked page](https://www.notion.so/") + ); assert!(rendered.document.body.contains("type=child_page")); assert!( rendered.media_assets.iter().any(|asset| { diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 0cb8f71..603f6b8 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, and display equations. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, and `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, and display equations. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 5715c6b..372a795 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -100,7 +100,7 @@ Inline rich text is represented with Notion DTOs first, then rendered through on - `RichTextDto` mirrors Notion's `text`, `mention`, and `equation` variants plus shared annotations and links. - `TextRichTextDto`, `MentionRichTextDto`, and `EquationRichTextDto` keep variant-specific payloads out of renderer control flow. - The renderer preserves whitespace around annotated spans so text like ` bold ` becomes ` **bold** ` instead of pulling spaces into Markdown delimiters. -- Page and database mentions render as `afs://...` links for now. That keeps the remote identity visible until local cross-document link resolution is implemented. +- Page and database mentions render as normal Markdown links to Notion object URLs. Unchanged mention preimages still preserve typed Notion mentions during block updates. - Unknown or partially populated rich text falls back to `plain_text` so live API additions remain readable. Nested children are fetched recursively and rendered after their parent, except valid table rows, which are folded into their parent table's Markdown block. This preserves content and block IDs for the first read path, but it does not yet preserve every Notion nesting/layout nuance. Layout-rich blocks should stay directive-backed until the renderer can round-trip them safely. @@ -111,7 +111,7 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, and display equations; -- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, `afs://` page links, and unchanged preimage mentions such as dates; +- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, legacy `afs://` page links, and unchanged preimage mentions such as dates; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, and phone; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; - unsupported write forms fail before API mutation, including tables, page/database creation outside database-row files, computed/read-only properties, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; diff --git a/docs/notion-cyclic-bug-journal.md b/docs/notion-cyclic-bug-journal.md new file mode 100644 index 0000000..b38e657 --- /dev/null +++ b/docs/notion-cyclic-bug-journal.md @@ -0,0 +1,47 @@ +# Notion Cyclic Test Bug Journal + +This journal tracks bugs found while exercising live Notion cyclic tests against +the disposable AFS e2e workspace. Each entry should include the live behavior, +the local symptom, and the fix made in the PR. + +## 2026-06-13 + +### `link_to_page` Rendered As An AFS Directive + +- **Found by:** `live_cyclic_diverse_page_read_noop_preserves_notion`. +- **Symptom:** A Notion `link_to_page` block rendered as + `::afs{id=... type=link_to_page ...}`. That made a normal page link look like + connector internals instead of a Markdown link that agents can follow. +- **Fix:** Render `link_to_page` blocks as ordinary Markdown links to Notion + object URLs. Malformed link blocks still fall back to directives so corrupted + native payloads are not silently flattened. +- **Verification:** Fixture tests now expect `[Linked page](https://www.notion.so/...)` + and `[Linked database](https://www.notion.so/...)`. The live cyclic read test + asserts no `type=link_to_page` directive appears for valid links. + +### Full Same-Shape Page Edits Planned Archive/Recreate + +- **Found by:** `live_cyclic_supported_block_edits_push_and_verify_notion`. +- **Symptom:** Editing every supported block in a page caused the diff engine to + mark all original blocks for archive and all edited blocks for append. The + push was blocked as a dangerous plan instead of producing block updates. +- **Cause:** Residual alignment degraded whenever more than one edited block and + more than one shadow block were unmatched, even if their Markdown block kinds + matched in order. +- **Fix:** Residual alignment now pairs blocks by order when the unmatched + edited and shadow sequences have the same Markdown kind sequence. Mixed-kind + residual sequences remain explicitly degraded. +- **Verification:** Added `residual_alignment_updates_same_kind_sequence_without_archive_recreate` + and kept an ambiguous mixed-kind degradation test. + +### UUID-Shaped External Links Could Be Parsed As Page Mentions + +- **Found by:** pre-PR code review of the Notion URL write parser. +- **Symptom:** The new page-mention parser accepted any Markdown link whose URL + ended with 32 hexadecimal characters. An unrelated external link could + therefore be converted into a Notion page mention. +- **Fix:** Page mention writes now accept legacy `afs://` links and URLs on + Notion hosts only (`www.notion.so`, `notion.so`, and `app.notion.com`). + Slugged and hyphenated Notion page IDs are still accepted. +- **Verification:** The rich text apply test now includes an external URL with a + UUID-shaped path and verifies it remains a normal linked text span. diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md new file mode 100644 index 0000000..3e89c89 --- /dev/null +++ b/docs/notion-cyclic-support-journal.md @@ -0,0 +1,33 @@ +# Notion Cyclic Support Journal + +This journal records Notion support added while expanding live cyclic tests. It +is separate from the support matrix so reviewers can see why a behavior changed +and what Markdown shape agents should expect. + +## 2026-06-13 + +### Page And Database Links + +- **Notion input:** `link_to_page` blocks with `page_id` or `database_id`. +- **Markdown output:** Valid targets render as normal links: + - `[Linked page](https://www.notion.so/)` + - `[Linked database](https://www.notion.so/)` +- **Write behavior:** Unchanged link blocks are preserved during pushes. Direct + retargeting of a `link_to_page` block is not yet supported; malformed native + link payloads still render as guarded AFS directives. +- **Inline mentions:** Page and database rich-text mentions now render as normal + Notion URL links instead of `afs://` links. The writer accepts page URLs on + Notion hosts as page mention writes and keeps legacy `afs://` parsing for + compatibility. External links with UUID-shaped paths remain ordinary links. + +### Mounted Live Cyclic Coverage + +- **Read/no-op cycle:** The live test creates a page containing paragraphs, + rich text annotations, inline page mentions, headings 1-4, lists, to-dos, + quote, callout, toggle children, code, divider, equation, bookmark, embed, + table, column layout, table of contents, breadcrumb, link-to-page, child page, + and external media blocks. It mounts and pulls the page, validates the Markdown + projection, performs a no-op push, and verifies Notion block JSON is unchanged. +- **Edit/push cycle:** The live test creates a supported-edit page, edits each + supported Markdown block shape locally, pushes, and verifies the rendered + Notion content through the Notion API. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 801ba12..f5f2407 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -56,7 +56,7 @@ Sources used for the baseline: | `pdf` | Markdown link | No | fixture, live read | Uses `external.url` or Notion-hosted `file.url`; local download intentionally skipped for now. | | `audio` | Markdown link | No | fixture, live read | Uses `external.url` or Notion-hosted `file.url`; local download intentionally skipped for now. | | `synced_block` | Directive wrapper; source block ID preserved when present | No | fixture | Rewriting synced blocks is lossy without source/copy semantics; live creation of an original synced block was rejected because Notion requires `synced_from`. | -| `link_to_page` | Directive | No | fixture, live read | Page/database target ID preserved. | +| `link_to_page` | Markdown link to Notion URL | Read/delete/move only | fixture, live read | Page/database target ID is preserved in the link target; direct retargeting is not a supported edit yet. | | `table_of_contents` | Directive | No | fixture, live read | Generated navigation block; no useful Markdown edit surface. | | `breadcrumb` | Directive | No | fixture, live read | Generated navigation block; no useful Markdown edit surface. | | `column_list` | Directive wrapper; children render below it | No | fixture, live read | Layout is anchored; child content remains readable. | @@ -78,8 +78,8 @@ Sources used for the baseline: | External text link | Markdown link | Yes | fixture, live | Link URL is preserved. | | Equation span | Inline math | Yes | fixture, live | `$...$`. | | Bold, italic, strikethrough, underline, code | Markdown/HTML inline formatting | Yes for emitted shapes | fixture, live | Underline uses ``. | -| Page mention | `afs://` link | Read; write via supported `afs://` parsing path | fixture | Stable ID is preserved. | -| Database mention | `afs://` link | Read only in current live suite | fixture | Stable ID is preserved. | +| Page mention | Markdown link to Notion URL | Read; write via Notion-hosted URL or legacy `afs://` parsing path | fixture, live | Stable ID is preserved; external UUID-shaped links remain ordinary links. | +| Database mention | Markdown link to Notion URL | Read only in current live suite | fixture | Stable ID is preserved. | | User mention | Plain `@name`/fallback | Read only | fixture | Needs identity lookup before safe writes. | | Date mention | Plain date/range text | Read only | fixture, live | Needs typed date mention parser before safe writes. | | Link preview mention | Markdown link | Read only | fixture | Preserves URL. | From b1225a44192f95dcc1c30347a96b369d77af07c9 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 01:02:59 -0700 Subject: [PATCH 02/18] Update degradation pipeline test --- crates/afs-core/tests/push_pipeline.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/afs-core/tests/push_pipeline.rs b/crates/afs-core/tests/push_pipeline.rs index 9803f04..85b1bbb 100644 --- a/crates/afs-core/tests/push_pipeline.rs +++ b/crates/afs-core/tests/push_pipeline.rs @@ -143,11 +143,8 @@ fn mount_touch_guardrail_requires_confirm() { #[test] fn plan_degradations_surface_in_output() { - let parsed = parsed_doc("First rewrite.\n\nSecond rewrite."); - let shadow = shadow( - "First paragraph.\n\nSecond paragraph.", - ["block-1", "block-2"], - ); + let parsed = parsed_doc("- First rewrite.\n\nSecond rewrite."); + let shadow = shadow("First paragraph.\n\n- Second item", ["block-1", "block-2"]); let output = plan_push_pipeline(request(&parsed, &shadow).with_approval(PushApproval { assume_yes: true, From c3a1d3c6f879e84f50c87b81d5950c9b463a2685 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 01:43:47 -0700 Subject: [PATCH 03/18] Add mounted Notion database row cyclic coverage --- crates/afs-cli/tests/e2e_push_workflow.rs | 417 +++++++++++++++++++++- crates/afs-notion/src/apply.rs | 37 +- crates/afs-notion/src/client.rs | 8 + crates/afs-notion/tests/apply.rs | 37 +- docs/notion-cyclic-bug-journal.md | 16 + docs/notion-cyclic-support-journal.md | 15 + docs/notion-object-support.md | 24 +- 7 files changed, 532 insertions(+), 22 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index c89c1e0..75af111 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -11,10 +11,11 @@ use afs_cli::pull::run_pull; use afs_cli::push::{PushOptions, run_push_with_daemon}; use afs_cli::status::{StatusOptions, run_status}; use afs_connector::{Connector, FetchRequest}; +use afs_core::canonical::render_canonical_markdown; use afs_core::model::{MountId, RemoteId}; use afs_notion::client::{HttpNotionApi, NotionApi}; use afs_notion::dto::{ - BlockDto, BlockListDto, NotionPageBundle, PageDto, PageListDto, PagePropertyDto, + BlockDto, BlockListDto, DatabaseDto, NotionPageBundle, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, RichTextBlockDto, RichTextDto, SyncedBlockDto, SyncedFromDto, TextRichTextDto, }; @@ -395,6 +396,224 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { } } +#[test] +#[ignore = "requires NOTION_TOKEN and AFS_NOTION_LIVE_PARENT_PAGE; creates and archives scratch Notion content"] +fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { + let env = LiveEnv::from_env(); + let api = HttpNotionApi::new(NotionConfig::default()); + let mut cleanup = LiveCleanup::new(api); + let scratch = cleanup.create_page( + &env.parent_page_id, + &format!("AFS cyclic database scratch {}", unique_suffix()), + Vec::new(), + ); + let database = + cleanup.create_database(&scratch.id, &format!("AFS cyclic rows {}", unique_suffix())); + let existing_row = cleanup.create_database_row( + &database, + &format!("AFS cyclic existing row {}", unique_suffix()), + database_row_properties( + "Initial row notes", + "7", + "Todo", + "Not started", + false, + "https://example.com/afs-db-row", + ), + vec![paragraph_child("Database row paragraph original.")], + ); + + let fixture = E2eFixture::new(); + let mut store = InMemoryStateStore::new(); + let connector = NotionConnector::new(NotionConfig::default()); + run_mount( + &mut store, + MountOptions { + mount_id: fixture.mount_id.clone(), + connector: "notion".to_string(), + root: fixture.root.clone(), + remote_root_id: Some(RemoteId::new(scratch.id.clone())), + connection_id: None, + read_only: false, + projection: ProjectionMode::PlainFiles, + }, + ) + .expect("mount live database root page"); + run_pull(&mut store, &connector, &fixture.root).expect("pull live database root page"); + + let schema_path = fixture.schema_file(); + let schema = fs::read_to_string(&schema_path).expect("read live database schema"); + for expected in [ + "type: notion_database_schema", + "\"Notes\":", + "\"Points\":", + "\"Status\":", + "\"State\":", + "\"Tags\":", + "\"Done\":", + "\"Due\":", + "\"URL\":", + "\"Email\":", + "\"Phone\":", + ] { + assert!(schema.contains(expected), "missing {expected:?}\n{schema}"); + } + + let row_path = fixture.nested_markdown_file_containing("AFS cyclic existing row"); + run_pull(&mut store, &connector, &row_path).expect("hydrate live database row"); + let original = fs::read_to_string(&row_path).expect("read hydrated row markdown"); + for expected in [ + "title: \"AFS cyclic existing row", + "\"Notes\": \"Initial row notes\"", + "\"Points\": 7", + "\"Status\": \"Todo\"", + "\"State\": \"Not started\"", + "\"Done\": false", + "\"URL\": \"https://example.com/afs-db-row\"", + "Database row paragraph original.", + ] { + assert!( + original.contains(expected), + "missing {expected:?}\n{original}" + ); + } + + let before = live_page_snapshot(&connector, &existing_row.id); + let clean_status = run_status( + &store, + StatusOptions { + path: Some(row_path.clone()), + ..StatusOptions::default() + }, + ) + .expect("clean row status"); + assert!(clean_status.clean, "{clean_status:#?}"); + + let noop = run_push_with_daemon( + &mut store, + &connector, + &row_path, + PushOptions { + assume_yes: true, + confirm_dangerous: false, + }, + ) + .expect("noop database row push"); + assert!(noop.ok, "{noop:#?}"); + assert_eq!(noop.action, "noop", "{noop:#?}"); + assert_eq!( + live_page_snapshot(&connector, &existing_row.id), + before, + "read/noop database row cycle must not mutate Notion" + ); + + let edited = original + .replace( + "\"Notes\": \"Initial row notes\"", + "\"Notes\": \"Updated row notes\"", + ) + .replace("\"Points\": 7", "\"Points\": 8") + .replace("\"Status\": \"Todo\"", "\"Status\": \"Done\"") + .replace("\"State\": \"Not started\"", "\"State\": \"In progress\"") + .replace("\"Done\": false", "\"Done\": true") + .replace( + "\"URL\": \"https://example.com/afs-db-row\"", + "\"URL\": \"https://example.com/afs-db-row-updated\"", + ) + .replace( + "Database row paragraph original.", + "Database row paragraph changed.", + ); + fs::write(&row_path, edited).expect("write live database row edit"); + let dirty_status = run_status( + &store, + StatusOptions { + path: Some(row_path.clone()), + ..StatusOptions::default() + }, + ) + .expect("dirty row status"); + assert!(!dirty_status.clean, "{dirty_status:#?}"); + + let push = run_push_with_daemon( + &mut store, + &connector, + &row_path, + PushOptions { + assume_yes: true, + confirm_dangerous: false, + }, + ) + .expect("push database row edit"); + assert!(push.ok, "{push:#?}"); + assert_eq!(push.action, "reconciled", "{push:#?}"); + + let verified = render_live_markdown(&connector, &existing_row.id, &row_path); + for expected in [ + "\"Notes\": \"Updated row notes\"", + "\"Points\": 8", + "\"Status\": \"Done\"", + "\"State\": \"In progress\"", + "\"Done\": true", + "\"URL\": \"https://example.com/afs-db-row-updated\"", + "Database row paragraph changed.", + ] { + assert!( + verified.contains(expected), + "missing {expected:?}\n{verified}" + ); + } + + let database_dir = fixture.database_dir(); + let new_row_path = database_dir.join("new-cyclic-row.md"); + fs::write( + &new_row_path, + "---\ntitle: AFS cyclic created row\nNotes: Created row notes\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\n---\n# Created row body\n\nCreated from mounted markdown.\n", + ) + .expect("write new live database row file"); + + let create_push = run_push_with_daemon( + &mut store, + &connector, + &new_row_path, + PushOptions { + assume_yes: true, + confirm_dangerous: false, + }, + ) + .expect("push new database row"); + assert!(create_push.ok, "{create_push:#?}"); + assert_eq!(create_push.action, "reconciled", "{create_push:#?}"); + let created_row_id = create_push + .changed_remote_ids + .iter() + .find(|id| *id != &database.id) + .expect("created row id") + .clone(); + cleanup.block_ids.push(created_row_id.clone()); + + let created = render_live_markdown(&connector, &created_row_id, &new_row_path); + for expected in [ + "title: \"AFS cyclic created row\"", + "\"Notes\": \"Created row notes\"", + "\"Points\": 13", + "\"Status\": \"Todo\"", + "\"State\": \"Not started\"", + "\"Tags\":", + "\"Alpha\"", + "\"Done\": false", + "\"URL\": \"https://example.com/afs-created-row\"", + "\"Email\": \"cyclic@example.com\"", + "\"Phone\": \"+1 415 555 0199\"", + "Created from mounted markdown.", + ] { + assert!( + created.contains(expected), + "missing {expected:?}\n{created}" + ); + } +} + struct E2eFixture { root: PathBuf, mount_id: MountId, @@ -429,6 +648,35 @@ impl E2eFixture { }) .expect("page file") } + + fn schema_file(&self) -> PathBuf { + collect_files(&self.root) + .into_iter() + .find(|path| file_name(path) == "_schema.yaml") + .expect("database schema file") + } + + fn database_dir(&self) -> PathBuf { + self.schema_file() + .parent() + .expect("database schema parent") + .to_path_buf() + } + + fn nested_markdown_file_containing(&self, needle: &str) -> PathBuf { + collect_files(&self.root) + .into_iter() + .filter(|path| { + path.extension().is_some_and(|extension| extension == "md") + && path.parent().is_some_and(|parent| parent != self.root) + }) + .find(|path| { + fs::read_to_string(path) + .map(|content| content.contains(needle)) + .unwrap_or(false) + }) + .expect("nested markdown file") + } } impl Drop for E2eFixture { @@ -480,6 +728,87 @@ impl LiveCleanup { self.block_ids.push(page.id.clone()); page } + + fn create_database(&mut self, parent_page_id: &str, title: &str) -> DatabaseDto { + let database = self + .api + .create_database(json!({ + "parent": { + "type": "page_id", + "page_id": parent_page_id, + }, + "title": rich_text_json(title), + "initial_data_source": { + "title": rich_text_json("Rows"), + "properties": { + "Name": { "title": {} }, + "Notes": { "rich_text": {} }, + "Points": { "number": { "format": "number" } }, + "Status": { + "select": { + "options": [ + { "name": "Todo", "color": "gray" }, + { "name": "Done", "color": "green" } + ] + } + }, + "State": { "status": {} }, + "Tags": { + "multi_select": { + "options": [ + { "name": "Alpha", "color": "blue" }, + { "name": "Beta", "color": "purple" } + ] + } + }, + "Done": { "checkbox": {} }, + "Due": { "date": {} }, + "URL": { "url": {} }, + "Email": { "email": {} }, + "Phone": { "phone_number": {} }, + "Files": { "files": {} }, + "People": { "people": {} }, + "Unique": { "unique_id": { "prefix": "AFS" } } + } + } + })) + .expect("create live database"); + self.block_ids.push(database.id.clone()); + database + } + + fn create_database_row( + &mut self, + database: &DatabaseDto, + title: &str, + mut properties: serde_json::Map, + children: Vec, + ) -> PageDto { + let data_source = database + .data_sources + .first() + .expect("created database data source"); + properties.insert( + "Name".to_string(), + json!({ "title": rich_text_json(title) }), + ); + let mut body = json!({ + "parent": { + "type": "data_source_id", + "data_source_id": data_source.id, + }, + "properties": Value::Object(properties), + }); + if !children.is_empty() { + body["children"] = Value::Array(children); + } + let page = self + .api + .create_page(body) + .expect("create live database row"); + self.block_ids.push(page.id.clone()); + page + } } impl Drop for LiveCleanup { @@ -542,6 +871,16 @@ fn live_block_snapshot(connector: &NotionConnector, page_id: &str) -> Value { serde_json::to_value(bundle.blocks).expect("snapshot json") } +fn live_page_snapshot(connector: &NotionConnector, page_id: &str) -> Value { + let native = connector + .fetch(FetchRequest { + remote_id: RemoteId::new(page_id.to_string()), + }) + .expect("fetch live page snapshot"); + let bundle: NotionPageBundle = serde_json::from_slice(&native.raw).expect("snapshot bundle"); + serde_json::to_value(bundle).expect("snapshot json") +} + fn render_live_page(connector: &NotionConnector, page_id: &str, page_path: &Path) -> String { let native = connector .fetch(FetchRequest { @@ -555,6 +894,19 @@ fn render_live_page(connector: &NotionConnector, page_id: &str, page_path: &Path .body } +fn render_live_markdown(connector: &NotionConnector, page_id: &str, page_path: &Path) -> String { + let native = connector + .fetch(FetchRequest { + remote_id: RemoteId::new(page_id.to_string()), + }) + .expect("fetch live page"); + let document = connector + .render_native_entity_for_path(&native, page_path) + .expect("render live page") + .document; + render_canonical_markdown(&document) +} + fn diverse_page_children(target_page_id: &str) -> Vec { vec![ json!({ @@ -780,6 +1132,69 @@ fn page_mention_part(label: &str, page_id: &str) -> Value { }) } +fn database_row_properties( + notes: &str, + points: &str, + status: &str, + state: &str, + done: bool, + url: &str, +) -> serde_json::Map { + serde_json::Map::from_iter([ + ( + "Notes".to_string(), + json!({ "rich_text": rich_text_json(notes) }), + ), + ( + "Points".to_string(), + json!({ "number": points.parse::().expect("points") }), + ), + ( + "Status".to_string(), + json!({ "select": { "name": status } }), + ), + ("State".to_string(), json!({ "status": { "name": state } })), + ( + "Tags".to_string(), + json!({ "multi_select": [{ "name": "Alpha" }, { "name": "Beta" }] }), + ), + ("Done".to_string(), json!({ "checkbox": done })), + ( + "Due".to_string(), + json!({ "date": { "start": "2026-06-13" } }), + ), + ("URL".to_string(), json!({ "url": url })), + ( + "Email".to_string(), + json!({ "email": "cyclic@example.com" }), + ), + ( + "Phone".to_string(), + json!({ "phone_number": "+1 415 555 0199" }), + ), + ]) +} + +fn collect_files(root: &Path) -> Vec { + let mut files = Vec::new(); + collect_files_into(root, &mut files); + files +} + +fn collect_files_into(path: &Path, files: &mut Vec) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_into(&path, files); + } else { + files.push(path); + } + } +} + #[derive(Debug)] struct MutableNotionApi { page: PageDto, diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 5e8d0aa..ee1863d 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -25,18 +25,23 @@ use crate::dto::{ use crate::fetch::fetch_page_bundle; pub fn check_concurrency(api: &dyn NotionApi, request: ApplyPlanRequest<'_>) -> AfsResult<()> { + let database_create_parent_ids = database_create_parent_ids(&request.plan.operations); for precondition in request.remote_preconditions { let Some(expected) = &precondition.remote_edited_at else { continue; }; - let page = api.retrieve_page(precondition.remote_id.as_str())?; - let actual = page - .last_edited_time - .as_deref() - .or(page.created_time.as_deref()) - .unwrap_or("unknown"); - - if actual != expected { + let actual = if database_create_parent_ids.contains(&precondition.remote_id) { + api.retrieve_database(precondition.remote_id.as_str())? + .last_edited_time + .unwrap_or_else(|| "unknown".to_string()) + } else { + let page = api.retrieve_page(precondition.remote_id.as_str())?; + page.last_edited_time + .or(page.created_time) + .unwrap_or_else(|| "unknown".to_string()) + }; + + if actual != *expected { return Err(AfsError::Guardrail(format!( "remote entity `{}` changed since last sync (expected remote_edited_at `{expected}`, found `{actual}`)", precondition.remote_id.0 @@ -256,6 +261,22 @@ fn create_parent_ids(operations: &[PushOperation]) -> BTreeSet { .collect() } +fn database_create_parent_ids(operations: &[PushOperation]) -> BTreeSet { + operations + .iter() + .filter_map(|operation| match operation { + PushOperation::CreateEntity { + parent_id, + parent_kind, + .. + } if !matches!(parent_kind, Some(afs_core::model::EntityKind::Page)) => { + Some(parent_id.clone()) + } + _ => None, + }) + .collect() +} + fn fetch_affected_bundles( api: &dyn NotionApi, affected_entities: &[RemoteId], diff --git a/crates/afs-notion/src/client.rs b/crates/afs-notion/src/client.rs index c16302a..ea58f43 100644 --- a/crates/afs-notion/src/client.rs +++ b/crates/afs-notion/src/client.rs @@ -52,6 +52,10 @@ pub trait NotionApi: std::fmt::Debug + Send + Sync { let _ = body; Err(AfsError::NotImplemented("create Notion page")) } + fn create_database(&self, body: serde_json::Value) -> AfsResult { + let _ = body; + Err(AfsError::NotImplemented("create Notion database")) + } fn retrieve_block_children( &self, block_id: &str, @@ -318,6 +322,10 @@ impl NotionApi for HttpNotionApi { self.post_json("/v1/pages", body) } + fn create_database(&self, body: serde_json::Value) -> AfsResult { + self.post_json("/v1/databases", body) + } + fn update_block(&self, block_id: &str, body: serde_json::Value) -> AfsResult { self.patch_json(&format!("/v1/blocks/{block_id}"), body) } diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index 7475f73..fcd58d5 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -4,7 +4,7 @@ use std::sync::Mutex; use afs_connector::{ApplyPlanRequest, Connector}; use afs_core::journal::{JournalApplyEffect, PushId, PushOperationId}; -use afs_core::model::{MountId, RemoteId}; +use afs_core::model::{EntityKind, MountId, RemoteId}; use afs_core::planner::{PropertyValue, PushOperation, PushPlan}; use afs_core::push::RemotePrecondition; use afs_core::{AfsError, AfsResult}; @@ -587,6 +587,39 @@ fn check_concurrency_rejects_remote_timestamp_mismatch() { assert!(matches!(error, AfsError::Guardrail(_))); } +#[test] +fn check_concurrency_uses_database_metadata_for_row_create_parent() { + let api = Arc::new(RecordingNotionApi::new("2026-06-10T00:00:00.000Z", false)); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("database-1")], + vec![PushOperation::CreateEntity { + parent_id: RemoteId::new("database-1"), + parent_kind: Some(EntityKind::Database), + title: "New row".to_string(), + properties: BTreeMap::new(), + body: String::new(), + source_path: "Rows/new-row.md".into(), + }], + ); + let push_id = PushId("push-1".to_string()); + let mount_id = MountId::new("notion-main"); + let preconditions = vec![RemotePrecondition { + remote_id: RemoteId::new("database-1"), + remote_edited_at: Some("2026-06-10T00:00:00.000Z".to_string()), + }]; + + connector + .check_concurrency(ApplyPlanRequest { + push_id: &push_id, + mount_id: &mount_id, + plan: &plan, + operation_ids: &[], + remote_preconditions: &preconditions, + }) + .expect("database parent concurrency check"); +} + #[test] fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { let api = Arc::new(RecordingNotionApi::with_paragraph_rich_text( @@ -1064,6 +1097,7 @@ impl RecordingNotionApi { id: "source-1".to_string(), name: Some("Tasks".to_string()), }], + last_edited_time: Some(last_edited_time.to_string()), ..Default::default() }; api.data_source = DataSourceDto { @@ -1115,6 +1149,7 @@ impl RecordingNotionApi { id: "source-1".to_string(), name: Some("Tasks".to_string()), }], + last_edited_time: Some("2026-06-10T00:00:00.000Z".to_string()), ..Default::default() }, data_source: DataSourceDto { diff --git a/docs/notion-cyclic-bug-journal.md b/docs/notion-cyclic-bug-journal.md index b38e657..dd195a1 100644 --- a/docs/notion-cyclic-bug-journal.md +++ b/docs/notion-cyclic-bug-journal.md @@ -45,3 +45,19 @@ the local symptom, and the fix made in the PR. Slugged and hyphenated Notion page IDs are still accepted. - **Verification:** The rich text apply test now includes an external URL with a UUID-shaped path and verifies it remains a normal linked text span. + +### Mounted Database Row Creation Fetched The Database As A Page + +- **Found by:** `live_cyclic_database_rows_mount_edit_create_and_verify_notion`. +- **Symptom:** Creating a new row by writing a Markdown file under a projected + database directory planned correctly, then failed during push with Notion's + "database, not a page" validation error. +- **Cause:** The push concurrency preflight always retrieved precondition + entities through the page API. For row creation, the affected entity is the + database parent, so the preflight must use database metadata. +- **Fix:** Concurrency checks now route `CreateEntity` parents with database + semantics through `retrieve_database` and continue to use `retrieve_page` for + normal page entities. +- **Verification:** Added a unit regression for database-parent concurrency + checks and a live mounted database test that creates a row from a new Markdown + file, then verifies the created row through the Notion API. diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 3e89c89..40525bb 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -31,3 +31,18 @@ and what Markdown shape agents should expect. - **Edit/push cycle:** The live test creates a supported-edit page, edits each supported Markdown block shape locally, pushes, and verifies the rendered Notion content through the Notion API. + +### Mounted Database Row Cycles + +- **Projection:** A live child database is mounted as a directory with + `_schema.yaml`; existing rows appear as Markdown files under that directory. +- **Read/no-op cycle:** The live test creates a database row with title, + rich-text, number, select, status, multi-select, checkbox, date, URL, email, + and phone properties. It hydrates the row file through the mount, performs a + no-op push, and verifies the Notion page bundle is unchanged. +- **Edit/push cycle:** The test edits row frontmatter and body from the mounted + Markdown file, pushes, and verifies the expected frontmatter/body render from + a fresh Notion API fetch. +- **Create cycle:** The test writes a new Markdown file under the database + directory, pushes it as a new Notion row, and verifies the created row's + properties and body through the Notion API. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index f5f2407..0531a60 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -17,7 +17,7 @@ Sources used for the baseline: | Page | Read, render, edit supported blocks, edit supported properties | fixture, live | Page body is block content; page metadata/properties are frontmatter. | | Block | Recursive read/render; write subset | fixture, live | Unsupported/lossy blocks render as anchored directives and are protected by directive validation. | | Database | Read/enumerate as directory | fixture, live | Database containers project to directories. | -| Data source | Read/query rows, render `_schema.yaml`, validate row property writes, create rows when database has exactly one data source | fixture, live | Multi-data-source row writes are intentionally blocked until path/schema selection exists. | +| Data source | Read/query rows, render `_schema.yaml`, validate row property writes, create rows when database has exactly one data source | fixture, live, mounted live | Multi-data-source row writes are intentionally blocked until path/schema selection exists. | | User | Read only when embedded in mentions/properties | fixture | User objects are not mounted as standalone files in v1. | | Comment | Unsupported | none | Comments are not in the v1 filesystem model from `plan.md`; adding them needs a thread representation and write policy. | | File upload | Unsupported for upload; external/download URLs are read | fixture, live image download | Uploading files needs retention, size, dedupe, and local path ownership policy. | @@ -89,17 +89,17 @@ Sources used for the baseline: | Property type | Read/frontmatter | Write | Tests | Notes | |---|---:|---:|---|---| -| `title` | Yes | Yes | fixture, live, schema | Title is the canonical `title` frontmatter field. | -| `rich_text` | Yes | Yes | fixture, live, schema | Written as plain rich text today. | -| `number` | Yes | Yes | fixture, live, schema | Numeric validation happens before API call. | -| `select` | Yes | Yes | fixture, live, schema | Option names must exist in `_schema.yaml`. | -| `status` | Yes | Yes | fixture, live, schema | Option names must exist in `_schema.yaml`. | -| `multi_select` | Yes | Yes | fixture, live, schema | List values must exist in `_schema.yaml`. | -| `checkbox` | Yes | Yes | fixture, live, schema | Boolean. | -| `date` | Yes | Yes | fixture, live, schema | String date or map with `start`/`end`/`time_zone`. | -| `url` | Yes | Yes | fixture, live, schema | Nullable HTTP/HTTPS string. | -| `email` | Yes | Yes | fixture, live, schema | Nullable email string. | -| `phone_number` | Yes | Yes | fixture, live, schema | Nullable string. | +| `title` | Yes | Yes | fixture, live, mounted live, schema | Title is the canonical `title` frontmatter field. | +| `rich_text` | Yes | Yes | fixture, live, mounted live, schema | Written as plain rich text today. | +| `number` | Yes | Yes | fixture, live, mounted live, schema | Numeric validation happens before API call. | +| `select` | Yes | Yes | fixture, live, mounted live, schema | Option names must exist in `_schema.yaml`. | +| `status` | Yes | Yes | fixture, live, mounted live, schema | Option names must exist in `_schema.yaml`. | +| `multi_select` | Yes | Yes | fixture, live, mounted live, schema | List values must exist in `_schema.yaml`. | +| `checkbox` | Yes | Yes | fixture, live, mounted live, schema | Boolean. | +| `date` | Yes | Yes | fixture, live, mounted live, schema | String date or map with `start`/`end`/`time_zone`. | +| `url` | Yes | Yes | fixture, live, mounted live, schema | Nullable HTTP/HTTPS string. | +| `email` | Yes | Yes | fixture, live, mounted live, schema | Nullable email string. | +| `phone_number` | Yes | Yes | fixture, live, mounted live, schema | Nullable string. | | `files` | Yes | No | fixture, live read-empty, schema-blocked | File upload/link ownership policy is not designed yet. | | `people` | Yes | No | fixture, live read-empty, schema-blocked | Needs user lookup and permission-aware validation before writes. | | `relation` | Yes | No | fixture, schema-blocked | Needs target data-source schema and path/ID resolution before writes. | From 0bf588daddf07bb77d3e33c10c77a15befc65c43 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 02:16:52 -0700 Subject: [PATCH 04/18] Render Notion URL blocks as editable Markdown links --- crates/afs-cli/tests/e2e_push_workflow.rs | 22 ++++++ crates/afs-notion/src/apply.rs | 18 +++++ crates/afs-notion/src/render.rs | 25 ++++++- crates/afs-notion/tests/apply.rs | 89 ++++++++++++++++++++++- crates/afs-notion/tests/fetch_render.rs | 8 +- crates/afs-notion/tests/live_integrity.rs | 13 +++- docs/notion-canonical-format.md | 4 +- docs/notion-connector.md | 7 +- docs/notion-cyclic-support-journal.md | 13 ++++ docs/notion-object-support.md | 4 +- 10 files changed, 188 insertions(+), 15 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index 75af111..5b0f0f0 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -255,6 +255,8 @@ fn live_cyclic_diverse_page_read_noop_preserves_notion() { "| Left | Right |", "[Linked page](https://www.notion.so/", "target mention [AFS cyclic link target", + "[Cyclic bookmark](https://example.com/cyclic-bookmark)", + "[Cyclic embed](https://example.com/cyclic-embed)", "![Cyclic image](https://www.w3.org/Icons/w3c_home.png)", "[Cyclic video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", "[Cyclic file](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", @@ -337,6 +339,14 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "> [!NOTE]\n> Editable callout", "> [!NOTE]\n> Editable callout changed", ) + .replace( + "[Editable bookmark](https://example.com/editable-bookmark)", + "[Editable bookmark changed](https://example.com/editable-bookmark-changed)", + ) + .replace( + "[Editable embed](https://example.com/editable-embed)", + "[Editable embed changed](https://example.com/editable-embed-changed)", + ) .replace("fn editable() {}", "fn editable_changed() {}") .replace("x+y=z", "x-y=z"); fs::write(&page_path, edited).expect("write cyclic edits"); @@ -386,6 +396,8 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "- [x] Editable todo changed", "> Editable quote changed", "> [!NOTE]\n> Editable callout changed", + "[Editable bookmark changed](https://example.com/editable-bookmark-changed)", + "[Editable embed changed](https://example.com/editable-embed-changed)", "fn editable_changed() {}", "x-y=z", ] { @@ -1043,6 +1055,16 @@ fn supported_edit_children() -> Vec { }), rich_text_child("quote", "Editable quote"), rich_text_child("callout", "Editable callout"), + json!({ + "object": "block", + "type": "bookmark", + "bookmark": { "url": "https://example.com/editable-bookmark", "caption": rich_text_json("Editable bookmark") } + }), + json!({ + "object": "block", + "type": "embed", + "embed": { "url": "https://example.com/editable-embed", "caption": rich_text_json("Editable embed") } + }), json!({ "object": "block", "type": "code", diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index ee1863d..4f79dfb 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -851,6 +851,24 @@ fn parse_supported_block( )); } + if let Some(kind @ ("bookmark" | "embed")) = current_kind + && let Some((label, href, consumed)) = parse_markdown_link(trimmed) + && consumed == trimmed.len() + { + let kind = match kind { + "bookmark" => "bookmark", + "embed" => "embed", + _ => unreachable!("matched URL block kind"), + }; + return Ok(NotionBlockPatch::new( + kind, + json!({ + "url": href, + "caption": rich_text_payload(label, preimage)?, + }), + )); + } + if looks_like_markdown_table(trimmed) { return Err(AfsError::Unsupported("writing Notion tables")); } diff --git a/crates/afs-notion/src/render.rs b/crates/afs-notion/src/render.rs index c10cf88..2ad3bdc 100644 --- a/crates/afs-notion/src/render.rs +++ b/crates/afs-notion/src/render.rs @@ -257,8 +257,8 @@ fn render_block(block: &BlockDto, options: &RenderOptions) -> RenderedBlock { "toggle", ), "equation" => equation_block(block, block.equation.as_ref()), - "embed" => url_directive_block(block, "embed", block.embed.as_ref()), - "bookmark" => url_directive_block(block, "bookmark", block.bookmark.as_ref()), + "embed" => url_markdown_block(block, "embed", block.embed.as_ref()), + "bookmark" => url_markdown_block(block, "bookmark", block.bookmark.as_ref()), "link_preview" => url_directive_block(block, "link_preview", block.link_preview.as_ref()), "image" => file_media_block(block, "image", block.image.as_ref(), options), "video" => file_media_block(block, "video", block.video.as_ref(), options), @@ -343,6 +343,27 @@ fn url_directive_block( directive_block_with_attrs(block, directive_type, attrs) } +fn url_markdown_block( + block: &BlockDto, + malformed_type: &'static str, + payload: Option<&UrlBlockDto>, +) -> RenderedBlock { + let Some(payload) = payload else { + return directive_block(block, &format!("malformed_{malformed_type}"), None); + }; + if payload.url.trim().is_empty() { + return directive_block(block, &format!("malformed_{malformed_type}"), None); + } + + let label = rich_text_list_title(&payload.caption) + .filter(|caption| !caption.trim().is_empty()) + .unwrap_or_else(|| payload.url.clone()); + rendered_block( + markdown_link_preserving_whitespace(&label, &payload.url), + Some(RemoteId::new(block.id.clone())), + ) +} + fn file_media_block( block: &BlockDto, media_type: &'static str, diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index fcd58d5..c52e1fb 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -13,7 +13,7 @@ use afs_notion::dto::{ BlockDto, BlockListDto, DataSourceDto, DataSourcePropertyDto, DataSourceSummaryDto, DatabaseDto, DateMentionDto, EquationBlockDto, LinkDto, MentionRichTextDto, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, RichTextAnnotationsDto, RichTextBlockDto, - RichTextDto, SelectOptionDto, TextRichTextDto, + RichTextDto, SelectOptionDto, TextRichTextDto, UrlBlockDto, }; use afs_notion::{NotionConfig, NotionConnector}; use serde_json::{Value, json}; @@ -757,6 +757,79 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { ); } +#[test] +fn apply_updates_bookmark_and_embed_blocks_from_markdown_links() { + let api = Arc::new(RecordingNotionApi::with_blocks( + "2026-06-10T00:00:00.000Z", + vec![ + url_block( + "bookmark-1", + "bookmark", + "https://example.com/original-bookmark", + "Original bookmark", + ), + url_block( + "embed-1", + "embed", + "https://example.com/original-embed", + "Original embed", + ), + ], + )); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("page-1")], + vec![ + PushOperation::UpdateBlock { + block_id: RemoteId::new("bookmark-1"), + content: "[Updated bookmark](https://example.com/updated-bookmark)".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("embed-1"), + content: "[Updated embed](https://example.com/updated-embed)".to_string(), + }, + ], + ); + let push_id = PushId("push-1".to_string()); + let operation_ids = operation_ids(&push_id, &plan); + let mount_id = MountId::new("notion-main"); + + connector + .apply(ApplyPlanRequest { + push_id: &push_id, + mount_id: &mount_id, + plan: &plan, + operation_ids: &operation_ids, + remote_preconditions: &[], + }) + .expect("apply URL block updates"); + + let writes = api.writes.lock().expect("writes"); + assert_eq!( + writes.as_slice(), + [ + WriteCall::Update { + block_id: "bookmark-1".to_string(), + body: json!({ + "bookmark": { + "url": "https://example.com/updated-bookmark", + "caption": rich_text_json("Updated bookmark"), + }, + }), + }, + WriteCall::Update { + block_id: "embed-1".to_string(), + body: json!({ + "embed": { + "url": "https://example.com/updated-embed", + "caption": rich_text_json("Updated embed"), + }, + }), + }, + ] + ); +} + #[test] fn apply_updates_supported_page_properties() { let api = Arc::new(RecordingNotionApi::with_page_properties( @@ -1412,6 +1485,20 @@ fn equation_block(id: &str, expression: &str) -> BlockDto { block } +fn url_block(id: &str, kind: &str, url: &str, caption: &str) -> BlockDto { + let mut block = block(id, kind); + let payload = Some(UrlBlockDto { + url: url.to_string(), + caption: rich_text(caption), + }); + match kind { + "bookmark" => block.bookmark = payload, + "embed" => block.embed = payload, + _ => {} + } + block +} + fn rich_text(text: &str) -> Vec { vec![rich_text_part(text)] } diff --git a/crates/afs-notion/tests/fetch_render.rs b/crates/afs-notion/tests/fetch_render.rs index ff705ff..64815ae 100644 --- a/crates/afs-notion/tests/fetch_render.rs +++ b/crates/afs-notion/tests/fetch_render.rs @@ -253,8 +253,8 @@ fn render_richer_notion_block_coverage() { "#### Heading four\n\n", "- Toggle summary\n\n", "$$\nE=mc^2\n$$\n\n", - "::afs{id=embed-1 type=embed title=\"Embed caption\" url=\"https://example.com/embed\"}\n\n", - "::afs{id=bookmark-1 type=bookmark title=\"Bookmark caption\" url=\"https://example.com/bookmark\"}\n\n", + "[Embed caption](https://example.com/embed)\n\n", + "[Bookmark caption](https://example.com/bookmark)\n\n", "![Image caption](https://example.com/image.png)\n\n", "::afs{id=synced-1 type=synced_block source_block_id=\"source-block-1\"}\n\n", "[Linked page](https://www.notion.so/target-page-1)\n\n", @@ -550,8 +550,8 @@ fn render_all_known_notion_block_objects_into_markdown_or_directives() { "- Toggle summary", " Toggle child", "$$\nE=mc^2\n$$", - "::afs{id=embed-1 type=embed title=\"Embed\" url=\"https://example.com/embed\"}", - "::afs{id=bookmark-1 type=bookmark title=\"Bookmark\" url=\"https://example.com/bookmark\"}", + "[Embed](https://example.com/embed)", + "[Bookmark](https://example.com/bookmark)", "::afs{id=link-preview-1 type=link_preview title=\"Preview\" url=\"https://example.com/preview\"}", "![Image](https://example.com/image.png)", "[Video](https://example.com/video.mp4)", diff --git a/crates/afs-notion/tests/live_integrity.rs b/crates/afs-notion/tests/live_integrity.rs index 49de471..2530c40 100644 --- a/crates/afs-notion/tests/live_integrity.rs +++ b/crates/afs-notion/tests/live_integrity.rs @@ -80,7 +80,18 @@ fn live_page_read_edit_write_verify_integrity_with_media_download() { assert!(rendered.document.body.contains( "[External audio](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3)" )); - assert!(rendered.document.body.contains("type=embed")); + assert!( + rendered + .document + .body + .contains("[https://example.com/embed](https://example.com/embed)") + ); + assert!( + rendered + .document + .body + .contains("[https://example.com/](https://example.com/)") + ); assert!(rendered.document.body.contains("type=table_of_contents")); assert!(rendered.document.body.contains("type=breadcrumb")); assert!(rendered.document.body.contains("type=column_list")); diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 603f6b8..a74fc16 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -27,7 +27,7 @@ Clean Markdown is preferred for diffable blocks. Undiffable or lossy blocks rend Directive integrity is validated before push. Agents may move directive lines as whole lines, but editing directive contents is rejected unless the change maps to an explicit supported operation. -The first renderer supports common text blocks, richer inline text, display equations, simple tables, and file-like media blocks with API URLs directly. Inline bold, italic, strikethrough, code, external links, date mentions, page/database mentions, link previews, and equations use ordinary Markdown or small HTML fallbacks when Markdown has no native equivalent. Child pages, child databases, toggles, embeds, bookmarks, synced blocks, column layouts, tabs, meeting notes, AI/custom blocks, URL-less media payloads, and unsupported/lossy blocks render as directives. This keeps the page inspectable while preserving remote block IDs for later safer round-trip support. +The first renderer supports common text blocks, richer inline text, display equations, simple tables, bookmark/embed URL blocks, and file-like media blocks with API URLs directly. Inline bold, italic, strikethrough, code, external links, date mentions, page/database mentions, link previews, and equations use ordinary Markdown or small HTML fallbacks when Markdown has no native equivalent. Child pages, child databases, toggles, synced blocks, column layouts, tabs, meeting notes, AI/custom blocks, URL-less media payloads, and unsupported/lossy blocks render as directives. This keeps the page inspectable while preserving remote block IDs for later safer round-trip support. Media blocks with a Notion `file.url` or `external.url` render as ordinary Markdown. Images use image syntax, while other file-like blocks use links: @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, and display equations. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, and existing bookmark/embed URL blocks. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 372a795..3ffaf66 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -18,10 +18,11 @@ The current implementation is a live-capable read, pull, and narrow write projec equations, display equations, and heading levels 1-4 render to Markdown where there is a stable textual representation; - simple Notion tables render as Markdown tables with table-row IDs retained in shadow metadata; -- toggles, embeds, bookmarks, synced blocks, column layouts, tabs, meeting notes, +- toggles, synced blocks, column layouts, tabs, meeting notes, AI/custom blocks, URL-less media payloads, and unsupported or lossy blocks render as `::afs{...}` directives so they retain remote identity and useful metadata such as title, URL, source block ID, or target page ID when the API exposes it. +- bookmark/embed URL blocks render as ordinary Markdown links. - media blocks with a Notion URL render as ordinary Markdown image or link syntax, while still keeping local media download metadata in the rendered entity for filesystem-aware callers. - `afs push -y` can update, append, and archive simple Notion blocks, update supported page @@ -93,7 +94,7 @@ GitHub Actions has a manual `notion-live-e2e` workflow for these tests. The work ## Initial Block Rendering -The renderer currently supports paragraphs, headings 1-4, bulleted/numbered list items, to-dos, quotes, callouts, code blocks, simple tables, dividers, display equations, and media blocks with URLs as Markdown. It renders child pages/databases, toggles, embeds, bookmarks, synced blocks, column layouts, tabs, table of contents, breadcrumbs, link-to-page blocks, meeting notes, AI/custom blocks, URL-less media payloads, and unknown future blocks as anchored directives. +The renderer currently supports paragraphs, headings 1-4, bulleted/numbered list items, to-dos, quotes, callouts, code blocks, simple tables, dividers, display equations, bookmark/embed URL blocks, and media blocks with URLs as Markdown. It renders child pages/databases, toggles, synced blocks, column layouts, tabs, table of contents, breadcrumbs, meeting notes, AI/custom blocks, URL-less media payloads, and unknown future blocks as anchored directives. Inline rich text is represented with Notion DTOs first, then rendered through one Markdown path: @@ -110,7 +111,7 @@ Nested children are fetched recursively and rendered after their parent, except The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; -- supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, and display equations; +- supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, and existing bookmark/embed URL blocks; - supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, legacy `afs://` page links, and unchanged preimage mentions such as dates; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, and phone; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 40525bb..1c9bbb1 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -46,3 +46,16 @@ and what Markdown shape agents should expect. - **Create cycle:** The test writes a new Markdown file under the database directory, pushes it as a new Notion row, and verifies the created row's properties and body through the Notion API. + +### Bookmark And Embed URL Blocks + +- **Notion input:** `bookmark` and `embed` blocks with URL and optional caption. +- **Markdown output:** Valid blocks render as normal Markdown links: + - `[Bookmark caption](https://example.com/bookmark)` + - `[Embed caption](https://example.com/embed)` +- **Write behavior:** Existing bookmark/embed blocks can be edited by changing + the Markdown link label or URL. A malformed URL block with no URL still falls + back to an AFS directive instead of becoming lossy Markdown. +- **Verification:** Fixture apply tests assert the exact Notion update payloads, + and the live mounted edit cycle updates bookmark/embed links then verifies the + rendered Notion result through the API. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 0531a60..f8bee86 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -47,8 +47,8 @@ Sources used for the baseline: | `child_page` | Directive and structural enumeration | No direct block write | fixture, live read | New child pages are created through page/entity creation, not block edits. | | `child_database` | Directive and structural enumeration | No direct block write | fixture, live read | Databases are created through the database API, not Markdown block writes. | | `toggle` | Directive wrapper; children render below it | No | fixture, live read | Toggle wrapper state is anchored to avoid flattening nested content. | -| `embed` | Directive | No | fixture, live read | URL preserved. | -| `bookmark` | Directive | No | fixture, live read | URL preserved. | +| `embed` | Markdown link | Yes for existing blocks | fixture, live read/write | Caption becomes link text; URL edits update the existing embed block. | +| `bookmark` | Markdown link | Yes for existing blocks | fixture, live read/write | Caption becomes link text; URL edits update the existing bookmark block. | | `link_preview` | Directive | No | fixture | URL preserved when returned by the API; the current create-page API rejected it as a child block in live testing. | | `image` | Markdown image plus local image download | No | fixture, live read/download | Uses `external.url` or Notion-hosted `file.url`; URL-less payloads fall back to directives. | | `video` | Markdown link | No | fixture, live read | Uses `external.url` or Notion-hosted `file.url`; local download intentionally skipped for now. | From faf7f88dc25072fc726ff2489fa04f513fd8aa28 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 02:56:44 -0700 Subject: [PATCH 05/18] Support external media URL block edits --- crates/afs-cli/tests/e2e_push_workflow.rs | 50 +++++++ crates/afs-notion/src/apply.rs | 53 +++++++ crates/afs-notion/tests/apply.rs | 168 +++++++++++++++++++++- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 2 +- docs/notion-cyclic-support-journal.md | 19 +++ docs/notion-object-support.md | 10 +- 7 files changed, 294 insertions(+), 10 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index 5b0f0f0..a6c8b8e 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -347,6 +347,26 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "[Editable embed](https://example.com/editable-embed)", "[Editable embed changed](https://example.com/editable-embed-changed)", ) + .replace( + "![Editable image](https://www.w3.org/Icons/w3c_home.png)", + "![Editable image changed](https://www.w3.org/Icons/w3c_home.png)", + ) + .replace( + "[Editable video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", + "[Editable video changed](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", + ) + .replace( + "[Editable file](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + "[Editable file changed](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + ) + .replace( + "[Editable PDF](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + "[Editable PDF changed](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + ) + .replace( + "[Editable audio](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3)", + "[Editable audio changed](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3)", + ) .replace("fn editable() {}", "fn editable_changed() {}") .replace("x+y=z", "x-y=z"); fs::write(&page_path, edited).expect("write cyclic edits"); @@ -398,6 +418,11 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "> [!NOTE]\n> Editable callout changed", "[Editable bookmark changed](https://example.com/editable-bookmark-changed)", "[Editable embed changed](https://example.com/editable-embed-changed)", + "![Editable image changed](https://www.w3.org/Icons/w3c_home.png)", + "[Editable video changed](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", + "[Editable file changed](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + "[Editable PDF changed](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + "[Editable audio changed](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3)", "fn editable_changed() {}", "x-y=z", ] { @@ -1065,6 +1090,31 @@ fn supported_edit_children() -> Vec { "type": "embed", "embed": { "url": "https://example.com/editable-embed", "caption": rich_text_json("Editable embed") } }), + media_child( + "image", + "https://www.w3.org/Icons/w3c_home.png", + "Editable image", + ), + media_child( + "video", + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "Editable video", + ), + media_child( + "file", + "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", + "Editable file", + ), + media_child( + "pdf", + "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", + "Editable PDF", + ), + media_child( + "audio", + "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", + "Editable audio", + ), json!({ "object": "block", "type": "code", diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 4f79dfb..64d369f 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -720,6 +720,16 @@ fn current_block_rich_text(block: &BlockDto) -> AfsResult .map(|block| block.rich_text.as_slice()), "to_do" => block.to_do.as_ref().map(|block| block.rich_text.as_slice()), "code" => block.code.as_ref().map(|block| block.rich_text.as_slice()), + "bookmark" => block + .bookmark + .as_ref() + .map(|block| block.caption.as_slice()), + "embed" => block.embed.as_ref().map(|block| block.caption.as_slice()), + "image" => block.image.as_ref().map(|block| block.caption.as_slice()), + "video" => block.video.as_ref().map(|block| block.caption.as_slice()), + "file" => block.file.as_ref().map(|block| block.caption.as_slice()), + "pdf" => block.pdf.as_ref().map(|block| block.caption.as_slice()), + "audio" => block.audio.as_ref().map(|block| block.caption.as_slice()), "divider" | "equation" => return Ok(None), _ => return Ok(None), } @@ -869,6 +879,28 @@ fn parse_supported_block( )); } + if let Some(kind @ ("image" | "video" | "file" | "pdf" | "audio")) = current_kind + && let Some((label, href)) = parse_media_markdown(kind, trimmed) + { + let kind = match kind { + "image" => "image", + "video" => "video", + "file" => "file", + "pdf" => "pdf", + "audio" => "audio", + _ => unreachable!("matched media block kind"), + }; + return Ok(NotionBlockPatch::new( + kind, + json!({ + "external": { + "url": href, + }, + "caption": rich_text_payload(label, preimage)?, + }), + )); + } + if looks_like_markdown_table(trimmed) { return Err(AfsError::Unsupported("writing Notion tables")); } @@ -1277,6 +1309,9 @@ fn find_closing(input: &str, start: usize, marker: &str) -> Option { } fn parse_markdown_link(input: &str) -> Option<(&str, &str, usize)> { + if !input.starts_with('[') { + return None; + } let label_end = input.find("](")?; let href_start = label_end + 2; let href_end = input[href_start..] @@ -1289,6 +1324,24 @@ fn parse_markdown_link(input: &str) -> Option<(&str, &str, usize)> { )) } +fn parse_media_markdown<'a>(kind: &str, input: &'a str) -> Option<(&'a str, &'a str)> { + let (label, href, consumed) = match kind { + "image" => { + let link = input.strip_prefix('!')?; + let (label, href, consumed) = parse_markdown_link(link)?; + (label, href, consumed + 1) + } + "video" | "file" | "pdf" | "audio" => parse_markdown_link(input)?, + _ => return None, + }; + + if consumed == input.len() { + Some((label, href)) + } else { + None + } +} + fn notion_page_id_from_href(href: &str) -> Option { if let Some(id) = href.strip_prefix("afs://") { return Some(id.to_string()); diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index c52e1fb..a062835 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -11,9 +11,10 @@ use afs_core::{AfsError, AfsResult}; use afs_notion::client::NotionApi; use afs_notion::dto::{ BlockDto, BlockListDto, DataSourceDto, DataSourcePropertyDto, DataSourceSummaryDto, - DatabaseDto, DateMentionDto, EquationBlockDto, LinkDto, MentionRichTextDto, PageDto, - PageListDto, PagePropertyDto, PaginatedListDto, RichTextAnnotationsDto, RichTextBlockDto, - RichTextDto, SelectOptionDto, TextRichTextDto, UrlBlockDto, + DatabaseDto, DateMentionDto, EquationBlockDto, ExternalFileDto, FileBlockDto, LinkDto, + MentionRichTextDto, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, + RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, TextRichTextDto, + UrlBlockDto, }; use afs_notion::{NotionConfig, NotionConnector}; use serde_json::{Value, json}; @@ -830,6 +831,146 @@ fn apply_updates_bookmark_and_embed_blocks_from_markdown_links() { ); } +#[test] +fn apply_updates_external_media_blocks_from_markdown_links() { + let api = Arc::new(RecordingNotionApi::with_blocks( + "2026-06-10T00:00:00.000Z", + vec![ + media_block( + "image-1", + "image", + "https://example.com/original-image.png", + "Original image", + ), + media_block( + "video-1", + "video", + "https://example.com/original-video.mp4", + "Original video", + ), + media_block( + "file-1", + "file", + "https://example.com/original-file.pdf", + "Original file", + ), + media_block( + "pdf-1", + "pdf", + "https://example.com/original.pdf", + "Original PDF", + ), + media_block( + "audio-1", + "audio", + "https://example.com/original-audio.mp3", + "Original audio", + ), + ], + )); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("page-1")], + vec![ + PushOperation::UpdateBlock { + block_id: RemoteId::new("image-1"), + content: "![Updated image](https://example.com/updated-image.png)".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("video-1"), + content: "[Updated video](https://example.com/updated-video.mp4)".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("file-1"), + content: "[Updated file](https://example.com/updated-file.pdf)".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("pdf-1"), + content: "[Updated PDF](https://example.com/updated.pdf)".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("audio-1"), + content: "[Updated audio](https://example.com/updated-audio.mp3)".to_string(), + }, + ], + ); + let push_id = PushId("push-1".to_string()); + let operation_ids = operation_ids(&push_id, &plan); + let mount_id = MountId::new("notion-main"); + + connector + .apply(ApplyPlanRequest { + push_id: &push_id, + mount_id: &mount_id, + plan: &plan, + operation_ids: &operation_ids, + remote_preconditions: &[], + }) + .expect("apply external media block updates"); + + let writes = api.writes.lock().expect("writes"); + assert_eq!( + writes.as_slice(), + [ + WriteCall::Update { + block_id: "image-1".to_string(), + body: json!({ + "image": { + "external": { + "url": "https://example.com/updated-image.png", + }, + "caption": rich_text_json("Updated image"), + }, + }), + }, + WriteCall::Update { + block_id: "video-1".to_string(), + body: json!({ + "video": { + "external": { + "url": "https://example.com/updated-video.mp4", + }, + "caption": rich_text_json("Updated video"), + }, + }), + }, + WriteCall::Update { + block_id: "file-1".to_string(), + body: json!({ + "file": { + "external": { + "url": "https://example.com/updated-file.pdf", + }, + "caption": rich_text_json("Updated file"), + }, + }), + }, + WriteCall::Update { + block_id: "pdf-1".to_string(), + body: json!({ + "pdf": { + "external": { + "url": "https://example.com/updated.pdf", + }, + "caption": rich_text_json("Updated PDF"), + }, + }), + }, + WriteCall::Update { + block_id: "audio-1".to_string(), + body: json!({ + "audio": { + "external": { + "url": "https://example.com/updated-audio.mp3", + }, + "caption": rich_text_json("Updated audio"), + }, + }), + }, + ] + ); +} + #[test] fn apply_updates_supported_page_properties() { let api = Arc::new(RecordingNotionApi::with_page_properties( @@ -1499,6 +1640,27 @@ fn url_block(id: &str, kind: &str, url: &str, caption: &str) -> BlockDto { block } +fn media_block(id: &str, kind: &str, url: &str, caption: &str) -> BlockDto { + let mut block = block(id, kind); + let payload = Some(FileBlockDto { + kind: "external".to_string(), + external: Some(ExternalFileDto { + url: url.to_string(), + }), + file: None, + caption: rich_text(caption), + }); + match kind { + "image" => block.image = payload, + "video" => block.video = payload, + "file" => block.file = payload, + "pdf" => block.pdf = payload, + "audio" => block.audio = payload, + _ => {} + } + block +} + fn rich_text(text: &str) -> Vec { vec![rich_text_part(text)] } diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index a74fc16..e192c2e 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, and existing bookmark/embed URL blocks. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 3ffaf66..094aa3a 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -111,7 +111,7 @@ Nested children are fetched recursively and rendered after their parent, except The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; -- supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, and existing bookmark/embed URL blocks; +- supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, existing bookmark/embed URL blocks, and existing URL-backed media blocks; - supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, legacy `afs://` page links, and unchanged preimage mentions such as dates; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, and phone; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 1c9bbb1..247b920 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -59,3 +59,22 @@ and what Markdown shape agents should expect. - **Verification:** Fixture apply tests assert the exact Notion update payloads, and the live mounted edit cycle updates bookmark/embed links then verifies the rendered Notion result through the API. + +### External Media URL Blocks + +- **Notion input:** `image`, `video`, `file`, `pdf`, and `audio` blocks with + `external.url` or Notion-hosted `file.url` plus optional captions. +- **Markdown output:** Images render as Markdown image syntax; other media + blocks render as Markdown links: + - `![Image caption](https://example.com/image.png)` + - `[File caption](https://example.com/file.pdf)` +- **Write behavior:** Existing media blocks can be edited by changing the + Markdown label or URL. Writes use Notion external media URLs; local uploads, + new media block appends, and local file attachment ownership remain deferred. +- **Verification:** Fixture apply tests assert exact update payloads for every + media kind. The live mounted edit cycle updates media captions, pushes them, + and verifies the rendered Notion result through the API. +- **Bug fixed during live testing:** The initial writer reused the create-block + media payload shape and sent `type: external` during block updates. The live + Notion update endpoint rejected that field, so the update payload now sends + only the nested `external.url` and `caption` fields for media block updates. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index f8bee86..35847d4 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -50,11 +50,11 @@ Sources used for the baseline: | `embed` | Markdown link | Yes for existing blocks | fixture, live read/write | Caption becomes link text; URL edits update the existing embed block. | | `bookmark` | Markdown link | Yes for existing blocks | fixture, live read/write | Caption becomes link text; URL edits update the existing bookmark block. | | `link_preview` | Directive | No | fixture | URL preserved when returned by the API; the current create-page API rejected it as a child block in live testing. | -| `image` | Markdown image plus local image download | No | fixture, live read/download | Uses `external.url` or Notion-hosted `file.url`; URL-less payloads fall back to directives. | -| `video` | Markdown link | No | fixture, live read | Uses `external.url` or Notion-hosted `file.url`; local download intentionally skipped for now. | -| `file` | Markdown link | No | fixture, live read | Uses `external.url` or Notion-hosted `file.url`; local download intentionally skipped for now. | -| `pdf` | Markdown link | No | fixture, live read | Uses `external.url` or Notion-hosted `file.url`; local download intentionally skipped for now. | -| `audio` | Markdown link | No | fixture, live read | Uses `external.url` or Notion-hosted `file.url`; local download intentionally skipped for now. | +| `image` | Markdown image plus local image download | Yes for existing URL blocks | fixture, live read/write/download | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. URL-less payloads fall back to directives. | +| `video` | Markdown link | Yes for existing URL blocks | fixture, live read/write | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. Local download intentionally skipped for now. | +| `file` | Markdown link | Yes for existing URL blocks | fixture, live read/write | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. Local download intentionally skipped for now. | +| `pdf` | Markdown link | Yes for existing URL blocks | fixture, live read/write | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. Local download intentionally skipped for now. | +| `audio` | Markdown link | Yes for existing URL blocks | fixture, live read/write | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. Local download intentionally skipped for now. | | `synced_block` | Directive wrapper; source block ID preserved when present | No | fixture | Rewriting synced blocks is lossy without source/copy semantics; live creation of an original synced block was rejected because Notion requires `synced_from`. | | `link_to_page` | Markdown link to Notion URL | Read/delete/move only | fixture, live read | Page/database target ID is preserved in the link target; direct retargeting is not a supported edit yet. | | `table_of_contents` | Directive | No | fixture, live read | Generated navigation block; no useful Markdown edit surface. | From 3e194101bfde1132cfd2b9970a4d56f8be2c1404 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 03:09:11 -0700 Subject: [PATCH 06/18] Verify live media URL edits --- crates/afs-cli/tests/e2e_push_workflow.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index a6c8b8e..bcbd350 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -349,23 +349,23 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { ) .replace( "![Editable image](https://www.w3.org/Icons/w3c_home.png)", - "![Editable image changed](https://www.w3.org/Icons/w3c_home.png)", + "![Editable image changed](https://www.w3.org/assets/logos/w3c-2025-transitional/w3c-72x48.png)", ) .replace( "[Editable video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", - "[Editable video changed](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", + "[Editable video changed](https://www.youtube.com/watch?v=oHg5SJYRHA0)", ) .replace( "[Editable file](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", - "[Editable file changed](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + "[Editable file changed](https://www.orimi.com/pdf-test.pdf)", ) .replace( "[Editable PDF](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", - "[Editable PDF changed](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", + "[Editable PDF changed](https://www.orimi.com/pdf-test.pdf)", ) .replace( "[Editable audio](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3)", - "[Editable audio changed](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3)", + "[Editable audio changed](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3)", ) .replace("fn editable() {}", "fn editable_changed() {}") .replace("x+y=z", "x-y=z"); @@ -418,11 +418,11 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "> [!NOTE]\n> Editable callout changed", "[Editable bookmark changed](https://example.com/editable-bookmark-changed)", "[Editable embed changed](https://example.com/editable-embed-changed)", - "![Editable image changed](https://www.w3.org/Icons/w3c_home.png)", - "[Editable video changed](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", - "[Editable file changed](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", - "[Editable PDF changed](https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf)", - "[Editable audio changed](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3)", + "![Editable image changed](https://www.w3.org/assets/logos/w3c-2025-transitional/w3c-72x48.png)", + "[Editable video changed](https://www.youtube.com/watch?v=oHg5SJYRHA0)", + "[Editable file changed](https://www.orimi.com/pdf-test.pdf)", + "[Editable PDF changed](https://www.orimi.com/pdf-test.pdf)", + "[Editable audio changed](https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3)", "fn editable_changed() {}", "x-y=z", ] { From 572d745acb3c6e4bc002ce44eb8d841f23e3c31a Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 05:26:50 -0500 Subject: [PATCH 07/18] Render link previews as markdown links --- crates/afs-notion/src/render.rs | 25 +------------------------ crates/afs-notion/tests/fetch_render.rs | 2 +- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 4 ++-- docs/notion-cyclic-support-journal.md | 14 ++++++++++++++ docs/notion-object-support.md | 2 +- 6 files changed, 20 insertions(+), 29 deletions(-) diff --git a/crates/afs-notion/src/render.rs b/crates/afs-notion/src/render.rs index 2ad3bdc..5deae6f 100644 --- a/crates/afs-notion/src/render.rs +++ b/crates/afs-notion/src/render.rs @@ -259,7 +259,7 @@ fn render_block(block: &BlockDto, options: &RenderOptions) -> RenderedBlock { "equation" => equation_block(block, block.equation.as_ref()), "embed" => url_markdown_block(block, "embed", block.embed.as_ref()), "bookmark" => url_markdown_block(block, "bookmark", block.bookmark.as_ref()), - "link_preview" => url_directive_block(block, "link_preview", block.link_preview.as_ref()), + "link_preview" => url_markdown_block(block, "link_preview", block.link_preview.as_ref()), "image" => file_media_block(block, "image", block.image.as_ref(), options), "video" => file_media_block(block, "video", block.video.as_ref(), options), "file" => file_media_block(block, "file", block.file.as_ref(), options), @@ -327,22 +327,6 @@ fn equation_block(block: &BlockDto, equation: Option<&EquationBlockDto>) -> Rend ) } -fn url_directive_block( - block: &BlockDto, - directive_type: &'static str, - payload: Option<&UrlBlockDto>, -) -> RenderedBlock { - let attrs = payload - .map(|payload| { - directive_attrs( - rich_text_list_title(&payload.caption).map(|title| ("title", title)), - Some(("url", payload.url.clone())), - ) - }) - .unwrap_or_default(); - directive_block_with_attrs(block, directive_type, attrs) -} - fn url_markdown_block( block: &BlockDto, malformed_type: &'static str, @@ -521,13 +505,6 @@ fn indent_rendered_block(mut block: RenderedBlock, indent_level: usize) -> Rende block } -fn directive_attrs( - first: Option<(&'static str, String)>, - second: Option<(&'static str, String)>, -) -> Vec<(&'static str, String)> { - first.into_iter().chain(second).collect() -} - fn rich_text_block_title(block: &RichTextBlockDto) -> Option { rich_text_list_title(&block.rich_text) } diff --git a/crates/afs-notion/tests/fetch_render.rs b/crates/afs-notion/tests/fetch_render.rs index 64815ae..17ec2fa 100644 --- a/crates/afs-notion/tests/fetch_render.rs +++ b/crates/afs-notion/tests/fetch_render.rs @@ -552,7 +552,7 @@ fn render_all_known_notion_block_objects_into_markdown_or_directives() { "$$\nE=mc^2\n$$", "[Embed](https://example.com/embed)", "[Bookmark](https://example.com/bookmark)", - "::afs{id=link-preview-1 type=link_preview title=\"Preview\" url=\"https://example.com/preview\"}", + "[Preview](https://example.com/preview)", "![Image](https://example.com/image.png)", "[Video](https://example.com/video.mp4)", "[File](https://example.com/file.txt)", diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index e192c2e..3501ff8 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -27,7 +27,7 @@ Clean Markdown is preferred for diffable blocks. Undiffable or lossy blocks rend Directive integrity is validated before push. Agents may move directive lines as whole lines, but editing directive contents is rejected unless the change maps to an explicit supported operation. -The first renderer supports common text blocks, richer inline text, display equations, simple tables, bookmark/embed URL blocks, and file-like media blocks with API URLs directly. Inline bold, italic, strikethrough, code, external links, date mentions, page/database mentions, link previews, and equations use ordinary Markdown or small HTML fallbacks when Markdown has no native equivalent. Child pages, child databases, toggles, synced blocks, column layouts, tabs, meeting notes, AI/custom blocks, URL-less media payloads, and unsupported/lossy blocks render as directives. This keeps the page inspectable while preserving remote block IDs for later safer round-trip support. +The first renderer supports common text blocks, richer inline text, display equations, simple tables, bookmark/embed/link-preview URL blocks, and file-like media blocks with API URLs directly. Inline bold, italic, strikethrough, code, external links, date mentions, page/database mentions, link previews, and equations use ordinary Markdown or small HTML fallbacks when Markdown has no native equivalent. Child pages, child databases, toggles, synced blocks, column layouts, tabs, meeting notes, AI/custom blocks, URL-less media payloads, and unsupported/lossy blocks render as directives. This keeps the page inspectable while preserving remote block IDs for later safer round-trip support. Media blocks with a Notion `file.url` or `external.url` render as ordinary Markdown. Images use image syntax, while other file-like blocks use links: diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 094aa3a..c3f9c86 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -22,7 +22,7 @@ The current implementation is a live-capable read, pull, and narrow write projec AI/custom blocks, URL-less media payloads, and unsupported or lossy blocks render as `::afs{...}` directives so they retain remote identity and useful metadata such as title, URL, source block ID, or target page ID when the API exposes it. -- bookmark/embed URL blocks render as ordinary Markdown links. +- bookmark/embed/link-preview URL blocks render as ordinary Markdown links. - media blocks with a Notion URL render as ordinary Markdown image or link syntax, while still keeping local media download metadata in the rendered entity for filesystem-aware callers. - `afs push -y` can update, append, and archive simple Notion blocks, update supported page @@ -94,7 +94,7 @@ GitHub Actions has a manual `notion-live-e2e` workflow for these tests. The work ## Initial Block Rendering -The renderer currently supports paragraphs, headings 1-4, bulleted/numbered list items, to-dos, quotes, callouts, code blocks, simple tables, dividers, display equations, bookmark/embed URL blocks, and media blocks with URLs as Markdown. It renders child pages/databases, toggles, synced blocks, column layouts, tabs, table of contents, breadcrumbs, meeting notes, AI/custom blocks, URL-less media payloads, and unknown future blocks as anchored directives. +The renderer currently supports paragraphs, headings 1-4, bulleted/numbered list items, to-dos, quotes, callouts, code blocks, simple tables, dividers, display equations, bookmark/embed/link-preview URL blocks, and media blocks with URLs as Markdown. It renders child pages/databases, toggles, synced blocks, column layouts, tabs, table of contents, breadcrumbs, meeting notes, AI/custom blocks, URL-less media payloads, and unknown future blocks as anchored directives. Inline rich text is represented with Notion DTOs first, then rendered through one Markdown path: diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 247b920..6499f18 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -78,3 +78,17 @@ and what Markdown shape agents should expect. media payload shape and sent `type: external` during block updates. The live Notion update endpoint rejected that field, so the update payload now sends only the nested `external.url` and `caption` fields for media block updates. + +### Link Preview Blocks + +- **Notion input:** `link_preview` blocks with a returned URL and optional + caption/title text. +- **Markdown output:** Link previews render as normal Markdown links, matching + bookmark/embed readability without exposing an AFS directive for URL-shaped + content: + - `[Preview](https://example.com/preview)` +- **Write behavior:** Link preview writes remain blocked. Live create-page + testing rejected `link_preview` as a child block, so AFS does not yet have a + safe write or append contract for this block type. +- **Verification:** Fixture render coverage asserts that a returned + `link_preview` block renders to Markdown link syntax. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 35847d4..808d162 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -49,7 +49,7 @@ Sources used for the baseline: | `toggle` | Directive wrapper; children render below it | No | fixture, live read | Toggle wrapper state is anchored to avoid flattening nested content. | | `embed` | Markdown link | Yes for existing blocks | fixture, live read/write | Caption becomes link text; URL edits update the existing embed block. | | `bookmark` | Markdown link | Yes for existing blocks | fixture, live read/write | Caption becomes link text; URL edits update the existing bookmark block. | -| `link_preview` | Directive | No | fixture | URL preserved when returned by the API; the current create-page API rejected it as a child block in live testing. | +| `link_preview` | Markdown link | Read only | fixture | Renders as a normal link when the API returns a URL; the current create-page API rejected it as a child block in live testing, so writes stay blocked. | | `image` | Markdown image plus local image download | Yes for existing URL blocks | fixture, live read/write/download | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. URL-less payloads fall back to directives. | | `video` | Markdown link | Yes for existing URL blocks | fixture, live read/write | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. Local download intentionally skipped for now. | | `file` | Markdown link | Yes for existing URL blocks | fixture, live read/write | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. Local download intentionally skipped for now. | From 99390cd58092e96aca6c2903ea35319a3fe49240 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 06:04:58 -0500 Subject: [PATCH 08/18] Support same-shape Notion table edits --- crates/afs-cli/tests/e2e_push_workflow.rs | 36 +++- crates/afs-core/tests/block_diff.rs | 27 +++ crates/afs-notion/src/apply.rs | 211 +++++++++++++++++++++- crates/afs-notion/tests/apply.rs | 136 +++++++++++++- crates/afs-notion/tests/live_integrity.rs | 18 +- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 4 +- docs/notion-cyclic-support-journal.md | 21 +++ docs/notion-object-support.md | 10 +- 9 files changed, 452 insertions(+), 13 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index bcbd350..4d9607f 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -339,6 +339,10 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "> [!NOTE]\n> Editable callout", "> [!NOTE]\n> Editable callout changed", ) + .replace( + "| Editable table item | Editable table state |", + "| Editable table item changed | Editable table state done |", + ) .replace( "[Editable bookmark](https://example.com/editable-bookmark)", "[Editable bookmark changed](https://example.com/editable-bookmark-changed)", @@ -416,6 +420,7 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "- [x] Editable todo changed", "> Editable quote changed", "> [!NOTE]\n> Editable callout changed", + "| Editable table item changed | Editable table state done |", "[Editable bookmark changed](https://example.com/editable-bookmark-changed)", "[Editable embed changed](https://example.com/editable-embed-changed)", "![Editable image changed](https://www.w3.org/assets/logos/w3c-2025-transitional/w3c-72x48.png)", @@ -767,6 +772,7 @@ impl LiveCleanup { } fn create_database(&mut self, parent_page_id: &str, title: &str) -> DatabaseDto { + let unique_prefix = unique_id_prefix(); let database = self .api .create_database(json!({ @@ -805,7 +811,7 @@ impl LiveCleanup { "Phone": { "phone_number": {} }, "Files": { "files": {} }, "People": { "people": {} }, - "Unique": { "unique_id": { "prefix": "AFS" } } + "Unique": { "unique_id": { "prefix": unique_prefix } } } } })) @@ -1090,6 +1096,19 @@ fn supported_edit_children() -> Vec { "type": "embed", "embed": { "url": "https://example.com/editable-embed", "caption": rich_text_json("Editable embed") } }), + json!({ + "object": "block", + "type": "table", + "table": { + "table_width": 2, + "has_column_header": true, + "has_row_header": false, + "children": [ + table_row_child("Editable table name", "Editable table status"), + table_row_child("Editable table item", "Editable table state") + ] + } + }), media_child( "image", "https://www.w3.org/Icons/w3c_home.png", @@ -1518,6 +1537,21 @@ fn unique_suffix() -> String { format!("{}-{nanos}", std::process::id()) } +fn unique_id_prefix() -> String { + let mut value = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut prefix = String::new(); + for _ in 0..6 { + let index = (value % alphabet.len() as u128) as usize; + prefix.push(alphabet[index] as char); + value /= alphabet.len() as u128; + } + prefix +} + fn file_name(path: &Path) -> &str { path.file_name() .and_then(|name| name.to_str()) diff --git a/crates/afs-core/tests/block_diff.rs b/crates/afs-core/tests/block_diff.rs index f0f43b3..99dab3e 100644 --- a/crates/afs-core/tests/block_diff.rs +++ b/crates/afs-core/tests/block_diff.rs @@ -197,6 +197,33 @@ fn residual_alignment_updates_same_kind_sequence_without_archive_recreate() { ); } +#[test] +fn editing_a_rendered_table_produces_table_block_update() { + let mut shadow = shadow( + "| Name | Status |\n| --- | --- |\n| Old task | Todo |", + ["table-1"], + ); + shadow.blocks[0].kind = MarkdownBlockKind::TableWithRows { + row_ids: vec![RemoteId::new("row-1"), RemoteId::new("row-2")], + has_column_header: true, + has_row_header: false, + }; + let edited = + CanonicalDocument::new("", "| Name | Status |\n| --- | --- |\n| New task | Done |"); + + let plan = BlockDiffEngine::new() + .plan_push(&shadow, &edited) + .expect("plan"); + + assert_eq!( + plan.operations, + vec![PushOperation::UpdateBlock { + block_id: RemoteId::new("table-1"), + content: "| Name | Status |\n| --- | --- |\n| New task | Done |".to_string(), + }] + ); +} + fn shadow(body: &str, ids: [&str; N]) -> ShadowDocument { ShadowDocument::from_synced_body( RemoteId::new("page-1"), diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 64d369f..0e96029 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -20,7 +20,7 @@ use serde_json::{Map, Value, json}; use crate::client::NotionApi; use crate::dto::{ BlockDto, BlockTreeDto, DataSourceDto, NotionPageBundle, PageDto, PagePropertyDto, - RichTextAnnotationsDto, RichTextDto, + RichTextAnnotationsDto, RichTextDto, TableBlockDto, }; use crate::fetch::fetch_page_bundle; @@ -69,6 +69,15 @@ pub fn apply_plan( match operation { PushOperation::UpdateBlock { block_id, content } => { let current = current_block(¤t_blocks, block_id)?; + if current.kind == "table" && looks_like_markdown_table(content) { + apply_table_update(api, &bundles, block_id, current, content)?; + effects.push(JournalApplyEffect::UpdatedBlock { + operation_id: request.operation_ids[operation_index].clone(), + operation_index, + block_id: block_id.clone(), + }); + continue; + } let patch = parse_supported_block( content, Some(current.kind.as_str()), @@ -221,6 +230,21 @@ pub fn apply_undo( for operation in &request.plan.operations { match operation { UndoOperation::RestoreBlockContent { block_id, content } => { + if looks_like_markdown_table(content) { + let create_parent_ids = BTreeSet::new(); + let bundles = fetch_affected_bundles( + api, + &request.plan.affected_entities, + &create_parent_ids, + )?; + let current_blocks = block_map(&bundles); + if let Ok(current) = current_block(¤t_blocks, block_id) + && current.kind == "table" + { + apply_table_update(api, &bundles, block_id, current, content)?; + continue; + } + } let patch = parse_supported_block(content, None, None)?; api.update_block(block_id.as_str(), patch.update_body())?; } @@ -327,6 +351,102 @@ fn collect_blocks<'a>(trees: &'a [BlockTreeDto], blocks: &mut BTreeMap AfsResult<()> { + let table = current.table.as_ref().ok_or_else(|| { + AfsError::InvalidState(format!( + "notion table block `{}` is missing its `table` payload", + current.id + )) + })?; + let current_rows = current_table_rows(bundles, table_id)?; + let parsed = parse_markdown_table(markdown, table)?; + + if parsed.rows.len() != current_rows.len() { + return Err(AfsError::Unsupported( + "writing Notion table row additions or deletions", + )); + } + + for (row_block, cells) in current_rows.iter().zip(parsed.rows) { + let current_row = row_block.table_row.as_ref().ok_or_else(|| { + AfsError::InvalidState(format!( + "notion table row block `{}` is missing its `table_row` payload", + row_block.id + )) + })?; + if cells.len() != current_row.cells.len() { + return Err(AfsError::Unsupported("writing Notion table width changes")); + } + + let cells = cells + .iter() + .enumerate() + .map(|(index, cell)| { + rich_text_payload( + cell, + current_row.cells.get(index).map(|cell| cell.as_slice()), + ) + }) + .collect::>>()?; + api.update_block( + &row_block.id, + json!({ + "table_row": { + "cells": cells, + }, + }), + )?; + } + + Ok(()) +} + +fn current_table_rows<'a>( + bundles: &'a [NotionPageBundle], + table_id: &RemoteId, +) -> AfsResult> { + let tree = bundles + .iter() + .find_map(|bundle| find_block_tree(&bundle.blocks, table_id)) + .ok_or_else(|| { + AfsError::InvalidState(format!( + "push referenced table `{}` that is absent from current Notion page content", + table_id.0 + )) + })?; + + tree.children + .iter() + .map(|child| { + if child.block.kind == "table_row" && child.children.is_empty() { + Ok(&child.block) + } else { + Err(AfsError::Unsupported( + "writing Notion tables with non-row children", + )) + } + }) + .collect() +} + +fn find_block_tree<'a>(trees: &'a [BlockTreeDto], block_id: &RemoteId) -> Option<&'a BlockTreeDto> { + for tree in trees { + if tree.block.id == block_id.0 { + return Some(tree); + } + if let Some(found) = find_block_tree(&tree.children, block_id) { + return Some(found); + } + } + None +} + fn current_block<'a>( blocks: &'a BTreeMap, block_id: &RemoteId, @@ -767,6 +887,95 @@ impl NotionBlockPatch { } } +struct ParsedMarkdownTable { + rows: Vec>, +} + +fn parse_markdown_table( + markdown: &str, + current_table: &TableBlockDto, +) -> AfsResult { + let lines = markdown + .lines() + .filter(|line| !line.trim().is_empty()) + .collect::>(); + if lines.len() < 2 { + return Err(AfsError::Unsupported("writing malformed Notion tables")); + } + + let header = parse_markdown_table_row(lines[0])?; + validate_markdown_table_separator(lines[1], header.len())?; + let mut data_rows = lines[2..] + .iter() + .map(|line| parse_markdown_table_row(line)) + .collect::>>()?; + let width = usize::from(current_table.table_width); + if width == 0 || header.len() != width || data_rows.iter().any(|row| row.len() != width) { + return Err(AfsError::Unsupported("writing Notion table width changes")); + } + + let rows = if current_table.has_column_header { + let mut rows = Vec::with_capacity(data_rows.len() + 1); + rows.push(header); + rows.append(&mut data_rows); + rows + } else { + if header.iter().any(|cell| !cell.trim().is_empty()) { + return Err(AfsError::Unsupported( + "writing Notion table header-mode changes", + )); + } + data_rows + }; + + Ok(ParsedMarkdownTable { rows }) +} + +fn parse_markdown_table_row(line: &str) -> AfsResult> { + let trimmed = line.trim(); + if !trimmed.starts_with('|') || !trimmed.ends_with('|') || trimmed.len() < 2 { + return Err(AfsError::Unsupported("writing malformed Notion tables")); + } + + let inner = &trimmed[1..trimmed.len() - 1]; + let mut cells = Vec::new(); + let mut current = String::new(); + let mut escaped = false; + for ch in inner.chars() { + if ch == '|' && !escaped { + cells.push(unescape_markdown_table_cell(current.trim())); + current.clear(); + } else { + current.push(ch); + } + escaped = ch == '\\' && !escaped; + if ch != '\\' { + escaped = false; + } + } + cells.push(unescape_markdown_table_cell(current.trim())); + + Ok(cells) +} + +fn validate_markdown_table_separator(line: &str, width: usize) -> AfsResult<()> { + let cells = parse_markdown_table_row(line)?; + let valid = cells.len() == width + && cells.iter().all(|cell| { + let trimmed = cell.trim(); + trimmed.contains('-') && trimmed.chars().all(|ch| matches!(ch, '-' | ':' | ' ')) + }); + if valid { + Ok(()) + } else { + Err(AfsError::Unsupported("writing malformed Notion tables")) + } +} + +fn unescape_markdown_table_cell(cell: &str) -> String { + cell.replace("\\|", "|").replace("
", "\n") +} + fn parse_supported_block( markdown: &str, current_kind: Option<&str>, diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index a062835..8abfa26 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -13,8 +13,8 @@ use afs_notion::dto::{ BlockDto, BlockListDto, DataSourceDto, DataSourcePropertyDto, DataSourceSummaryDto, DatabaseDto, DateMentionDto, EquationBlockDto, ExternalFileDto, FileBlockDto, LinkDto, MentionRichTextDto, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, - RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, TextRichTextDto, - UrlBlockDto, + RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, TableBlockDto, + TableRowBlockDto, TextRichTextDto, UrlBlockDto, }; use afs_notion::{NotionConfig, NotionConnector}; use serde_json::{Value, json}; @@ -971,6 +971,68 @@ fn apply_updates_external_media_blocks_from_markdown_links() { ); } +#[test] +fn apply_updates_simple_table_rows_from_markdown_table() { + let api = Arc::new(RecordingNotionApi::with_table( + "2026-06-10T00:00:00.000Z", + table_block("table-1", 2, true), + vec![ + table_row_block("row-1", &["Name", "Status"]), + table_row_block("row-2", &["Old task", "Todo"]), + ], + )); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("page-1")], + vec![PushOperation::UpdateBlock { + block_id: RemoteId::new("table-1"), + content: "| Name | Status |\n| --- | --- |\n| New task | Done |".to_string(), + }], + ); + let push_id = PushId("push-1".to_string()); + let operation_ids = operation_ids(&push_id, &plan); + let mount_id = MountId::new("notion-main"); + + connector + .apply(ApplyPlanRequest { + push_id: &push_id, + mount_id: &mount_id, + plan: &plan, + operation_ids: &operation_ids, + remote_preconditions: &[], + }) + .expect("apply table row updates"); + + let writes = api.writes.lock().expect("writes"); + assert_eq!( + writes.as_slice(), + [ + WriteCall::Update { + block_id: "row-1".to_string(), + body: json!({ + "table_row": { + "cells": [ + rich_text_json("Name"), + rich_text_json("Status"), + ], + }, + }), + }, + WriteCall::Update { + block_id: "row-2".to_string(), + body: json!({ + "table_row": { + "cells": [ + rich_text_json("New task"), + rich_text_json("Done"), + ], + }, + }), + }, + ] + ); +} + #[test] fn apply_updates_supported_page_properties() { let api = Arc::new(RecordingNotionApi::with_page_properties( @@ -1336,6 +1398,57 @@ impl RecordingNotionApi { Self::with_page_and_block_results(page, blocks) } + fn with_table(last_edited_time: &str, table: BlockDto, rows: Vec) -> Self { + let page = PageDto { + id: "page-1".to_string(), + parent: None, + created_time: Some("2026-06-10T00:00:00.000Z".to_string()), + last_edited_time: Some(last_edited_time.to_string()), + archived: false, + in_trash: false, + properties: BTreeMap::new(), + }; + let children = BTreeMap::from([ + ( + ("page-1".to_string(), None), + PaginatedListDto { + results: vec![table.clone()], + next_cursor: None, + has_more: false, + }, + ), + ( + (table.id, None), + PaginatedListDto { + results: rows, + next_cursor: None, + has_more: false, + }, + ), + ]); + Self { + page, + database: DatabaseDto { + id: "database-1".to_string(), + data_sources: vec![DataSourceSummaryDto { + id: "source-1".to_string(), + name: Some("Tasks".to_string()), + }], + last_edited_time: Some("2026-06-10T00:00:00.000Z".to_string()), + ..Default::default() + }, + data_source: DataSourceDto { + id: "source-1".to_string(), + name: Some("Tasks".to_string()), + properties: BTreeMap::new(), + ..Default::default() + }, + children, + writes: Mutex::new(Vec::new()), + append_count: Mutex::new(0), + } + } + fn with_page_and_children(page: PageDto, rich_text: Vec) -> Self { Self::with_page_and_block_results( page, @@ -1661,6 +1774,25 @@ fn media_block(id: &str, kind: &str, url: &str, caption: &str) -> BlockDto { block } +fn table_block(id: &str, width: u16, has_column_header: bool) -> BlockDto { + let mut block = block(id, "table"); + block.has_children = true; + block.table = Some(TableBlockDto { + table_width: width, + has_column_header, + has_row_header: false, + }); + block +} + +fn table_row_block(id: &str, cells: &[&str]) -> BlockDto { + let mut block = block(id, "table_row"); + block.table_row = Some(TableRowBlockDto { + cells: cells.iter().map(|cell| rich_text(cell)).collect(), + }); + block +} + fn rich_text(text: &str) -> Vec { vec![rich_text_part(text)] } diff --git a/crates/afs-notion/tests/live_integrity.rs b/crates/afs-notion/tests/live_integrity.rs index 2530c40..0c12033 100644 --- a/crates/afs-notion/tests/live_integrity.rs +++ b/crates/afs-notion/tests/live_integrity.rs @@ -481,6 +481,7 @@ impl LiveCleanup { } fn create_database(&mut self, parent_page_id: &str, title: &str) -> DatabaseDto { + let unique_prefix = unique_id_prefix(); let database = self .api .create_database(json!({ @@ -519,7 +520,7 @@ impl LiveCleanup { "Phone": { "phone_number": {} }, "Files": { "files": {} }, "People": { "people": {} }, - "Unique": { "unique_id": { "prefix": "AFS" } } + "Unique": { "unique_id": { "prefix": unique_prefix } } } } })) @@ -922,3 +923,18 @@ fn unique_suffix() -> String { .as_nanos(); format!("{}-{nanos}", std::process::id()) } + +fn unique_id_prefix() -> String { + let mut value = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut prefix = String::new(); + for _ in 0..6 { + let index = (value % alphabet.len() as u128) as usize; + prefix.push(alphabet[index] as char); + value /= alphabet.len() as u128; + } + prefix +} diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 3501ff8..28897bb 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded same-shape Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing tables with unchanged width/header mode/row count, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index c3f9c86..f0e3e54 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -111,11 +111,11 @@ Nested children are fetched recursively and rendered after their parent, except The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; -- supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, existing bookmark/embed URL blocks, and existing URL-backed media blocks; +- supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, same-shape existing tables, existing bookmark/embed URL blocks, and existing URL-backed media blocks; - supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, legacy `afs://` page links, and unchanged preimage mentions such as dates; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, and phone; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; -- unsupported write forms fail before API mutation, including tables, page/database creation outside database-row files, computed/read-only properties, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; +- unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; - appends use Notion's current position object, with `start` for prepends and `after_block` for inserts after a known block; - before apply, the connector re-reads the page and compares the current Notion edit timestamp against the last-synced timestamp carried by the push executor; - after apply, the CLI reconciler fetches changed and created pages, rewrites local files atomically, saves refreshed shadows, updates `remote_edited_at`, and removes the temporary source filename when a created row moves into its projected path. diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 6499f18..2662c1f 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -92,3 +92,24 @@ and what Markdown shape agents should expect. safe write or append contract for this block type. - **Verification:** Fixture render coverage asserts that a returned `link_preview` block renders to Markdown link syntax. + +### Same-Shape Table Cell Edits + +- **Notion input:** Simple `table` blocks with `table_row` children and no + nested row children. +- **Markdown output:** Tables render as standard Markdown tables. Existing + column-header tables map the first Markdown row to the first Notion table row; + headerless tables keep the renderer's empty Markdown header marker and map + data lines to Notion rows. +- **Write behavior:** A Markdown edit to an existing table updates the + corresponding Notion `table_row.cells` values when table width, row count, and + header flags are unchanged. Row additions, row deletions, width changes, and + header-mode changes fail before API mutation. +- **Verification:** Core diff coverage asserts that table edits produce a table + block update rather than archive/recreate. Fixture apply tests assert exact + row update payloads, and the live mounted edit cycle updates a table cell then + verifies the rendered Notion result through the API. +- **Bug fixed during live testing:** The live database fixtures used a fixed + Notion unique-ID prefix, which can collide at workspace scope on repeated + runs. The live fixtures now generate a short unique alphanumeric prefix for + each scratch database. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 808d162..fd4cc4c 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -42,7 +42,7 @@ Sources used for the baseline: | `code` | Native fenced code | Yes | fixture, live | Language is preserved on simple code fences. | | `divider` | Native Markdown rule | Yes | fixture, live | `---`. | | `equation` | Native display math | Yes | fixture, live | `$$ ... $$`. | -| `table` | Native Markdown table | No | fixture, live read | Row-level write planning is future work. | +| `table` | Native Markdown table | Yes for same-shape existing tables | fixture, live read/write | Existing cell edits update table rows when width, header mode, and row count stay unchanged. Row add/delete and width changes are still blocked. | | `table_row` | Structural inside tables | No | fixture | Standalone/malformed rows render as directives. | | `child_page` | Directive and structural enumeration | No direct block write | fixture, live read | New child pages are created through page/entity creation, not block edits. | | `child_database` | Directive and structural enumeration | No direct block write | fixture, live read | Databases are created through the database API, not Markdown block writes. | @@ -117,8 +117,8 @@ Sources used for the baseline: - Media upload and non-image downloads are deferred until AFS has size limits, retention rules, and local path ownership semantics. -- Table writes are deferred until the planner can produce row-level operations - instead of replacing the whole table. +- Table structural writes are deferred until the planner can produce row-level + operations for row add/delete, width changes, and header-mode changes. - Layout and generated blocks (`column_*`, `breadcrumb`, `table_of_contents`, tabs) stay as directives because Markdown cannot represent their semantics. - Comments are not mounted because they need a separate thread model and push @@ -131,8 +131,8 @@ Sources used for the baseline: 1. Add fixture-backed write tests before widening any block type. The Tier 1 writer suite now covers headings, numbered lists, to-dos, quotes, callouts, code fences, dividers, and equations. -2. Treat tables as the next large design item. They need row-level diff/apply - rather than whole-table replacement. +2. Extend table writes beyond same-shape cell edits. Row add/delete and width + changes need row-level diff/apply rather than whole-table replacement. 3. Keep layout, generated, synced, and unknown future blocks directive-backed until their Notion semantics can be represented without content loss. 4. Design media writes separately from text block writes. Upload support needs From 7918377ce7174c4a02884e240f7101da45980389 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 06:29:00 -0500 Subject: [PATCH 09/18] Support external file property writes --- crates/afs-cli/tests/e2e_push_workflow.rs | 25 ++++++++- crates/afs-notion/src/apply.rs | 67 +++++++++++++++++++++++ crates/afs-notion/src/schema.rs | 49 ++++++++++++++++- crates/afs-notion/tests/apply.rs | 45 +++++++++++++++ crates/afs-notion/tests/live_integrity.rs | 24 +++++++- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 4 +- docs/notion-cyclic-support-journal.md | 16 ++++++ docs/notion-object-support.md | 8 +-- 9 files changed, 229 insertions(+), 11 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index 4d9607f..f7a01af 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -497,6 +497,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"URL\":", "\"Email\":", "\"Phone\":", + "\"Files\":", ] { assert!(schema.contains(expected), "missing {expected:?}\n{schema}"); } @@ -512,6 +513,8 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"State\": \"Not started\"", "\"Done\": false", "\"URL\": \"https://example.com/afs-db-row\"", + "\"Files\":", + "\"Initial file \"", "Database row paragraph original.", ] { assert!( @@ -562,6 +565,10 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"URL\": \"https://example.com/afs-db-row\"", "\"URL\": \"https://example.com/afs-db-row-updated\"", ) + .replace( + "\"Initial file \"", + "\"Updated file \"", + ) .replace( "Database row paragraph original.", "Database row paragraph changed.", @@ -598,6 +605,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"State\": \"In progress\"", "\"Done\": true", "\"URL\": \"https://example.com/afs-db-row-updated\"", + "\"Updated file \"", "Database row paragraph changed.", ] { assert!( @@ -610,7 +618,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { let new_row_path = database_dir.join("new-cyclic-row.md"); fs::write( &new_row_path, - "---\ntitle: AFS cyclic created row\nNotes: Created row notes\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\n---\n# Created row body\n\nCreated from mounted markdown.\n", + "---\ntitle: AFS cyclic created row\nNotes: Created row notes\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\nFiles:\n - Created file \n---\n# Created row body\n\nCreated from mounted markdown.\n", ) .expect("write new live database row file"); @@ -647,6 +655,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"URL\": \"https://example.com/afs-created-row\"", "\"Email\": \"cyclic@example.com\"", "\"Phone\": \"+1 415 555 0199\"", + "\"Created file \"", "Created from mounted markdown.", ] { assert!( @@ -1263,6 +1272,20 @@ fn database_row_properties( "Phone".to_string(), json!({ "phone_number": "+1 415 555 0199" }), ), + ( + "Files".to_string(), + json!({ + "files": [ + { + "name": "Initial file", + "type": "external", + "external": { + "url": "https://example.com/initial.pdf" + } + } + ] + }), + ), ]) } diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 0e96029..646a146 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -656,6 +656,7 @@ fn property_value_for_kind(kind: &str, value: &PropertyValue, key: &str) -> AfsR "checkbox" => bool_property(value, key), "date" => date_property(value, key), "url" | "email" | "phone_number" => nullable_string_property(kind, value, key), + "files" => files_property(value, key), _ => Err(AfsError::Unsupported("updating this Notion property type")), } } @@ -753,6 +754,72 @@ fn single_property(kind: &str, value: Value) -> Value { Value::Object(object) } +fn files_property(value: &PropertyValue, key: &str) -> AfsResult { + let entries = match value { + PropertyValue::Null => Vec::new(), + PropertyValue::String(value) if value.trim().is_empty() => Vec::new(), + PropertyValue::String(value) => vec![value.as_str()], + PropertyValue::List(values) => values.iter().map(String::as_str).collect(), + _ => return Err(property_type_error(key, "file URL string or list")), + }; + + let files = entries + .into_iter() + .map(|entry| external_file_property_value(entry, key)) + .collect::>>()?; + Ok(json!({ "files": files })) +} + +fn external_file_property_value(entry: &str, key: &str) -> AfsResult { + let (name, url) = parse_external_file_entry(entry); + if url.trim().is_empty() || !valid_url(url) { + return Err(AfsError::Validation(vec![property_issue( + key, + "notion_property_file_url_invalid", + "Notion file properties must be HTTP(S) URLs or `name ` entries", + )])); + } + let name = if name.trim().is_empty() { + file_name_from_url(url) + } else { + name.trim().to_string() + }; + + Ok(json!({ + "name": name, + "type": "external", + "external": { + "url": url, + }, + })) +} + +fn parse_external_file_entry(entry: &str) -> (&str, &str) { + let trimmed = entry.trim(); + if let Some(without_close) = trimmed.strip_suffix('>') + && let Some((name, url)) = without_close.rsplit_once(" <") + { + return (name, url); + } + ("", trimmed) +} + +fn file_name_from_url(url: &str) -> String { + url.split(['?', '#']) + .next() + .unwrap_or(url) + .trim_end_matches('/') + .rsplit('/') + .next() + .filter(|segment| !segment.is_empty()) + .unwrap_or("File") + .to_string() +} + +fn valid_url(value: &str) -> bool { + value.starts_with("http://") || value.starts_with("https://") +} + fn required_string(value: &PropertyValue, key: &str) -> AfsResult { match value { PropertyValue::String(value) => Ok(value.clone()), diff --git a/crates/afs-notion/src/schema.rs b/crates/afs-notion/src/schema.rs index 61f1604..d45ba18 100644 --- a/crates/afs-notion/src/schema.rs +++ b/crates/afs-notion/src/schema.rs @@ -270,6 +270,7 @@ fn validate_value_for_property( "url" => validate_nullable_string_shape(value, "URL", valid_url), "email" => validate_nullable_string_shape(value, "email address", valid_email), "phone_number" => validate_nullable_string(value, "phone number"), + "files" => validate_files(value), _ => Ok(()), } } @@ -453,6 +454,45 @@ fn validate_nullable_string_shape( Ok(()) } +fn validate_files(value: &PropertyValue) -> Result<(), (&'static str, String, &'static str)> { + let entries = match value { + PropertyValue::Null => return Ok(()), + PropertyValue::String(value) if value.trim().is_empty() => return Ok(()), + PropertyValue::String(value) => vec![value.as_str()], + PropertyValue::List(values) => values.iter().map(String::as_str).collect(), + _ => { + return Err(( + "notion_schema_property_type_mismatch", + "must be a file URL string or list".to_string(), + "use HTTP(S) URLs or `name ` list entries", + )); + } + }; + + if entries.iter().all(|entry| { + let (_, url) = parse_external_file_entry(entry); + valid_url(url) + }) { + Ok(()) + } else { + Err(( + "notion_schema_property_shape_invalid", + "must contain valid HTTP(S) file URLs".to_string(), + "use HTTP(S) URLs or `name ` list entries", + )) + } +} + +fn parse_external_file_entry(entry: &str) -> (&str, &str) { + let trimmed = entry.trim(); + if let Some(without_close) = trimmed.strip_suffix('>') + && let Some((name, url)) = without_close.rsplit_once(" <") + { + return (name, url); + } + ("", trimmed) +} + fn valid_url(value: &str) -> bool { value.starts_with("http://") || value.starts_with("https://") } @@ -555,6 +595,7 @@ impl PropertySchema { | "url" | "email" | "phone_number" + | "files" ) } @@ -611,7 +652,7 @@ mod tests { #[test] fn validates_create_row_against_schema_options_and_types() { let parsed = parse_canonical_markdown( - "---\ntitle: New task\nStatus: Todo\nTags:\n - Backend\nDone: false\nPoints: 5\nDue:\n start: \"2026-06-10\"\nURL: https://example.com/afs\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\n---\n# Body\n", + "---\ntitle: New task\nStatus: Todo\nTags:\n - Backend\nDone: false\nPoints: 5\nDue:\n start: \"2026-06-10\"\nURL: https://example.com/afs\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \n---\n# Body\n", ) .expect("parse row"); @@ -623,7 +664,7 @@ mod tests { #[test] fn rejects_unknown_options_and_read_only_properties() { let parsed = parse_canonical_markdown( - "---\ntitle: New task\nStatus: Blocked\nFormula: edited\n---\n# Body\n", + "---\ntitle: New task\nStatus: Blocked\nFormula: edited\nFiles:\n - not-a-url\n---\n# Body\n", ) .expect("parse row"); @@ -636,6 +677,7 @@ mod tests { .map(|issue| issue.code.as_str()) .collect::>(), vec![ + "notion_schema_property_shape_invalid", "notion_schema_property_read_only", "notion_schema_option_unknown" ] @@ -735,6 +777,9 @@ data_sources: Phone: id: "phone-id" type: "phone_number" + Files: + id: "files-id" + type: "files" Formula: id: "formula-id" type: "formula" diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index 8abfa26..8978ffd 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -1045,6 +1045,7 @@ fn apply_updates_supported_page_properties() { ("Points".to_string(), page_property("number")), ("Due".to_string(), page_property("date")), ("URL".to_string(), page_property("url")), + ("Files".to_string(), page_property("files")), ]), )); let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); @@ -1075,6 +1076,13 @@ fn apply_updates_supported_page_properties() { "URL".to_string(), PropertyValue::String("https://example.com/afs".to_string()), ), + ( + "Files".to_string(), + PropertyValue::List(vec![ + "Spec ".to_string(), + "https://example.com/diagram.png".to_string(), + ]), + ), ] .into_iter() .collect(), @@ -1104,6 +1112,7 @@ fn apply_updates_supported_page_properties() { keys: vec![ "Done".to_string(), "Due".to_string(), + "Files".to_string(), "Points".to_string(), "Status".to_string(), "Tags".to_string(), @@ -1147,6 +1156,24 @@ fn apply_updates_supported_page_properties() { "URL": { "url": "https://example.com/afs", }, + "Files": { + "files": [ + { + "name": "Spec", + "type": "external", + "external": { + "url": "https://example.com/spec.pdf", + }, + }, + { + "name": "diagram.png", + "type": "external", + "external": { + "url": "https://example.com/diagram.png", + }, + }, + ], + }, }, }), }] @@ -1163,6 +1190,7 @@ fn apply_creates_database_row_with_properties_and_children() { ("Tags".to_string(), data_source_property("multi_select")), ("Done".to_string(), data_source_property("checkbox")), ("Points".to_string(), data_source_property("number")), + ("Files".to_string(), data_source_property("files")), ]), )); let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); @@ -1183,6 +1211,12 @@ fn apply_creates_database_row_with_properties_and_children() { ), ("Done".to_string(), PropertyValue::Bool(false)), ("Points".to_string(), PropertyValue::Number("5".to_string())), + ( + "Files".to_string(), + PropertyValue::List(vec![ + "Design ".to_string(), + ]), + ), ] .into_iter() .collect(), @@ -1247,6 +1281,17 @@ fn apply_creates_database_row_with_properties_and_children() { "Points": { "number": 5.0, }, + "Files": { + "files": [ + { + "name": "Design", + "type": "external", + "external": { + "url": "https://example.com/design.pdf", + }, + }, + ], + }, }, "children": [ { diff --git a/crates/afs-notion/tests/live_integrity.rs b/crates/afs-notion/tests/live_integrity.rs index 0c12033..08dc0b7 100644 --- a/crates/afs-notion/tests/live_integrity.rs +++ b/crates/afs-notion/tests/live_integrity.rs @@ -255,7 +255,7 @@ fn live_database_row_property_create_edit_verify_integrity() { .database_schema_yaml(&database_id) .expect("live schema"); let valid_row = parse_canonical_markdown( - "---\ntitle: AFS created row\nNotes: Rich row notes\nPoints: 42\nStatus: Todo\nState: Not started\nTags:\n - Alpha\n - Beta\nDone: false\nDue: \"2026-06-10\"\nURL: https://example.com/afs-live\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\n---\n# Row body\n", + "---\ntitle: AFS created row\nNotes: Rich row notes\nPoints: 42\nStatus: Todo\nState: Not started\nTags:\n - Alpha\n - Beta\nDone: false\nDue: \"2026-06-10\"\nURL: https://example.com/afs-live\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \n---\n# Row body\n", ) .expect("valid row frontmatter"); assert!(validate_create_row_frontmatter(&schema_yaml, &valid_row, "Rows/valid.md").is_clean()); @@ -317,6 +317,10 @@ fn live_database_row_property_create_edit_verify_integrity() { "Phone".to_string(), PropertyValue::String("+1 415 555 0100".to_string()), ), + ( + "Files".to_string(), + PropertyValue::List(vec!["Spec ".to_string()]), + ), ]), body: "# Row body\n\nCreated from live integration.\n".to_string(), source_path: "Rows/afs-created-row.md".into(), @@ -362,6 +366,12 @@ fn live_database_row_property_create_edit_verify_integrity() { .frontmatter .contains("\"URL\": \"https://example.com/afs-live\"") ); + assert!( + rendered + .document + .frontmatter + .contains("\"Spec \"") + ); let update = PushPlan::new( vec![row_id.clone()], @@ -381,6 +391,12 @@ fn live_database_row_property_create_edit_verify_integrity() { "URL".to_string(), PropertyValue::String("https://example.com/afs-live-updated".to_string()), ), + ( + "Files".to_string(), + PropertyValue::List(vec![ + "Spec updated ".to_string(), + ]), + ), ]), }], ); @@ -416,6 +432,12 @@ fn live_database_row_property_create_edit_verify_integrity() { .frontmatter .contains("\"URL\": \"https://example.com/afs-live-updated\"") ); + assert!( + verified_render + .document + .frontmatter + .contains("\"Spec updated \"") + ); } #[derive(Clone, Debug)] diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 28897bb..5f89948 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -62,7 +62,7 @@ title: "Fix login bug" Supported read-side property values include title, rich text, number, select, multi-select, status, checkbox, date, URL, email, phone, files, people, relation IDs, created/edited timestamps, created/edited users, formula, rollup, unique ID, and verification values. -Property writes are planned by comparing edited frontmatter against the shadow frontmatter captured during the last render. The Notion writer currently applies title, rich text, number, select, status, multi-select, checkbox, date, URL, email, and phone properties. Read-only, computed, or identity-backed property classes such as files, people, relation, formula, rollup, created/edited timestamps, created/edited users, unique ID, and verification remain read-side only until schema validation and richer property preimages are added. +Property writes are planned by comparing edited frontmatter against the shadow frontmatter captured during the last render. The Notion writer currently applies title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, and external file URL properties. File entries use either `https://...` or `Name ` frontmatter list values and write Notion external file objects. Read-only, computed, or identity-backed property classes such as people, relation, formula, rollup, created/edited timestamps, created/edited users, unique ID, and verification remain read-side only until schema validation and richer property preimages are added. A new database row starts as the same document shape without generated identity fields: diff --git a/docs/notion-connector.md b/docs/notion-connector.md index f0e3e54..8e4a357 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -113,9 +113,9 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, same-shape existing tables, existing bookmark/embed URL blocks, and existing URL-backed media blocks; - supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, legacy `afs://` page links, and unchanged preimage mentions such as dates; -- supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, and phone; +- supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, and external file URLs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; -- unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; +- unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; - appends use Notion's current position object, with `start` for prepends and `after_block` for inserts after a known block; - before apply, the connector re-reads the page and compares the current Notion edit timestamp against the last-synced timestamp carried by the push executor; - after apply, the CLI reconciler fetches changed and created pages, rewrites local files atomically, saves refreshed shadows, updates `remote_edited_at`, and removes the temporary source filename when a created row moves into its projected path. diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 2662c1f..15137bf 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -113,3 +113,19 @@ and what Markdown shape agents should expect. Notion unique-ID prefix, which can collide at workspace scope on repeated runs. The live fixtures now generate a short unique alphanumeric prefix for each scratch database. + +### External File Properties + +- **Notion input:** Database/page `files` properties containing external files + or Notion-hosted files. +- **Markdown output:** File properties render as frontmatter lists. Entries with + both name and URL use `Name `; URL-only entries + render as the URL string. +- **Write behavior:** Frontmatter edits can write external file URLs using + either `https://example.com/file.pdf` or `Name ` + list entries. Uploading local files, rewriting hosted Notion files, and + retention/dedupe policy remain deferred. +- **Verification:** Fixture apply tests assert exact page update and row create + payloads. Schema tests validate accepted/rejected file frontmatter. The live + mounted database cycle edits and creates rows with file properties, and the + live direct integrity test creates and updates a file property through the API. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index fd4cc4c..be2d97a 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -20,7 +20,7 @@ Sources used for the baseline: | Data source | Read/query rows, render `_schema.yaml`, validate row property writes, create rows when database has exactly one data source | fixture, live, mounted live | Multi-data-source row writes are intentionally blocked until path/schema selection exists. | | User | Read only when embedded in mentions/properties | fixture | User objects are not mounted as standalone files in v1. | | Comment | Unsupported | none | Comments are not in the v1 filesystem model from `plan.md`; adding them needs a thread representation and write policy. | -| File upload | Unsupported for upload; external/download URLs are read | fixture, live image download | Uploading files needs retention, size, dedupe, and local path ownership policy. | +| File upload | Unsupported for upload; external/download URLs are read and external file properties are writable | fixture, live image download, live property write | Uploading local files still needs retention, size, dedupe, and local path ownership policy. | | View | Unsupported | none | Views are database presentation state, not row/page content. | | Custom emoji | Unsupported | none | Emoji metadata is presentation state; emoji text still appears through rich text/plain text. | | Webhook event | Unsupported locally | none | Webhooks belong to the optional relay path, not the local direct connector. | @@ -100,7 +100,7 @@ Sources used for the baseline: | `url` | Yes | Yes | fixture, live, mounted live, schema | Nullable HTTP/HTTPS string. | | `email` | Yes | Yes | fixture, live, mounted live, schema | Nullable email string. | | `phone_number` | Yes | Yes | fixture, live, mounted live, schema | Nullable string. | -| `files` | Yes | No | fixture, live read-empty, schema-blocked | File upload/link ownership policy is not designed yet. | +| `files` | Yes | Yes for external URLs | fixture, live read/write, schema | Frontmatter accepts `https://...` or `Name ` entries and writes Notion external file objects. Hosted/uploaded file ownership remains read-only. | | `people` | Yes | No | fixture, live read-empty, schema-blocked | Needs user lookup and permission-aware validation before writes. | | `relation` | Yes | No | fixture, schema-blocked | Needs target data-source schema and path/ID resolution before writes. | | `formula` | Yes | No | fixture, schema-blocked | Computed/read-only by Notion. | @@ -115,8 +115,8 @@ Sources used for the baseline: ## Current Intentional Gaps -- Media upload and non-image downloads are deferred until AFS has size limits, - retention rules, and local path ownership semantics. +- Media upload, hosted file rewrites, and non-image downloads are deferred until + AFS has size limits, retention rules, and local path ownership semantics. - Table structural writes are deferred until the planner can produce row-level operations for row add/delete, width changes, and header-mode changes. - Layout and generated blocks (`column_*`, `breadcrumb`, `table_of_contents`, From 7dd37c55ae1b9776c00d487386091873e5289323 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 06:46:36 -0500 Subject: [PATCH 10/18] Support relation property writes --- crates/afs-cli/tests/e2e_push_workflow.rs | 175 ++++++++++++++----- crates/afs-notion/src/apply.rs | 35 ++++ crates/afs-notion/src/schema.rs | 41 ++++- crates/afs-notion/tests/apply.rs | 25 +++ crates/afs-notion/tests/live_integrity.rs | 194 ++++++++++++++++++---- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 4 +- docs/notion-cyclic-support-journal.md | 20 +++ docs/notion-object-support.md | 7 +- 9 files changed, 425 insertions(+), 78 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index f7a01af..045add2 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -449,8 +449,27 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { &format!("AFS cyclic database scratch {}", unique_suffix()), Vec::new(), ); - let database = - cleanup.create_database(&scratch.id, &format!("AFS cyclic rows {}", unique_suffix())); + let related_database = cleanup.create_database( + &scratch.id, + &format!("AFS cyclic related rows {}", unique_suffix()), + ); + let related_data_source_id = related_database + .data_sources + .first() + .expect("related data source") + .id + .clone(); + let related_row = cleanup.create_database_row( + &related_database, + &format!("AFS cyclic related row {}", unique_suffix()), + serde_json::Map::new(), + vec![paragraph_child("Related row target.")], + ); + let database = cleanup.create_database_with_relation( + &scratch.id, + &format!("AFS cyclic rows {}", unique_suffix()), + &related_data_source_id, + ); let existing_row = cleanup.create_database_row( &database, &format!("AFS cyclic existing row {}", unique_suffix()), @@ -461,6 +480,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "Not started", false, "https://example.com/afs-db-row", + &[related_row.id.as_str()], ), vec![paragraph_child("Database row paragraph original.")], ); @@ -498,6 +518,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"Email\":", "\"Phone\":", "\"Files\":", + "\"Related\":", ] { assert!(schema.contains(expected), "missing {expected:?}\n{schema}"); } @@ -515,6 +536,8 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"URL\": \"https://example.com/afs-db-row\"", "\"Files\":", "\"Initial file \"", + "\"Related\":", + &format!("\"{}\"", related_row.id), "Database row paragraph original.", ] { assert!( @@ -606,6 +629,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"Done\": true", "\"URL\": \"https://example.com/afs-db-row-updated\"", "\"Updated file \"", + &format!("\"{}\"", related_row.id), "Database row paragraph changed.", ] { assert!( @@ -618,7 +642,10 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { let new_row_path = database_dir.join("new-cyclic-row.md"); fs::write( &new_row_path, - "---\ntitle: AFS cyclic created row\nNotes: Created row notes\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\nFiles:\n - Created file \n---\n# Created row body\n\nCreated from mounted markdown.\n", + &format!( + "---\ntitle: AFS cyclic created row\nNotes: Created row notes\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\nFiles:\n - Created file \nRelated:\n - \"{}\"\n---\n# Created row body\n\nCreated from mounted markdown.\n", + related_row.id + ), ) .expect("write new live database row file"); @@ -656,6 +683,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"Email\": \"cyclic@example.com\"", "\"Phone\": \"+1 415 555 0199\"", "\"Created file \"", + &format!("\"{}\"", related_row.id), "Created from mounted markdown.", ] { assert!( @@ -701,9 +729,19 @@ impl E2eFixture { } fn schema_file(&self) -> PathBuf { - collect_files(&self.root) + let schemas = collect_files(&self.root) .into_iter() - .find(|path| file_name(path) == "_schema.yaml") + .filter(|path| file_name(path) == "_schema.yaml") + .collect::>(); + schemas + .iter() + .find(|path| { + fs::read_to_string(path) + .map(|content| content.contains("\"Related\":")) + .unwrap_or(false) + }) + .cloned() + .or_else(|| schemas.into_iter().next()) .expect("database schema file") } @@ -781,7 +819,82 @@ impl LiveCleanup { } fn create_database(&mut self, parent_page_id: &str, title: &str) -> DatabaseDto { + self.create_database_with_optional_relation(parent_page_id, title, None) + } + + fn create_database_with_relation( + &mut self, + parent_page_id: &str, + title: &str, + related_data_source_id: &str, + ) -> DatabaseDto { + self.create_database_with_optional_relation( + parent_page_id, + title, + Some(related_data_source_id), + ) + } + + fn create_database_with_optional_relation( + &mut self, + parent_page_id: &str, + title: &str, + related_data_source_id: Option<&str>, + ) -> DatabaseDto { let unique_prefix = unique_id_prefix(); + let mut properties = serde_json::Map::from_iter([ + ("Name".to_string(), json!({ "title": {} })), + ("Notes".to_string(), json!({ "rich_text": {} })), + ( + "Points".to_string(), + json!({ "number": { "format": "number" } }), + ), + ( + "Status".to_string(), + json!({ + "select": { + "options": [ + { "name": "Todo", "color": "gray" }, + { "name": "Done", "color": "green" } + ] + } + }), + ), + ("State".to_string(), json!({ "status": {} })), + ( + "Tags".to_string(), + json!({ + "multi_select": { + "options": [ + { "name": "Alpha", "color": "blue" }, + { "name": "Beta", "color": "purple" } + ] + } + }), + ), + ("Done".to_string(), json!({ "checkbox": {} })), + ("Due".to_string(), json!({ "date": {} })), + ("URL".to_string(), json!({ "url": {} })), + ("Email".to_string(), json!({ "email": {} })), + ("Phone".to_string(), json!({ "phone_number": {} })), + ("Files".to_string(), json!({ "files": {} })), + ("People".to_string(), json!({ "people": {} })), + ( + "Unique".to_string(), + json!({ "unique_id": { "prefix": unique_prefix } }), + ), + ]); + if let Some(data_source_id) = related_data_source_id { + properties.insert( + "Related".to_string(), + json!({ + "relation": { + "data_source_id": data_source_id, + "single_property": {}, + } + }), + ); + } let database = self .api .create_database(json!({ @@ -792,36 +905,7 @@ impl LiveCleanup { "title": rich_text_json(title), "initial_data_source": { "title": rich_text_json("Rows"), - "properties": { - "Name": { "title": {} }, - "Notes": { "rich_text": {} }, - "Points": { "number": { "format": "number" } }, - "Status": { - "select": { - "options": [ - { "name": "Todo", "color": "gray" }, - { "name": "Done", "color": "green" } - ] - } - }, - "State": { "status": {} }, - "Tags": { - "multi_select": { - "options": [ - { "name": "Alpha", "color": "blue" }, - { "name": "Beta", "color": "purple" } - ] - } - }, - "Done": { "checkbox": {} }, - "Due": { "date": {} }, - "URL": { "url": {} }, - "Email": { "email": {} }, - "Phone": { "phone_number": {} }, - "Files": { "files": {} }, - "People": { "people": {} }, - "Unique": { "unique_id": { "prefix": unique_prefix } } - } + "properties": Value::Object(properties) } })) .expect("create live database"); @@ -1239,8 +1323,9 @@ fn database_row_properties( state: &str, done: bool, url: &str, + related_page_ids: &[&str], ) -> serde_json::Map { - serde_json::Map::from_iter([ + let mut properties = serde_json::Map::from_iter([ ( "Notes".to_string(), json!({ "rich_text": rich_text_json(notes) }), @@ -1286,7 +1371,19 @@ fn database_row_properties( ] }), ), - ]) + ]); + if !related_page_ids.is_empty() { + properties.insert( + "Related".to_string(), + json!({ + "relation": related_page_ids + .iter() + .map(|id| json!({ "id": id })) + .collect::>() + }), + ); + } + properties } fn collect_files(root: &Path) -> Vec { @@ -1565,8 +1662,12 @@ fn unique_id_prefix() -> String { .duration_since(UNIX_EPOCH) .expect("clock") .as_nanos(); + let first_alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let mut prefix = String::new(); + let first_index = (value % first_alphabet.len() as u128) as usize; + prefix.push(first_alphabet[first_index] as char); + value /= first_alphabet.len() as u128; for _ in 0..6 { let index = (value % alphabet.len() as u128) as usize; prefix.push(alphabet[index] as char); diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 646a146..615b770 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -657,6 +657,7 @@ fn property_value_for_kind(kind: &str, value: &PropertyValue, key: &str) -> AfsR "date" => date_property(value, key), "url" | "email" | "phone_number" => nullable_string_property(kind, value, key), "files" => files_property(value, key), + "relation" => relation_property(value, key), _ => Err(AfsError::Unsupported("updating this Notion property type")), } } @@ -820,6 +821,40 @@ fn valid_url(value: &str) -> bool { value.starts_with("http://") || value.starts_with("https://") } +fn relation_property(value: &PropertyValue, key: &str) -> AfsResult { + let entries = match value { + PropertyValue::Null => Vec::new(), + PropertyValue::String(value) if value.trim().is_empty() => Vec::new(), + PropertyValue::String(value) => vec![value.as_str()], + PropertyValue::List(values) => values.iter().map(String::as_str).collect(), + _ => return Err(property_type_error(key, "Notion page ID string or list")), + }; + + let relations = entries + .into_iter() + .map(|entry| relation_property_value(entry, key)) + .collect::>>()?; + Ok(json!({ "relation": relations })) +} + +fn relation_property_value(entry: &str, key: &str) -> AfsResult { + let id = entry.trim(); + if !valid_notion_id(id) { + return Err(AfsError::Validation(vec![property_issue( + key, + "notion_property_relation_id_invalid", + "Notion relation properties must contain page IDs", + )])); + } + + Ok(json!({ "id": id })) +} + +fn valid_notion_id(value: &str) -> bool { + let compact = value.replace('-', ""); + compact.len() == 32 && compact.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + fn required_string(value: &PropertyValue, key: &str) -> AfsResult { match value { PropertyValue::String(value) => Ok(value.clone()), diff --git a/crates/afs-notion/src/schema.rs b/crates/afs-notion/src/schema.rs index d45ba18..2f86dc1 100644 --- a/crates/afs-notion/src/schema.rs +++ b/crates/afs-notion/src/schema.rs @@ -271,6 +271,7 @@ fn validate_value_for_property( "email" => validate_nullable_string_shape(value, "email address", valid_email), "phone_number" => validate_nullable_string(value, "phone number"), "files" => validate_files(value), + "relation" => validate_relation(value), _ => Ok(()), } } @@ -497,6 +498,37 @@ fn valid_url(value: &str) -> bool { value.starts_with("http://") || value.starts_with("https://") } +fn validate_relation(value: &PropertyValue) -> Result<(), (&'static str, String, &'static str)> { + let entries = match value { + PropertyValue::Null => return Ok(()), + PropertyValue::String(value) if value.trim().is_empty() => return Ok(()), + PropertyValue::String(value) => vec![value.as_str()], + PropertyValue::List(values) => values.iter().map(String::as_str).collect(), + _ => { + return Err(( + "notion_schema_property_type_mismatch", + "must be a Notion page ID string or list".to_string(), + "use Notion page IDs from the rendered relation property", + )); + } + }; + + if entries.iter().all(|entry| valid_notion_id(entry.trim())) { + Ok(()) + } else { + Err(( + "notion_schema_property_shape_invalid", + "must contain valid Notion page IDs".to_string(), + "use 32-character or hyphenated Notion page IDs", + )) + } +} + +fn valid_notion_id(value: &str) -> bool { + let compact = value.replace('-', ""); + compact.len() == 32 && compact.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + fn valid_email(value: &str) -> bool { let value = value.trim(); let Some((local, domain)) = value.split_once('@') else { @@ -596,6 +628,7 @@ impl PropertySchema { | "email" | "phone_number" | "files" + | "relation" ) } @@ -652,7 +685,7 @@ mod tests { #[test] fn validates_create_row_against_schema_options_and_types() { let parsed = parse_canonical_markdown( - "---\ntitle: New task\nStatus: Todo\nTags:\n - Backend\nDone: false\nPoints: 5\nDue:\n start: \"2026-06-10\"\nURL: https://example.com/afs\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \n---\n# Body\n", + "---\ntitle: New task\nStatus: Todo\nTags:\n - Backend\nDone: false\nPoints: 5\nDue:\n start: \"2026-06-10\"\nURL: https://example.com/afs\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \nRelation:\n - \"11111111111111111111111111111111\"\n---\n# Body\n", ) .expect("parse row"); @@ -664,7 +697,7 @@ mod tests { #[test] fn rejects_unknown_options_and_read_only_properties() { let parsed = parse_canonical_markdown( - "---\ntitle: New task\nStatus: Blocked\nFormula: edited\nFiles:\n - not-a-url\n---\n# Body\n", + "---\ntitle: New task\nStatus: Blocked\nFormula: edited\nFiles:\n - not-a-url\nRelation:\n - bad-id\n---\n# Body\n", ) .expect("parse row"); @@ -679,6 +712,7 @@ mod tests { vec![ "notion_schema_property_shape_invalid", "notion_schema_property_read_only", + "notion_schema_property_shape_invalid", "notion_schema_option_unknown" ] ); @@ -780,6 +814,9 @@ data_sources: Files: id: "files-id" type: "files" + Relation: + id: "relation-id" + type: "relation" Formula: id: "formula-id" type: "formula" diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index 8978ffd..5eae876 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -1046,6 +1046,7 @@ fn apply_updates_supported_page_properties() { ("Due".to_string(), page_property("date")), ("URL".to_string(), page_property("url")), ("Files".to_string(), page_property("files")), + ("Relation".to_string(), page_property("relation")), ]), )); let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); @@ -1083,6 +1084,13 @@ fn apply_updates_supported_page_properties() { "https://example.com/diagram.png".to_string(), ]), ), + ( + "Relation".to_string(), + PropertyValue::List(vec![ + "11111111111111111111111111111111".to_string(), + "22222222-2222-2222-2222-222222222222".to_string(), + ]), + ), ] .into_iter() .collect(), @@ -1114,6 +1122,7 @@ fn apply_updates_supported_page_properties() { "Due".to_string(), "Files".to_string(), "Points".to_string(), + "Relation".to_string(), "Status".to_string(), "Tags".to_string(), "URL".to_string(), @@ -1174,6 +1183,12 @@ fn apply_updates_supported_page_properties() { }, ], }, + "Relation": { + "relation": [ + { "id": "11111111111111111111111111111111" }, + { "id": "22222222-2222-2222-2222-222222222222" }, + ], + }, }, }), }] @@ -1191,6 +1206,7 @@ fn apply_creates_database_row_with_properties_and_children() { ("Done".to_string(), data_source_property("checkbox")), ("Points".to_string(), data_source_property("number")), ("Files".to_string(), data_source_property("files")), + ("Relation".to_string(), data_source_property("relation")), ]), )); let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); @@ -1217,6 +1233,10 @@ fn apply_creates_database_row_with_properties_and_children() { "Design ".to_string(), ]), ), + ( + "Relation".to_string(), + PropertyValue::List(vec!["33333333333333333333333333333333".to_string()]), + ), ] .into_iter() .collect(), @@ -1292,6 +1312,11 @@ fn apply_creates_database_row_with_properties_and_children() { }, ], }, + "Relation": { + "relation": [ + { "id": "33333333333333333333333333333333" }, + ], + }, }, "children": [ { diff --git a/crates/afs-notion/tests/live_integrity.rs b/crates/afs-notion/tests/live_integrity.rs index 08dc0b7..189391a 100644 --- a/crates/afs-notion/tests/live_integrity.rs +++ b/crates/afs-notion/tests/live_integrity.rs @@ -248,14 +248,42 @@ fn live_database_row_property_create_edit_verify_integrity() { &format!("AFS live database scratch {}", unique_suffix()), Vec::new(), ); - let database = - cleanup.create_database(&scratch.id, &format!("AFS live rows {}", unique_suffix())); + let related_database = cleanup.create_database( + &scratch.id, + &format!("AFS live related rows {}", unique_suffix()), + ); + let related_data_source_id = related_database + .data_sources + .first() + .expect("related data source") + .id + .clone(); + let related_row_initial = cleanup.create_database_row( + &related_database, + &format!("AFS live related initial {}", unique_suffix()), + serde_json::Map::new(), + Vec::new(), + ); + let related_row_updated = cleanup.create_database_row( + &related_database, + &format!("AFS live related updated {}", unique_suffix()), + serde_json::Map::new(), + Vec::new(), + ); + let database = cleanup.create_database_with_relation( + &scratch.id, + &format!("AFS live rows {}", unique_suffix()), + &related_data_source_id, + ); let database_id = RemoteId::new(database.id.clone()); let schema_yaml = connector .database_schema_yaml(&database_id) .expect("live schema"); let valid_row = parse_canonical_markdown( - "---\ntitle: AFS created row\nNotes: Rich row notes\nPoints: 42\nStatus: Todo\nState: Not started\nTags:\n - Alpha\n - Beta\nDone: false\nDue: \"2026-06-10\"\nURL: https://example.com/afs-live\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \n---\n# Row body\n", + &format!( + "---\ntitle: AFS created row\nNotes: Rich row notes\nPoints: 42\nStatus: Todo\nState: Not started\nTags:\n - Alpha\n - Beta\nDone: false\nDue: \"2026-06-10\"\nURL: https://example.com/afs-live\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \nRelated:\n - \"{}\"\n---\n# Row body\n", + related_row_initial.id + ), ) .expect("valid row frontmatter"); assert!(validate_create_row_frontmatter(&schema_yaml, &valid_row, "Rows/valid.md").is_clean()); @@ -321,6 +349,10 @@ fn live_database_row_property_create_edit_verify_integrity() { "Files".to_string(), PropertyValue::List(vec!["Spec ".to_string()]), ), + ( + "Related".to_string(), + PropertyValue::List(vec![related_row_initial.id.clone()]), + ), ]), body: "# Row body\n\nCreated from live integration.\n".to_string(), source_path: "Rows/afs-created-row.md".into(), @@ -372,6 +404,12 @@ fn live_database_row_property_create_edit_verify_integrity() { .frontmatter .contains("\"Spec \"") ); + assert!( + rendered + .document + .frontmatter + .contains(&format!("\"{}\"", related_row_initial.id)) + ); let update = PushPlan::new( vec![row_id.clone()], @@ -397,6 +435,10 @@ fn live_database_row_property_create_edit_verify_integrity() { "Spec updated ".to_string(), ]), ), + ( + "Related".to_string(), + PropertyValue::List(vec![related_row_updated.id.clone()]), + ), ]), }], ); @@ -438,6 +480,12 @@ fn live_database_row_property_create_edit_verify_integrity() { .frontmatter .contains("\"Spec updated \"") ); + assert!( + verified_render + .document + .frontmatter + .contains(&format!("\"{}\"", related_row_updated.id)) + ); } #[derive(Clone, Debug)] @@ -503,7 +551,82 @@ impl LiveCleanup { } fn create_database(&mut self, parent_page_id: &str, title: &str) -> DatabaseDto { + self.create_database_with_optional_relation(parent_page_id, title, None) + } + + fn create_database_with_relation( + &mut self, + parent_page_id: &str, + title: &str, + related_data_source_id: &str, + ) -> DatabaseDto { + self.create_database_with_optional_relation( + parent_page_id, + title, + Some(related_data_source_id), + ) + } + + fn create_database_with_optional_relation( + &mut self, + parent_page_id: &str, + title: &str, + related_data_source_id: Option<&str>, + ) -> DatabaseDto { let unique_prefix = unique_id_prefix(); + let mut properties = serde_json::Map::from_iter([ + ("Name".to_string(), json!({ "title": {} })), + ("Notes".to_string(), json!({ "rich_text": {} })), + ( + "Points".to_string(), + json!({ "number": { "format": "number" } }), + ), + ( + "Status".to_string(), + json!({ + "select": { + "options": [ + { "name": "Todo", "color": "gray" }, + { "name": "Done", "color": "green" } + ] + } + }), + ), + ("State".to_string(), json!({ "status": {} })), + ( + "Tags".to_string(), + json!({ + "multi_select": { + "options": [ + { "name": "Alpha", "color": "blue" }, + { "name": "Beta", "color": "purple" } + ] + } + }), + ), + ("Done".to_string(), json!({ "checkbox": {} })), + ("Due".to_string(), json!({ "date": {} })), + ("URL".to_string(), json!({ "url": {} })), + ("Email".to_string(), json!({ "email": {} })), + ("Phone".to_string(), json!({ "phone_number": {} })), + ("Files".to_string(), json!({ "files": {} })), + ("People".to_string(), json!({ "people": {} })), + ( + "Unique".to_string(), + json!({ "unique_id": { "prefix": unique_prefix } }), + ), + ]); + if let Some(data_source_id) = related_data_source_id { + properties.insert( + "Related".to_string(), + json!({ + "relation": { + "data_source_id": data_source_id, + "single_property": {}, + } + }), + ); + } let database = self .api .create_database(json!({ @@ -514,42 +637,43 @@ impl LiveCleanup { "title": rich_text(title), "initial_data_source": { "title": rich_text("Rows"), - "properties": { - "Name": { "title": {} }, - "Notes": { "rich_text": {} }, - "Points": { "number": { "format": "number" } }, - "Status": { - "select": { - "options": [ - { "name": "Todo", "color": "gray" }, - { "name": "Done", "color": "green" } - ] - } - }, - "State": { "status": {} }, - "Tags": { - "multi_select": { - "options": [ - { "name": "Alpha", "color": "blue" }, - { "name": "Beta", "color": "purple" } - ] - } - }, - "Done": { "checkbox": {} }, - "Due": { "date": {} }, - "URL": { "url": {} }, - "Email": { "email": {} }, - "Phone": { "phone_number": {} }, - "Files": { "files": {} }, - "People": { "people": {} }, - "Unique": { "unique_id": { "prefix": unique_prefix } } - } + "properties": Value::Object(properties) } })) .expect("create live database"); self.block_ids.push(database.id.clone()); database } + + fn create_database_row( + &mut self, + database: &DatabaseDto, + title: &str, + mut properties: serde_json::Map, + children: Vec, + ) -> PageDto { + let data_source = database + .data_sources + .first() + .expect("created database data source"); + properties.insert("Name".to_string(), json!({ "title": rich_text(title) })); + let mut body = json!({ + "parent": { + "type": "data_source_id", + "data_source_id": data_source.id, + }, + "properties": Value::Object(properties), + }); + if !children.is_empty() { + body["children"] = Value::Array(children); + } + let page = self + .api + .create_page(body) + .expect("create live database row"); + self.block_ids.push(page.id.clone()); + page + } } impl Drop for LiveCleanup { @@ -951,8 +1075,12 @@ fn unique_id_prefix() -> String { .duration_since(UNIX_EPOCH) .expect("clock") .as_nanos(); + let first_alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let mut prefix = String::new(); + let first_index = (value % first_alphabet.len() as u128) as usize; + prefix.push(first_alphabet[first_index] as char); + value /= first_alphabet.len() as u128; for _ in 0..6 { let index = (value % alphabet.len() as u128) as usize; prefix.push(alphabet[index] as char); diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 5f89948..ee25778 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -62,7 +62,7 @@ title: "Fix login bug" Supported read-side property values include title, rich text, number, select, multi-select, status, checkbox, date, URL, email, phone, files, people, relation IDs, created/edited timestamps, created/edited users, formula, rollup, unique ID, and verification values. -Property writes are planned by comparing edited frontmatter against the shadow frontmatter captured during the last render. The Notion writer currently applies title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, and external file URL properties. File entries use either `https://...` or `Name ` frontmatter list values and write Notion external file objects. Read-only, computed, or identity-backed property classes such as people, relation, formula, rollup, created/edited timestamps, created/edited users, unique ID, and verification remain read-side only until schema validation and richer property preimages are added. +Property writes are planned by comparing edited frontmatter against the shadow frontmatter captured during the last render. The Notion writer currently applies title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URL, and relation properties. File entries use either `https://...` or `Name ` frontmatter list values and write Notion external file objects. Relation entries use Notion page IDs as strings or YAML lists. Read-only, computed, or identity-backed property classes such as people, formula, rollup, created/edited timestamps, created/edited users, unique ID, and verification remain read-side only until schema validation and richer property preimages are added. A new database row starts as the same document shape without generated identity fields: diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 8e4a357..4651eef 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -113,7 +113,7 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, same-shape existing tables, existing bookmark/embed URL blocks, and existing URL-backed media blocks; - supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, legacy `afs://` page links, and unchanged preimage mentions such as dates; -- supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, and external file URLs; +- supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, and explicit relation page IDs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; - unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; - appends use Notion's current position object, with `start` for prepends and `after_block` for inserts after a known block; @@ -128,7 +128,7 @@ Projected database directories carry `_schema.yaml`, generated from the live Not For existing rows, only changed frontmatter properties are validated, so read-only values rendered from Notion, such as formulas or rollups, can remain in the file unchanged. For new row files, every non-identity frontmatter property is validated because all of them become create-page payload fields. -The current validator supports the same writable property set as apply: `title`, `rich_text`, `number`, `select`, `status`, `multi_select`, `checkbox`, `date`, `url`, `email`, and `phone_number`. Select-like values must use option names already present in `_schema.yaml`; unknown options stop as `fix_validation` instead of implicitly creating new Notion options. Computed, read-only, or unresolved types such as `files`, `people`, `relation`, `formula`, `rollup`, timestamps, users, `unique_id`, and `verification` are blocked with structured validation errors until their ownership and resolution policies are designed. +The current validator supports the same writable property set as apply: `title`, `rich_text`, `number`, `select`, `status`, `multi_select`, `checkbox`, `date`, `url`, `email`, `phone_number`, external `files`, and `relation` page IDs. Select-like values must use option names already present in `_schema.yaml`; unknown options stop as `fix_validation` instead of implicitly creating new Notion options. Computed, read-only, or unresolved types such as `people`, `formula`, `rollup`, timestamps, users, `unique_id`, and `verification` are blocked with structured validation errors until their ownership and resolution policies are designed. Multi-data-source databases still stop before row writes because AFS does not yet have a path-level way to choose the target data source. Pull the database again if `_schema.yaml` is missing or stale. diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 15137bf..d6b9d64 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -129,3 +129,23 @@ and what Markdown shape agents should expect. payloads. Schema tests validate accepted/rejected file frontmatter. The live mounted database cycle edits and creates rows with file properties, and the live direct integrity test creates and updates a file property through the API. + +### Relation Properties + +- **Notion input:** Database/page `relation` properties containing related page + IDs. Live fixtures create the target database first, then create a + single-property relation schema pointing at that target data source. +- **Markdown output:** Relation properties render as frontmatter lists of + Notion page IDs, matching the current read projection. +- **Write behavior:** Frontmatter edits can write a string or YAML list of + explicit Notion page IDs. Clearing with `null`, an empty string, or an empty + list is supported by the same writer shape. Resolving relation targets by + local path, row title, or workspace search remains deferred. +- **Live finding:** Current Notion relation schema creation rejects a relation + with only `data_source_id`; the live fixture must include + `single_property: {}` or `dual_property` in the relation schema. +- **Verification:** Fixture apply tests assert exact page update and row create + payloads. Schema tests validate accepted/rejected relation frontmatter. The + live mounted database cycle reads, creates, and verifies relation properties, + and the live direct integrity test creates then updates a relation property + through the API. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index be2d97a..8fc8b00 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -102,7 +102,7 @@ Sources used for the baseline: | `phone_number` | Yes | Yes | fixture, live, mounted live, schema | Nullable string. | | `files` | Yes | Yes for external URLs | fixture, live read/write, schema | Frontmatter accepts `https://...` or `Name ` entries and writes Notion external file objects. Hosted/uploaded file ownership remains read-only. | | `people` | Yes | No | fixture, live read-empty, schema-blocked | Needs user lookup and permission-aware validation before writes. | -| `relation` | Yes | No | fixture, schema-blocked | Needs target data-source schema and path/ID resolution before writes. | +| `relation` | Yes | Yes for explicit page IDs | fixture, live read/write, schema | Frontmatter accepts a Notion page ID string or list of page IDs. Path/title resolution is deferred. | | `formula` | Yes | No | fixture, schema-blocked | Computed/read-only by Notion. | | `rollup` | Yes | No | fixture, schema-blocked | Computed/read-only by Notion. | | `created_time` | Yes | No | fixture | Read-only by Notion. | @@ -123,8 +123,9 @@ Sources used for the baseline: tabs) stay as directives because Markdown cannot represent their semantics. - Comments are not mounted because they need a separate thread model and push policy. -- People/relation writes are blocked until validation can resolve user IDs and - related page IDs from local references. +- People writes are blocked until validation can resolve user IDs. Relation + writes currently require explicit related page IDs; path/title resolution is + deferred. ## Next Block Work From 70a3bdf3b9fb27249f5acce590ab382683b1092e Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 07:02:26 -0500 Subject: [PATCH 11/18] Support people property writes --- crates/afs-cli/tests/e2e_push_workflow.rs | 36 ++++++++++++++- crates/afs-notion/src/apply.rs | 42 +++++++++++++++++- crates/afs-notion/src/render.rs | 17 ++++--- crates/afs-notion/src/schema.rs | 54 +++++++++++++++++++++-- crates/afs-notion/tests/apply.rs | 23 ++++++++++ crates/afs-notion/tests/fetch_render.rs | 2 +- crates/afs-notion/tests/live_integrity.rs | 37 +++++++++++++++- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 4 +- docs/notion-cyclic-support-journal.md | 18 ++++++++ docs/notion-object-support.md | 10 ++--- 11 files changed, 223 insertions(+), 22 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index 045add2..e0c2801 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -444,6 +444,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { let env = LiveEnv::from_env(); let api = HttpNotionApi::new(NotionConfig::default()); let mut cleanup = LiveCleanup::new(api); + let people_user_id = cleanup.current_user_id(); let scratch = cleanup.create_page( &env.parent_page_id, &format!("AFS cyclic database scratch {}", unique_suffix()), @@ -480,6 +481,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "Not started", false, "https://example.com/afs-db-row", + &[], &[related_row.id.as_str()], ), vec![paragraph_child("Database row paragraph original.")], @@ -518,6 +520,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"Email\":", "\"Phone\":", "\"Files\":", + "\"People\":", "\"Related\":", ] { assert!(schema.contains(expected), "missing {expected:?}\n{schema}"); @@ -536,6 +539,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"URL\": \"https://example.com/afs-db-row\"", "\"Files\":", "\"Initial file \"", + "\"People\": []", "\"Related\":", &format!("\"{}\"", related_row.id), "Database row paragraph original.", @@ -592,6 +596,10 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"Initial file \"", "\"Updated file \"", ) + .replace( + "\"People\": []", + &format!("\"People\":\n - \"{}\"", people_user_id), + ) .replace( "Database row paragraph original.", "Database row paragraph changed.", @@ -629,6 +637,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"Done\": true", "\"URL\": \"https://example.com/afs-db-row-updated\"", "\"Updated file \"", + &people_user_id, &format!("\"{}\"", related_row.id), "Database row paragraph changed.", ] { @@ -643,8 +652,8 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { fs::write( &new_row_path, &format!( - "---\ntitle: AFS cyclic created row\nNotes: Created row notes\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\nFiles:\n - Created file \nRelated:\n - \"{}\"\n---\n# Created row body\n\nCreated from mounted markdown.\n", - related_row.id + "---\ntitle: AFS cyclic created row\nNotes: Created row notes\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\nFiles:\n - Created file \nPeople:\n - \"{}\"\nRelated:\n - \"{}\"\n---\n# Created row body\n\nCreated from mounted markdown.\n", + people_user_id, related_row.id ), ) .expect("write new live database row file"); @@ -683,6 +692,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { "\"Email\": \"cyclic@example.com\"", "\"Phone\": \"+1 415 555 0199\"", "\"Created file \"", + &people_user_id, &format!("\"{}\"", related_row.id), "Created from mounted markdown.", ] { @@ -818,6 +828,16 @@ impl LiveCleanup { page } + fn current_user_id(&self) -> String { + self.api + .retrieve_current_user() + .expect("retrieve current Notion user") + .get("id") + .and_then(Value::as_str) + .expect("current Notion user id") + .to_string() + } + fn create_database(&mut self, parent_page_id: &str, title: &str) -> DatabaseDto { self.create_database_with_optional_relation(parent_page_id, title, None) } @@ -1323,6 +1343,7 @@ fn database_row_properties( state: &str, done: bool, url: &str, + people_user_ids: &[&str], related_page_ids: &[&str], ) -> serde_json::Map { let mut properties = serde_json::Map::from_iter([ @@ -1383,6 +1404,17 @@ fn database_row_properties( }), ); } + if !people_user_ids.is_empty() { + properties.insert( + "People".to_string(), + json!({ + "people": people_user_ids + .iter() + .map(|id| json!({ "id": id })) + .collect::>() + }), + ); + } properties } diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 615b770..138d472 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -657,6 +657,7 @@ fn property_value_for_kind(kind: &str, value: &PropertyValue, key: &str) -> AfsR "date" => date_property(value, key), "url" | "email" | "phone_number" => nullable_string_property(kind, value, key), "files" => files_property(value, key), + "people" => people_property(value, key), "relation" => relation_property(value, key), _ => Err(AfsError::Unsupported("updating this Notion property type")), } @@ -821,6 +822,35 @@ fn valid_url(value: &str) -> bool { value.starts_with("http://") || value.starts_with("https://") } +fn people_property(value: &PropertyValue, key: &str) -> AfsResult { + let entries = match value { + PropertyValue::Null => Vec::new(), + PropertyValue::String(value) if value.trim().is_empty() => Vec::new(), + PropertyValue::String(value) => vec![value.as_str()], + PropertyValue::List(values) => values.iter().map(String::as_str).collect(), + _ => return Err(property_type_error(key, "Notion user ID string or list")), + }; + + let people = entries + .into_iter() + .map(|entry| people_property_value(entry, key)) + .collect::>>()?; + Ok(json!({ "people": people })) +} + +fn people_property_value(entry: &str, key: &str) -> AfsResult { + let id = parse_named_id_entry(entry).trim(); + if !valid_notion_id(id) { + return Err(AfsError::Validation(vec![property_issue( + key, + "notion_property_people_id_invalid", + "Notion people properties must contain user IDs", + )])); + } + + Ok(json!({ "id": id })) +} + fn relation_property(value: &PropertyValue, key: &str) -> AfsResult { let entries = match value { PropertyValue::Null => Vec::new(), @@ -838,7 +868,7 @@ fn relation_property(value: &PropertyValue, key: &str) -> AfsResult { } fn relation_property_value(entry: &str, key: &str) -> AfsResult { - let id = entry.trim(); + let id = parse_named_id_entry(entry).trim(); if !valid_notion_id(id) { return Err(AfsError::Validation(vec![property_issue( key, @@ -850,6 +880,16 @@ fn relation_property_value(entry: &str, key: &str) -> AfsResult { Ok(json!({ "id": id })) } +fn parse_named_id_entry(entry: &str) -> &str { + let trimmed = entry.trim(); + if let Some(without_close) = trimmed.strip_suffix('>') + && let Some((_, id)) = without_close.rsplit_once(" <") + { + return id; + } + trimmed +} + fn valid_notion_id(value: &str) -> bool { let compact = value.replace('-', ""); compact.len() == 32 && compact.bytes().all(|byte| byte.is_ascii_hexdigit()) diff --git a/crates/afs-notion/src/render.rs b/crates/afs-notion/src/render.rs index 5deae6f..53a45d8 100644 --- a/crates/afs-notion/src/render.rs +++ b/crates/afs-notion/src/render.rs @@ -944,11 +944,7 @@ fn property_frontmatter_value(property: &PagePropertyDto) -> Option Some(FrontmatterValue::List( - property - .people - .iter() - .map(|user| user.name.as_deref().unwrap_or(user.id.as_str()).to_string()) - .collect(), + property.people.iter().map(user_property_label).collect(), )), "relation" => Some(FrontmatterValue::List( property @@ -1033,6 +1029,17 @@ fn file_property_label(file: &crate::dto::FilePropertyDto) -> String { } } +fn user_property_label(user: &crate::dto::UserMentionDto) -> String { + let id = user.id.as_str(); + let name = user.name.as_deref().unwrap_or_default(); + match (name.is_empty(), id.is_empty()) { + (false, false) => format!("{name} <{id}>"), + (false, true) => name.to_string(), + (true, false) => id.to_string(), + (true, true) => String::new(), + } +} + fn formula_value(value: &Value) -> Option { let kind = value.get("type").and_then(Value::as_str)?; match kind { diff --git a/crates/afs-notion/src/schema.rs b/crates/afs-notion/src/schema.rs index 2f86dc1..09e03ff 100644 --- a/crates/afs-notion/src/schema.rs +++ b/crates/afs-notion/src/schema.rs @@ -271,6 +271,7 @@ fn validate_value_for_property( "email" => validate_nullable_string_shape(value, "email address", valid_email), "phone_number" => validate_nullable_string(value, "phone number"), "files" => validate_files(value), + "people" => validate_people(value), "relation" => validate_relation(value), _ => Ok(()), } @@ -498,6 +499,35 @@ fn valid_url(value: &str) -> bool { value.starts_with("http://") || value.starts_with("https://") } +fn validate_people(value: &PropertyValue) -> Result<(), (&'static str, String, &'static str)> { + let entries = match value { + PropertyValue::Null => return Ok(()), + PropertyValue::String(value) if value.trim().is_empty() => return Ok(()), + PropertyValue::String(value) => vec![value.as_str()], + PropertyValue::List(values) => values.iter().map(String::as_str).collect(), + _ => { + return Err(( + "notion_schema_property_type_mismatch", + "must be a Notion user ID string or list".to_string(), + "use Notion user IDs from the rendered people property", + )); + } + }; + + if entries + .iter() + .all(|entry| valid_notion_id(parse_named_id_entry(entry).trim())) + { + Ok(()) + } else { + Err(( + "notion_schema_property_shape_invalid", + "must contain valid Notion user IDs".to_string(), + "use 32-character or hyphenated Notion user IDs", + )) + } +} + fn validate_relation(value: &PropertyValue) -> Result<(), (&'static str, String, &'static str)> { let entries = match value { PropertyValue::Null => return Ok(()), @@ -513,7 +543,10 @@ fn validate_relation(value: &PropertyValue) -> Result<(), (&'static str, String, } }; - if entries.iter().all(|entry| valid_notion_id(entry.trim())) { + if entries + .iter() + .all(|entry| valid_notion_id(parse_named_id_entry(entry).trim())) + { Ok(()) } else { Err(( @@ -524,6 +557,16 @@ fn validate_relation(value: &PropertyValue) -> Result<(), (&'static str, String, } } +fn parse_named_id_entry(entry: &str) -> &str { + let trimmed = entry.trim(); + if let Some(without_close) = trimmed.strip_suffix('>') + && let Some((_, id)) = without_close.rsplit_once(" <") + { + return id; + } + trimmed +} + fn valid_notion_id(value: &str) -> bool { let compact = value.replace('-', ""); compact.len() == 32 && compact.bytes().all(|byte| byte.is_ascii_hexdigit()) @@ -628,6 +671,7 @@ impl PropertySchema { | "email" | "phone_number" | "files" + | "people" | "relation" ) } @@ -685,7 +729,7 @@ mod tests { #[test] fn validates_create_row_against_schema_options_and_types() { let parsed = parse_canonical_markdown( - "---\ntitle: New task\nStatus: Todo\nTags:\n - Backend\nDone: false\nPoints: 5\nDue:\n start: \"2026-06-10\"\nURL: https://example.com/afs\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \nRelation:\n - \"11111111111111111111111111111111\"\n---\n# Body\n", + "---\ntitle: New task\nStatus: Todo\nTags:\n - Backend\nDone: false\nPoints: 5\nDue:\n start: \"2026-06-10\"\nURL: https://example.com/afs\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \nPeople:\n - Ada <11111111111111111111111111111111>\nRelation:\n - \"11111111111111111111111111111111\"\n---\n# Body\n", ) .expect("parse row"); @@ -697,7 +741,7 @@ mod tests { #[test] fn rejects_unknown_options_and_read_only_properties() { let parsed = parse_canonical_markdown( - "---\ntitle: New task\nStatus: Blocked\nFormula: edited\nFiles:\n - not-a-url\nRelation:\n - bad-id\n---\n# Body\n", + "---\ntitle: New task\nStatus: Blocked\nFormula: edited\nFiles:\n - not-a-url\nPeople:\n - not-a-user-id\nRelation:\n - bad-id\n---\n# Body\n", ) .expect("parse row"); @@ -713,6 +757,7 @@ mod tests { "notion_schema_property_shape_invalid", "notion_schema_property_read_only", "notion_schema_property_shape_invalid", + "notion_schema_property_shape_invalid", "notion_schema_option_unknown" ] ); @@ -814,6 +859,9 @@ data_sources: Files: id: "files-id" type: "files" + People: + id: "people-id" + type: "people" Relation: id: "relation-id" type: "relation" diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index 5eae876..2ebcc3b 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -1046,6 +1046,7 @@ fn apply_updates_supported_page_properties() { ("Due".to_string(), page_property("date")), ("URL".to_string(), page_property("url")), ("Files".to_string(), page_property("files")), + ("People".to_string(), page_property("people")), ("Relation".to_string(), page_property("relation")), ]), )); @@ -1084,6 +1085,12 @@ fn apply_updates_supported_page_properties() { "https://example.com/diagram.png".to_string(), ]), ), + ( + "People".to_string(), + PropertyValue::List(vec![ + "Ada ".to_string(), + ]), + ), ( "Relation".to_string(), PropertyValue::List(vec![ @@ -1121,6 +1128,7 @@ fn apply_updates_supported_page_properties() { "Done".to_string(), "Due".to_string(), "Files".to_string(), + "People".to_string(), "Points".to_string(), "Relation".to_string(), "Status".to_string(), @@ -1183,6 +1191,11 @@ fn apply_updates_supported_page_properties() { }, ], }, + "People": { + "people": [ + { "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, + ], + }, "Relation": { "relation": [ { "id": "11111111111111111111111111111111" }, @@ -1206,6 +1219,7 @@ fn apply_creates_database_row_with_properties_and_children() { ("Done".to_string(), data_source_property("checkbox")), ("Points".to_string(), data_source_property("number")), ("Files".to_string(), data_source_property("files")), + ("People".to_string(), data_source_property("people")), ("Relation".to_string(), data_source_property("relation")), ]), )); @@ -1233,6 +1247,10 @@ fn apply_creates_database_row_with_properties_and_children() { "Design ".to_string(), ]), ), + ( + "People".to_string(), + PropertyValue::List(vec!["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()]), + ), ( "Relation".to_string(), PropertyValue::List(vec!["33333333333333333333333333333333".to_string()]), @@ -1312,6 +1330,11 @@ fn apply_creates_database_row_with_properties_and_children() { }, ], }, + "People": { + "people": [ + { "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, + ], + }, "Relation": { "relation": [ { "id": "33333333333333333333333333333333" }, diff --git a/crates/afs-notion/tests/fetch_render.rs b/crates/afs-notion/tests/fetch_render.rs index 17ec2fa..43d7153 100644 --- a/crates/afs-notion/tests/fetch_render.rs +++ b/crates/afs-notion/tests/fetch_render.rs @@ -1189,7 +1189,7 @@ fn render_all_supported_page_property_values_as_frontmatter() { "\"Email\": \"agentfs@example.com\"", "\"Phone\": \"+1 415 555 0100\"", "\"Files\":\n - \"Spec \"\n - \"https://example.com/hosted.png\"", - "\"People\":\n - \"Ada\"", + "\"People\":\n - \"Ada \"", "\"Relation\":\n - \"related-page-1\"", "\"Created Time\": \"2026-06-10T00:00:00.000Z\"", "\"Last Edited Time\": \"2026-06-11T00:00:00.000Z\"", diff --git a/crates/afs-notion/tests/live_integrity.rs b/crates/afs-notion/tests/live_integrity.rs index 189391a..a512184 100644 --- a/crates/afs-notion/tests/live_integrity.rs +++ b/crates/afs-notion/tests/live_integrity.rs @@ -243,6 +243,7 @@ fn live_database_row_property_create_edit_verify_integrity() { let api = Arc::new(LiveNotion::new(env.token.clone())); let mut cleanup = LiveCleanup::new(api.clone()); let connector = NotionConnector::new(NotionConfig::default()); + let people_user_id = cleanup.current_user_id(); let scratch = cleanup.create_page( &env.parent_page_id, &format!("AFS live database scratch {}", unique_suffix()), @@ -281,8 +282,8 @@ fn live_database_row_property_create_edit_verify_integrity() { .expect("live schema"); let valid_row = parse_canonical_markdown( &format!( - "---\ntitle: AFS created row\nNotes: Rich row notes\nPoints: 42\nStatus: Todo\nState: Not started\nTags:\n - Alpha\n - Beta\nDone: false\nDue: \"2026-06-10\"\nURL: https://example.com/afs-live\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \nRelated:\n - \"{}\"\n---\n# Row body\n", - related_row_initial.id + "---\ntitle: AFS created row\nNotes: Rich row notes\nPoints: 42\nStatus: Todo\nState: Not started\nTags:\n - Alpha\n - Beta\nDone: false\nDue: \"2026-06-10\"\nURL: https://example.com/afs-live\nEmail: agentfs@example.com\nPhone: \"+1 415 555 0100\"\nFiles:\n - Spec \nPeople:\n - \"{}\"\nRelated:\n - \"{}\"\n---\n# Row body\n", + people_user_id, related_row_initial.id ), ) .expect("valid row frontmatter"); @@ -349,6 +350,10 @@ fn live_database_row_property_create_edit_verify_integrity() { "Files".to_string(), PropertyValue::List(vec!["Spec ".to_string()]), ), + ( + "People".to_string(), + PropertyValue::List(vec![people_user_id.clone()]), + ), ( "Related".to_string(), PropertyValue::List(vec![related_row_initial.id.clone()]), @@ -404,6 +409,7 @@ fn live_database_row_property_create_edit_verify_integrity() { .frontmatter .contains("\"Spec \"") ); + assert!(rendered.document.frontmatter.contains(&people_user_id)); assert!( rendered .document @@ -435,6 +441,7 @@ fn live_database_row_property_create_edit_verify_integrity() { "Spec updated ".to_string(), ]), ), + ("People".to_string(), PropertyValue::List(Vec::new())), ( "Related".to_string(), PropertyValue::List(vec![related_row_updated.id.clone()]), @@ -480,6 +487,18 @@ fn live_database_row_property_create_edit_verify_integrity() { .frontmatter .contains("\"Spec updated \"") ); + assert!( + verified_render + .document + .frontmatter + .contains("\"People\": []") + ); + assert!( + !verified_render + .document + .frontmatter + .contains(&people_user_id) + ); assert!( verified_render .document @@ -550,6 +569,12 @@ impl LiveCleanup { page } + fn current_user_id(&self) -> String { + self.api + .current_user_id() + .expect("retrieve current user id") + } + fn create_database(&mut self, parent_page_id: &str, title: &str) -> DatabaseDto { self.create_database_with_optional_relation(parent_page_id, title, None) } @@ -706,6 +731,14 @@ impl LiveNotion { self.send_json(reqwest::Method::POST, "/v1/databases", Some(body)) } + fn current_user_id(&self) -> Result { + let user = self.send_json::(reqwest::Method::GET, "/v1/users/me", None)?; + user.get("id") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "current Notion user response had no id".to_string()) + } + fn archive_block(&self, block_id: &str) -> Result { self.send_json::( reqwest::Method::DELETE, diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index ee25778..0b6c0b9 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -62,7 +62,7 @@ title: "Fix login bug" Supported read-side property values include title, rich text, number, select, multi-select, status, checkbox, date, URL, email, phone, files, people, relation IDs, created/edited timestamps, created/edited users, formula, rollup, unique ID, and verification values. -Property writes are planned by comparing edited frontmatter against the shadow frontmatter captured during the last render. The Notion writer currently applies title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URL, and relation properties. File entries use either `https://...` or `Name ` frontmatter list values and write Notion external file objects. Relation entries use Notion page IDs as strings or YAML lists. Read-only, computed, or identity-backed property classes such as people, formula, rollup, created/edited timestamps, created/edited users, unique ID, and verification remain read-side only until schema validation and richer property preimages are added. +Property writes are planned by comparing edited frontmatter against the shadow frontmatter captured during the last render. The Notion writer currently applies title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URL, people, and relation properties. File entries use either `https://...` or `Name ` frontmatter list values and write Notion external file objects. People entries use Notion user IDs or `Name ` strings. Relation entries use Notion page IDs as strings or YAML lists. Read-only, computed, or identity-backed property classes such as formula, rollup, created/edited timestamps, created/edited users, unique ID, and verification remain read-side only until schema validation and richer property preimages are added. A new database row starts as the same document shape without generated identity fields: diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 4651eef..b1172d9 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -113,7 +113,7 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, same-shape existing tables, existing bookmark/embed URL blocks, and existing URL-backed media blocks; - supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, legacy `afs://` page links, and unchanged preimage mentions such as dates; -- supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, and explicit relation page IDs; +- supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, explicit people user IDs, and explicit relation page IDs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; - unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; - appends use Notion's current position object, with `start` for prepends and `after_block` for inserts after a known block; @@ -128,7 +128,7 @@ Projected database directories carry `_schema.yaml`, generated from the live Not For existing rows, only changed frontmatter properties are validated, so read-only values rendered from Notion, such as formulas or rollups, can remain in the file unchanged. For new row files, every non-identity frontmatter property is validated because all of them become create-page payload fields. -The current validator supports the same writable property set as apply: `title`, `rich_text`, `number`, `select`, `status`, `multi_select`, `checkbox`, `date`, `url`, `email`, `phone_number`, external `files`, and `relation` page IDs. Select-like values must use option names already present in `_schema.yaml`; unknown options stop as `fix_validation` instead of implicitly creating new Notion options. Computed, read-only, or unresolved types such as `people`, `formula`, `rollup`, timestamps, users, `unique_id`, and `verification` are blocked with structured validation errors until their ownership and resolution policies are designed. +The current validator supports the same writable property set as apply: `title`, `rich_text`, `number`, `select`, `status`, `multi_select`, `checkbox`, `date`, `url`, `email`, `phone_number`, external `files`, explicit `people` user IDs, and `relation` page IDs. Select-like values must use option names already present in `_schema.yaml`; unknown options stop as `fix_validation` instead of implicitly creating new Notion options. Computed, read-only, or unresolved types such as `formula`, `rollup`, timestamps, users, `unique_id`, and `verification` are blocked with structured validation errors until their ownership and resolution policies are designed. Multi-data-source databases still stop before row writes because AFS does not yet have a path-level way to choose the target data source. Pull the database again if `_schema.yaml` is missing or stale. diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index d6b9d64..71abbb5 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -149,3 +149,21 @@ and what Markdown shape agents should expect. live mounted database cycle reads, creates, and verifies relation properties, and the live direct integrity test creates then updates a relation property through the API. + +### People Properties + +- **Notion input:** Database/page `people` properties containing user objects. + The live PAT test uses the token's bot user ID from `/v1/users/me`. +- **Markdown output:** People properties render as frontmatter lists. Entries + with both display name and ID use `Name `; ID-only entries render as + the ID string. +- **Write behavior:** Frontmatter edits can write a string or YAML list of + explicit Notion user IDs. `Name ` is accepted so the rendered shape + can round-trip. Clearing with `null`, an empty string, or an empty list is + supported by the same writer shape. Name/email lookup remains deferred. +- **Verification:** Fixture apply tests assert exact page update and row create + payloads. Schema tests validate accepted/rejected people frontmatter. The + live mounted database cycle starts with an empty people property, writes the + bot user through a mounted Markdown edit, and verifies the rendered Notion + result. The live direct integrity test creates a people value and then clears + it through the API writer. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 8fc8b00..eec2efc 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -18,7 +18,7 @@ Sources used for the baseline: | Block | Recursive read/render; write subset | fixture, live | Unsupported/lossy blocks render as anchored directives and are protected by directive validation. | | Database | Read/enumerate as directory | fixture, live | Database containers project to directories. | | Data source | Read/query rows, render `_schema.yaml`, validate row property writes, create rows when database has exactly one data source | fixture, live, mounted live | Multi-data-source row writes are intentionally blocked until path/schema selection exists. | -| User | Read only when embedded in mentions/properties | fixture | User objects are not mounted as standalone files in v1. | +| User | Read when embedded in mentions/properties; writable by explicit ID in people properties | fixture, live property write | User objects are not mounted as standalone files in v1. | | Comment | Unsupported | none | Comments are not in the v1 filesystem model from `plan.md`; adding them needs a thread representation and write policy. | | File upload | Unsupported for upload; external/download URLs are read and external file properties are writable | fixture, live image download, live property write | Uploading local files still needs retention, size, dedupe, and local path ownership policy. | | View | Unsupported | none | Views are database presentation state, not row/page content. | @@ -101,7 +101,7 @@ Sources used for the baseline: | `email` | Yes | Yes | fixture, live, mounted live, schema | Nullable email string. | | `phone_number` | Yes | Yes | fixture, live, mounted live, schema | Nullable string. | | `files` | Yes | Yes for external URLs | fixture, live read/write, schema | Frontmatter accepts `https://...` or `Name ` entries and writes Notion external file objects. Hosted/uploaded file ownership remains read-only. | -| `people` | Yes | No | fixture, live read-empty, schema-blocked | Needs user lookup and permission-aware validation before writes. | +| `people` | Yes | Yes for explicit user IDs | fixture, live read/write, schema | Frontmatter accepts a Notion user ID string, `Name `, or a list. User lookup by name/email is deferred. | | `relation` | Yes | Yes for explicit page IDs | fixture, live read/write, schema | Frontmatter accepts a Notion page ID string or list of page IDs. Path/title resolution is deferred. | | `formula` | Yes | No | fixture, schema-blocked | Computed/read-only by Notion. | | `rollup` | Yes | No | fixture, schema-blocked | Computed/read-only by Notion. | @@ -123,9 +123,9 @@ Sources used for the baseline: tabs) stay as directives because Markdown cannot represent their semantics. - Comments are not mounted because they need a separate thread model and push policy. -- People writes are blocked until validation can resolve user IDs. Relation - writes currently require explicit related page IDs; path/title resolution is - deferred. +- People writes currently require explicit user IDs; name/email resolution is + deferred. Relation writes currently require explicit related page IDs; + path/title resolution is deferred. ## Next Block Work From 0551db88a973b0f0ce44b5a3ee872304ecbab757 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 07:18:42 -0500 Subject: [PATCH 12/18] Preserve database mention links --- crates/afs-cli/tests/e2e_push_workflow.rs | 28 ++++++++++- crates/afs-notion/src/apply.rs | 61 ++++++++++++++++++++++- crates/afs-notion/tests/apply.rs | 36 +++++++++++-- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 2 +- docs/notion-cyclic-support-journal.md | 17 +++++++ docs/notion-object-support.md | 2 +- 7 files changed, 139 insertions(+), 9 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index e0c2801..31af082 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -222,10 +222,14 @@ fn live_cyclic_diverse_page_read_noop_preserves_notion() { &format!("AFS cyclic link target {}", unique_suffix()), vec![paragraph_child("Target page for live link checks.")], ); + let linked_database = cleanup.create_database( + &env.parent_page_id, + &format!("AFS cyclic linked database {}", unique_suffix()), + ); let source = cleanup.create_page( &env.parent_page_id, &format!("AFS cyclic diverse read {}", unique_suffix()), - diverse_page_children(&target.id), + diverse_page_children(&target.id, &linked_database.id), ); cleanup.create_page( &source.id, @@ -254,7 +258,9 @@ fn live_cyclic_diverse_page_read_noop_preserves_notion() { "$$\na^2+b^2=c^2\n$$", "| Left | Right |", "[Linked page](https://www.notion.so/", + "[Linked database](https://www.notion.so/", "target mention [AFS cyclic link target", + "database mention [AFS cyclic linked database", "[Cyclic bookmark](https://example.com/cyclic-bookmark)", "[Cyclic embed](https://example.com/cyclic-embed)", "![Cyclic image](https://www.w3.org/Icons/w3c_home.png)", @@ -1063,7 +1069,7 @@ fn render_live_markdown(connector: &NotionConnector, page_id: &str, page_path: & render_canonical_markdown(&document) } -fn diverse_page_children(target_page_id: &str) -> Vec { +fn diverse_page_children(target_page_id: &str, database_id: &str) -> Vec { vec![ json!({ "object": "block", @@ -1074,6 +1080,8 @@ fn diverse_page_children(target_page_id: &str) -> Vec { annotated_text("bold", "bold"), text_part(" and a target mention "), page_mention_part("Target page", target_page_id), + text_part(" and database mention "), + database_mention_part("Linked database", database_id), text_part(" plus inline math "), equation_part("a^2+b^2=c^2") ] @@ -1155,6 +1163,11 @@ fn diverse_page_children(target_page_id: &str) -> Vec { "type": "link_to_page", "link_to_page": { "type": "page_id", "page_id": target_page_id } }), + json!({ + "object": "block", + "type": "link_to_page", + "link_to_page": { "type": "database_id", "database_id": database_id } + }), media_child( "image", "https://www.w3.org/Icons/w3c_home.png", @@ -1336,6 +1349,17 @@ fn page_mention_part(label: &str, page_id: &str) -> Value { }) } +fn database_mention_part(label: &str, database_id: &str) -> Value { + json!({ + "type": "mention", + "mention": { + "type": "database", + "database": { "id": database_id } + }, + "plain_text": label + }) +} + fn database_row_properties( notes: &str, points: &str, diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 138d472..c08ea0b 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -1327,6 +1327,10 @@ enum RichTextWritePart { id: String, annotations: InlineAnnotations, }, + DatabaseMention { + id: String, + annotations: InlineAnnotations, + }, Preimage(Box), } @@ -1335,7 +1339,8 @@ impl RichTextWritePart { match self { Self::Text { annotations, .. } | Self::Equation { annotations, .. } - | Self::PageMention { annotations, .. } => apply(annotations), + | Self::PageMention { annotations, .. } + | Self::DatabaseMention { annotations, .. } => apply(annotations), Self::Preimage(part) => { let mut annotations = InlineAnnotations::from(&part.annotations); apply(&mut annotations); @@ -1407,6 +1412,19 @@ impl RichTextWritePart { insert_annotations(&mut value, annotations); Ok(value) } + Self::DatabaseMention { id, annotations } => { + let mut value = json!({ + "type": "mention", + "mention": { + "type": "database", + "database": { + "id": id, + }, + }, + }); + insert_annotations(&mut value, annotations); + Ok(value) + } Self::Preimage(part) => preimage_part_to_request_value(part), } } @@ -1590,6 +1608,15 @@ impl InlineParser<'_> { && let Some((label, href, consumed)) = parse_markdown_link(rest) { if let Some(id) = notion_page_id_from_href(href) { + if self.preimage_has_mention("database", &id) { + return Ok(Some(( + vec![RichTextWritePart::DatabaseMention { + id, + annotations: InlineAnnotations::default(), + }], + consumed, + ))); + } return Ok(Some(( vec![RichTextWritePart::PageMention { id, @@ -1619,6 +1646,26 @@ impl InlineParser<'_> { Ok(None) } + fn preimage_has_mention(&self, kind: &str, id: &str) -> bool { + self.preimage_tokens.iter().any(|token| { + let Some(mention) = token.part.mention.as_ref() else { + return false; + }; + if mention.kind != kind { + return false; + } + let preimage_id = match kind { + "page" => mention.page.as_ref().map(|page| page.id.as_str()), + "database" => mention + .database + .as_ref() + .map(|database| database.id.as_str()), + _ => None, + }; + preimage_id.is_some_and(|preimage_id| notion_ids_equal(preimage_id, id)) + }) + } + fn next_special_or_preimage(&self, start: usize, closing: Option<&str>) -> usize { let mut next = self.input.len(); for marker in ["**", "~~", "", "`", "$", "[", "_"] { @@ -1746,6 +1793,18 @@ fn notion_id_from_url_segment(segment: &str) -> Option { None } +fn notion_ids_equal(left: &str, right: &str) -> bool { + let left = left + .chars() + .filter(|character| character.is_ascii_hexdigit()) + .collect::(); + let right = right + .chars() + .filter(|character| character.is_ascii_hexdigit()) + .collect::(); + !left.is_empty() && left.eq_ignore_ascii_case(&right) +} + fn unescape_markdown_text(value: &str) -> String { value.replace("\\\\", "\\") } diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index 2ebcc3b..f2e36a5 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -11,8 +11,8 @@ use afs_core::{AfsError, AfsResult}; use afs_notion::client::NotionApi; use afs_notion::dto::{ BlockDto, BlockListDto, DataSourceDto, DataSourcePropertyDto, DataSourceSummaryDto, - DatabaseDto, DateMentionDto, EquationBlockDto, ExternalFileDto, FileBlockDto, LinkDto, - MentionRichTextDto, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, + DatabaseDto, DateMentionDto, EquationBlockDto, ExternalFileDto, FileBlockDto, IdRefDto, + LinkDto, MentionRichTextDto, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, TableBlockDto, TableRowBlockDto, TextRichTextDto, UrlBlockDto, }; @@ -631,6 +631,8 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { date_mention("2026-06-10", "2026-06-10"), rich_text_part(" plus "), linked_text("Docs", "https://example.com/"), + rich_text_part(" and database "), + database_mention("Tasks", "33333333-3333-3333-3333-333333333333"), rich_text_part("."), ], )); @@ -639,7 +641,7 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { vec![RemoteId::new("page-1")], vec![PushOperation::UpdateBlock { block_id: RemoteId::new("paragraph-1"), - content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), + content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and database [Tasks updated](https://www.notion.so/33333333333333333333333333333333) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), }], ); let push_id = PushId("push-1".to_string()); @@ -709,6 +711,21 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { }, }, }, + { + "type": "text", + "text": { + "content": " and database ", + }, + }, + { + "type": "mention", + "mention": { + "type": "database", + "database": { + "id": "33333333333333333333333333333333", + }, + }, + }, { "type": "text", "text": { @@ -1938,6 +1955,19 @@ fn date_mention(text: &str, start: &str) -> RichTextDto { } } +fn database_mention(text: &str, id: &str) -> RichTextDto { + RichTextDto { + kind: "mention".to_string(), + mention: Some(MentionRichTextDto { + kind: "database".to_string(), + database: Some(IdRefDto { id: id.to_string() }), + ..Default::default() + }), + plain_text: text.to_string(), + ..Default::default() + } +} + fn rich_text_json(text: &str) -> Value { json!([ { diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 0b6c0b9..087602b 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded same-shape Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing tables with unchanged width/header mode/row count, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded same-shape Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing tables with unchanged width/header mode/row count, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index b1172d9..554c8a0 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -112,7 +112,7 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, same-shape existing tables, existing bookmark/embed URL blocks, and existing URL-backed media blocks; -- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, legacy `afs://` page links, and unchanged preimage mentions such as dates; +- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, legacy `afs://` page links, and unchanged preimage mentions such as dates; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, explicit people user IDs, and explicit relation page IDs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; - unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 71abbb5..4e6f616 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -167,3 +167,20 @@ and what Markdown shape agents should expect. bot user through a mounted Markdown edit, and verifies the rendered Notion result. The live direct integrity test creates a people value and then clears it through the API writer. + +### Database Mentions As Markdown Links + +- **Notion input:** Rich-text database mentions and `link_to_page` blocks that + target databases. +- **Markdown output:** Both render as normal Markdown links to Notion URLs, + matching page mention/link behavior. +- **Write behavior:** When a rendered database mention link is edited only in + label text and the Notion target ID is unchanged, the rich-text parser writes + it back as a typed Notion database mention instead of accidentally converting + it to a page mention. Creating arbitrary new database mentions from a plain + Notion URL still needs an explicit typed-link syntax because Notion page and + database URLs are not distinguishable from the ID alone. +- **Verification:** Fixture apply tests assert edited database mention links + produce `mention.database` payloads. The live diverse page cyclic test creates + both a rich-text database mention and a database `link_to_page` block, then + verifies the mounted read/no-op push does not mutate the Notion block JSON. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index eec2efc..b02d89a 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -79,7 +79,7 @@ Sources used for the baseline: | Equation span | Inline math | Yes | fixture, live | `$...$`. | | Bold, italic, strikethrough, underline, code | Markdown/HTML inline formatting | Yes for emitted shapes | fixture, live | Underline uses ``. | | Page mention | Markdown link to Notion URL | Read; write via Notion-hosted URL or legacy `afs://` parsing path | fixture, live | Stable ID is preserved; external UUID-shaped links remain ordinary links. | -| Database mention | Markdown link to Notion URL | Read only in current live suite | fixture | Stable ID is preserved. | +| Database mention | Markdown link to Notion URL | Read; label edits preserve database type when target ID is unchanged | fixture, live read | Stable ID is preserved. Arbitrary new database-link creation needs an explicit typed link form. | | User mention | Plain `@name`/fallback | Read only | fixture | Needs identity lookup before safe writes. | | Date mention | Plain date/range text | Read only | fixture, live | Needs typed date mention parser before safe writes. | | Link preview mention | Markdown link | Read only | fixture | Preserves URL. | From 0311905f41a7351f6d97f29b59ab3e1188c0f504 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 08:46:17 -0500 Subject: [PATCH 13/18] Support explicit Notion date mentions --- crates/afs-cli/tests/e2e_push_workflow.rs | 20 ++++ crates/afs-notion/src/apply.rs | 113 +++++++++++++++++++++- crates/afs-notion/tests/apply.rs | 81 +++++++++++++++- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 2 +- docs/notion-cyclic-bug-journal.md | 14 +++ docs/notion-cyclic-support-journal.md | 23 ++++- docs/notion-object-support.md | 4 +- 8 files changed, 247 insertions(+), 12 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index 31af082..771358a 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -327,6 +327,7 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "Editable paragraph original.", "Editable paragraph changed.", ) + .replace("Editable date 2026-06-13", "Editable date @date(2026-06-14)") .replace("# Editable heading one", "# Editable heading one changed") .replace("## Editable heading two", "## Editable heading two changed") .replace( @@ -417,6 +418,7 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { let verified = render_live_page(&connector, &source.id, &page_path); for expected in [ "Editable paragraph changed.", + "Editable date 2026-06-14", "# Editable heading one changed", "## Editable heading two changed", "### Editable heading three changed", @@ -1199,6 +1201,13 @@ fn diverse_page_children(target_page_id: &str, database_id: &str) -> Vec fn supported_edit_children() -> Vec { vec![ paragraph_child("Editable paragraph original."), + json!({ + "object": "block", + "type": "paragraph", + "paragraph": { + "rich_text": [text_part("Editable date "), date_mention_part("2026-06-13")] + } + }), rich_text_child("heading_1", "Editable heading one"), rich_text_child("heading_2", "Editable heading two"), rich_text_child("heading_3", "Editable heading three"), @@ -1360,6 +1369,17 @@ fn database_mention_part(label: &str, database_id: &str) -> Value { }) } +fn date_mention_part(start: &str) -> Value { + json!({ + "type": "mention", + "mention": { + "type": "date", + "date": { "start": start } + }, + "plain_text": start + }) +} + fn database_row_properties( notes: &str, points: &str, diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index c08ea0b..16cec4c 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -1129,6 +1129,12 @@ fn parse_supported_block( return Err(AfsError::Unsupported("empty Notion block writes")); } + if current_kind == Some("link_to_page") { + return Err(AfsError::Unsupported( + "retargeting Notion link_to_page blocks; Notion ignores direct target updates and replacement needs undo-aware block identity support", + )); + } + if let Some((language, code)) = parse_code_fence(trimmed) { let language = if language.is_empty() { "plain text".to_string() @@ -1331,6 +1337,12 @@ enum RichTextWritePart { id: String, annotations: InlineAnnotations, }, + DateMention { + start: String, + end: Option, + time_zone: Option, + annotations: InlineAnnotations, + }, Preimage(Box), } @@ -1340,7 +1352,8 @@ impl RichTextWritePart { Self::Text { annotations, .. } | Self::Equation { annotations, .. } | Self::PageMention { annotations, .. } - | Self::DatabaseMention { annotations, .. } => apply(annotations), + | Self::DatabaseMention { annotations, .. } + | Self::DateMention { annotations, .. } => apply(annotations), Self::Preimage(part) => { let mut annotations = InlineAnnotations::from(&part.annotations); apply(&mut annotations); @@ -1425,6 +1438,30 @@ impl RichTextWritePart { insert_annotations(&mut value, annotations); Ok(value) } + Self::DateMention { + start, + end, + time_zone, + annotations, + } => { + let mut value = json!({ + "type": "mention", + "mention": { + "type": "date", + "date": { + "start": start, + }, + }, + }); + if let Some(end) = end { + value["mention"]["date"]["end"] = json!(end); + } + if let Some(time_zone) = time_zone { + value["mention"]["date"]["time_zone"] = json!(time_zone); + } + insert_annotations(&mut value, annotations); + Ok(value) + } Self::Preimage(part) => preimage_part_to_request_value(part), } } @@ -1604,6 +1641,21 @@ impl InlineParser<'_> { ))); } + if rest.starts_with("@date(") + && let Some(end) = find_closing(rest, 6, ")") + { + let (start, end_date, time_zone) = parse_date_mention_args(&rest[6..end])?; + return Ok(Some(( + vec![RichTextWritePart::DateMention { + start, + end: end_date, + time_zone, + annotations: InlineAnnotations::default(), + }], + end + 1, + ))); + } + if rest.starts_with('[') && let Some((label, href, consumed)) = parse_markdown_link(rest) { @@ -1668,7 +1720,7 @@ impl InlineParser<'_> { fn next_special_or_preimage(&self, start: usize, closing: Option<&str>) -> usize { let mut next = self.input.len(); - for marker in ["**", "~~", "", "`", "$", "[", "_"] { + for marker in ["**", "~~", "", "`", "$", "@date(", "[", "_"] { if let Some(offset) = self.input[start..].find(marker) { next = next.min(start + offset); } @@ -1706,6 +1758,63 @@ fn find_closing(input: &str, start: usize, marker: &str) -> Option { input[start..].find(marker).map(|offset| start + offset) } +fn parse_date_mention_args(input: &str) -> AfsResult<(String, Option, Option)> { + let input = input.trim(); + if input.is_empty() { + return Err(invalid_date_mention_syntax()); + } + + let (range, time_zone) = if let Some((range, time_zone)) = input + .rsplit_once(", tz=") + .or_else(|| input.rsplit_once(", timezone=")) + { + let time_zone = time_zone.trim(); + if time_zone.is_empty() { + return Err(invalid_date_mention_syntax()); + } + (range.trim(), Some(time_zone.to_string())) + } else { + (input, None) + }; + + let (start, end) = if let Some((start, end)) = range.split_once(" to ") { + let start = start.trim(); + let end = end.trim(); + if start.is_empty() || end.is_empty() { + return Err(invalid_date_mention_syntax()); + } + (start.to_string(), Some(end.to_string())) + } else { + (range.trim().to_string(), None) + }; + + if !looks_like_date_literal(&start) + || end + .as_deref() + .is_some_and(|end| !looks_like_date_literal(end)) + { + return Err(invalid_date_mention_syntax()); + } + + Ok((start, end, time_zone)) +} + +fn looks_like_date_literal(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 10 + && bytes[0..4].iter().all(|byte| byte.is_ascii_digit()) + && bytes[4] == b'-' + && bytes[5..7].iter().all(|byte| byte.is_ascii_digit()) + && bytes[7] == b'-' + && bytes[8..10].iter().all(|byte| byte.is_ascii_digit()) +} + +fn invalid_date_mention_syntax() -> AfsError { + AfsError::Unsupported( + "date mention syntax; use @date(YYYY-MM-DD) or @date(YYYY-MM-DD to YYYY-MM-DD)", + ) +} + fn parse_markdown_link(input: &str) -> Option<(&str, &str, usize)> { if !input.starts_with('[') { return None; diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index f2e36a5..0d68f95 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -12,9 +12,9 @@ use afs_notion::client::NotionApi; use afs_notion::dto::{ BlockDto, BlockListDto, DataSourceDto, DataSourcePropertyDto, DataSourceSummaryDto, DatabaseDto, DateMentionDto, EquationBlockDto, ExternalFileDto, FileBlockDto, IdRefDto, - LinkDto, MentionRichTextDto, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, - RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, TableBlockDto, - TableRowBlockDto, TextRichTextDto, UrlBlockDto, + LinkDto, LinkToPageBlockDto, MentionRichTextDto, PageDto, PageListDto, PagePropertyDto, + PaginatedListDto, RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, + TableBlockDto, TableRowBlockDto, TextRichTextDto, UrlBlockDto, }; use afs_notion::{NotionConfig, NotionConnector}; use serde_json::{Value, json}; @@ -641,7 +641,7 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { vec![RemoteId::new("page-1")], vec![PushOperation::UpdateBlock { block_id: RemoteId::new("paragraph-1"), - content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and database [Tasks updated](https://www.notion.so/33333333333333333333333333333333) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), + content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and database [Tasks updated](https://www.notion.so/33333333333333333333333333333333) and @date(2026-06-11 to 2026-06-12, tz=America/Chicago) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), }], ); let push_id = PushId("push-1".to_string()); @@ -732,6 +732,23 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { "content": " and ", }, }, + { + "type": "mention", + "mention": { + "type": "date", + "date": { + "start": "2026-06-11", + "end": "2026-06-12", + "time_zone": "America/Chicago", + }, + }, + }, + { + "type": "text", + "text": { + "content": " and ", + }, + }, { "type": "equation", "equation": { @@ -775,6 +792,47 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { ); } +#[test] +fn apply_rejects_link_to_page_retargeting_before_api_mutation() { + let api = Arc::new(RecordingNotionApi::with_blocks( + "2026-06-10T00:00:00.000Z", + vec![link_to_page_block( + "page-link-1", + "page_id", + "11111111-1111-1111-1111-111111111111", + )], + )); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("page-1")], + vec![PushOperation::UpdateBlock { + block_id: RemoteId::new("page-link-1"), + content: + "[Updated page](https://www.notion.so/Project-22222222222222222222222222222222)" + .to_string(), + }], + ); + let push_id = PushId("push-1".to_string()); + let operation_ids = operation_ids(&push_id, &plan); + let mount_id = MountId::new("notion-main"); + + let error = connector + .apply(ApplyPlanRequest { + push_id: &push_id, + mount_id: &mount_id, + plan: &plan, + operation_ids: &operation_ids, + remote_preconditions: &[], + }) + .expect_err("link_to_page retargeting is intentionally unsupported"); + + assert!( + matches!(error, AfsError::Unsupported(message) if message.contains("link_to_page")), + "{error:?}" + ); + assert!(api.writes.lock().expect("writes").is_empty()); +} + #[test] fn apply_updates_bookmark_and_embed_blocks_from_markdown_links() { let api = Arc::new(RecordingNotionApi::with_blocks( @@ -1903,6 +1961,21 @@ fn table_row_block(id: &str, cells: &[&str]) -> BlockDto { block } +fn link_to_page_block(id: &str, kind: &str, target_id: &str) -> BlockDto { + let mut block = block(id, "link_to_page"); + let mut payload = LinkToPageBlockDto { + kind: kind.to_string(), + ..Default::default() + }; + match kind { + "page_id" => payload.page_id = Some(target_id.to_string()), + "database_id" => payload.database_id = Some(target_id.to_string()), + _ => {} + } + block.link_to_page = Some(payload); + block +} + fn rich_text(text: &str) -> Vec { vec![rich_text_part(text)] } diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 087602b..3d14cf7 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded same-shape Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing tables with unchanged width/header mode/row count, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, and legacy `afs://` page links. Unchanged preimage mentions, such as date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded same-shape Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing tables with unchanged width/header mode/row count, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, explicit date mentions written as `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)`, and legacy `afs://` page links. Unchanged preimage mentions, such as existing date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 554c8a0..c34aee0 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -112,7 +112,7 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, same-shape existing tables, existing bookmark/embed URL blocks, and existing URL-backed media blocks; -- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, legacy `afs://` page links, and unchanged preimage mentions such as dates; +- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, explicit `@date(...)` date mentions, legacy `afs://` page links, and unchanged preimage mentions such as dates; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, explicit people user IDs, and explicit relation page IDs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; - unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; diff --git a/docs/notion-cyclic-bug-journal.md b/docs/notion-cyclic-bug-journal.md index dd195a1..4140260 100644 --- a/docs/notion-cyclic-bug-journal.md +++ b/docs/notion-cyclic-bug-journal.md @@ -19,6 +19,20 @@ the local symptom, and the fix made in the PR. and `[Linked database](https://www.notion.so/...)`. The live cyclic read test asserts no `type=link_to_page` directive appears for valid links. +### `link_to_page` Target PATCH Was A Silent No-Op + +- **Found by:** live scratch API probe while evaluating editable page/database + link targets. +- **Symptom:** `PATCH /v1/blocks/{block_id}` with a new `link_to_page.page_id` + returned HTTP success but the response and subsequent child-list fetch still + showed the original target ID. +- **Decision:** AFS now keeps direct `link_to_page` retargeting blocked with a + specific unsupported-write message. Replacing the block by append/delete is + deferred until the journal can represent undo-aware block replacement, because + the old block ID disappears. +- **Verification:** Added a fixture apply test that attempts to retarget a + rendered `link_to_page` Markdown link and asserts no Notion API write is made. + ### Full Same-Shape Page Edits Planned Archive/Recreate - **Found by:** `live_cyclic_supported_block_edits_push_and_verify_notion`. diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 4e6f616..df5a093 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -13,8 +13,11 @@ and what Markdown shape agents should expect. - `[Linked page](https://www.notion.so/)` - `[Linked database](https://www.notion.so/)` - **Write behavior:** Unchanged link blocks are preserved during pushes. Direct - retargeting of a `link_to_page` block is not yet supported; malformed native - link payloads still render as guarded AFS directives. + retargeting of a `link_to_page` block is blocked before mutation: live API + probing showed Notion accepts a target update request but returns the original + target unchanged. Replacement-by-append/delete is deferred until AFS journals + undo-aware block identity replacement. Malformed native link payloads still + render as guarded AFS directives. - **Inline mentions:** Page and database rich-text mentions now render as normal Notion URL links instead of `afs://` links. The writer accepts page URLs on Notion hosts as page mention writes and keeps legacy `afs://` parsing for @@ -184,3 +187,19 @@ and what Markdown shape agents should expect. produce `mention.database` payloads. The live diverse page cyclic test creates both a rich-text database mention and a database `link_to_page` block, then verifies the mounted read/no-op push does not mutate the Notion block JSON. + +### Explicit Date Mention Writes + +- **Notion input:** Rich-text date mentions. +- **Markdown output:** Date mentions continue to render as readable date or + range text, for example `2026-06-13`. +- **Write behavior:** Unchanged rendered date mentions preserve their typed + Notion mention payload through the preimage. When an agent needs to change or + create a typed date mention, it can use `@date(2026-06-14)` or + `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)`. Plain date-looking + text is not auto-promoted to a date mention because normal prose can contain + dates. +- **Verification:** Fixture apply tests assert `@date(...)` produces a typed + `mention.date` request payload. The live supported-edit cycle edits a Notion + date mention through the mounted Markdown file and verifies the fresh Notion + render shows the updated date. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index b02d89a..9367fb8 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -56,7 +56,7 @@ Sources used for the baseline: | `pdf` | Markdown link | Yes for existing URL blocks | fixture, live read/write | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. Local download intentionally skipped for now. | | `audio` | Markdown link | Yes for existing URL blocks | fixture, live read/write | Uses `external.url` or Notion-hosted `file.url`; Markdown edits write external URLs. Local download intentionally skipped for now. | | `synced_block` | Directive wrapper; source block ID preserved when present | No | fixture | Rewriting synced blocks is lossy without source/copy semantics; live creation of an original synced block was rejected because Notion requires `synced_from`. | -| `link_to_page` | Markdown link to Notion URL | Read/delete/move only | fixture, live read | Page/database target ID is preserved in the link target; direct retargeting is not a supported edit yet. | +| `link_to_page` | Markdown link to Notion URL | Read/delete/move only | fixture, live read, blocked-write regression | Page/database target ID is preserved in the link target; direct retargeting is blocked because Notion ignores direct target PATCHes and replacement needs undo-aware block identity support. | | `table_of_contents` | Directive | No | fixture, live read | Generated navigation block; no useful Markdown edit surface. | | `breadcrumb` | Directive | No | fixture, live read | Generated navigation block; no useful Markdown edit surface. | | `column_list` | Directive wrapper; children render below it | No | fixture, live read | Layout is anchored; child content remains readable. | @@ -81,7 +81,7 @@ Sources used for the baseline: | Page mention | Markdown link to Notion URL | Read; write via Notion-hosted URL or legacy `afs://` parsing path | fixture, live | Stable ID is preserved; external UUID-shaped links remain ordinary links. | | Database mention | Markdown link to Notion URL | Read; label edits preserve database type when target ID is unchanged | fixture, live read | Stable ID is preserved. Arbitrary new database-link creation needs an explicit typed link form. | | User mention | Plain `@name`/fallback | Read only | fixture | Needs identity lookup before safe writes. | -| Date mention | Plain date/range text | Read only | fixture, live | Needs typed date mention parser before safe writes. | +| Date mention | Plain date/range text; explicit `@date(...)` write syntax | Yes through explicit syntax | fixture, live read/write | Agents can write `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)` when the result must remain a typed Notion date mention. Plain dates stay plain text unless preserved from the preimage. | | Link preview mention | Markdown link | Read only | fixture | Preserves URL. | | Unknown mention variants | Plain text fallback | No | fixture | Avoids losing visible content while blocking typed edits. | From 186492eaa5ef29761c8233c4ebf12ff56b8012cc Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 08:57:01 -0500 Subject: [PATCH 14/18] Support explicit Notion user mentions --- crates/afs-cli/tests/e2e_push_workflow.rs | 52 ++++++++++++++++++++++- crates/afs-notion/src/apply.rs | 46 +++++++++++++++++++- crates/afs-notion/tests/apply.rs | 17 +++++++- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 2 +- docs/notion-cyclic-support-journal.md | 14 ++++++ docs/notion-object-support.md | 2 +- 7 files changed, 127 insertions(+), 8 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index 771358a..ce2ff86 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -314,10 +314,11 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { let env = LiveEnv::from_env(); let api = HttpNotionApi::new(NotionConfig::default()); let mut cleanup = LiveCleanup::new(api); + let user_id = cleanup.current_user_id(); let source = cleanup.create_page( &env.parent_page_id, &format!("AFS cyclic supported edits {}", unique_suffix()), - supported_edit_children(), + supported_edit_children(&user_id), ); let connector = NotionConnector::new(NotionConfig::default()); @@ -380,6 +381,11 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { ) .replace("fn editable() {}", "fn editable_changed() {}") .replace("x+y=z", "x-y=z"); + let edited = replace_line_with_prefix( + edited, + "Editable user ", + &format!("Editable user @user({user_id})"), + ); fs::write(&page_path, edited).expect("write cyclic edits"); let dirty_status = run_status( @@ -419,6 +425,7 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { for expected in [ "Editable paragraph changed.", "Editable date 2026-06-14", + "Editable user ", "# Editable heading one changed", "## Editable heading two changed", "### Editable heading three changed", @@ -1198,7 +1205,7 @@ fn diverse_page_children(target_page_id: &str, database_id: &str) -> Vec ] } -fn supported_edit_children() -> Vec { +fn supported_edit_children(user_id: &str) -> Vec { vec![ paragraph_child("Editable paragraph original."), json!({ @@ -1208,6 +1215,13 @@ fn supported_edit_children() -> Vec { "rich_text": [text_part("Editable date "), date_mention_part("2026-06-13")] } }), + json!({ + "object": "block", + "type": "paragraph", + "paragraph": { + "rich_text": [text_part("Editable user "), user_mention_part(user_id)] + } + }), rich_text_child("heading_1", "Editable heading one"), rich_text_child("heading_2", "Editable heading two"), rich_text_child("heading_3", "Editable heading three"), @@ -1380,6 +1394,17 @@ fn date_mention_part(start: &str) -> Value { }) } +fn user_mention_part(user_id: &str) -> Value { + json!({ + "type": "mention", + "mention": { + "type": "user", + "user": { "id": user_id } + }, + "plain_text": "@user" + }) +} + fn database_row_properties( notes: &str, points: &str, @@ -1725,6 +1750,29 @@ fn normalize_notion_id(input: &str) -> String { } } +fn replace_line_with_prefix(markdown: String, prefix: &str, replacement: &str) -> String { + let mut replaced = false; + let lines = markdown + .lines() + .map(|line| { + if !replaced && line.starts_with(prefix) { + replaced = true; + replacement.to_string() + } else { + line.to_string() + } + }) + .collect::>(); + + assert!( + replaced, + "expected line starting with `{prefix}` in:\n{markdown}" + ); + + let trailing_newline = if markdown.ends_with('\n') { "\n" } else { "" }; + format!("{}{trailing_newline}", lines.join("\n")) +} + fn unique_suffix() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 16cec4c..0a7cc22 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -1343,6 +1343,10 @@ enum RichTextWritePart { time_zone: Option, annotations: InlineAnnotations, }, + UserMention { + id: String, + annotations: InlineAnnotations, + }, Preimage(Box), } @@ -1353,7 +1357,8 @@ impl RichTextWritePart { | Self::Equation { annotations, .. } | Self::PageMention { annotations, .. } | Self::DatabaseMention { annotations, .. } - | Self::DateMention { annotations, .. } => apply(annotations), + | Self::DateMention { annotations, .. } + | Self::UserMention { annotations, .. } => apply(annotations), Self::Preimage(part) => { let mut annotations = InlineAnnotations::from(&part.annotations); apply(&mut annotations); @@ -1462,6 +1467,19 @@ impl RichTextWritePart { insert_annotations(&mut value, annotations); Ok(value) } + Self::UserMention { id, annotations } => { + let mut value = json!({ + "type": "mention", + "mention": { + "type": "user", + "user": { + "id": id, + }, + }, + }); + insert_annotations(&mut value, annotations); + Ok(value) + } Self::Preimage(part) => preimage_part_to_request_value(part), } } @@ -1656,6 +1674,19 @@ impl InlineParser<'_> { ))); } + if rest.starts_with("@user(") + && let Some(end) = find_closing(rest, 6, ")") + { + let id = parse_user_mention_arg(&rest[6..end])?; + return Ok(Some(( + vec![RichTextWritePart::UserMention { + id, + annotations: InlineAnnotations::default(), + }], + end + 1, + ))); + } + if rest.starts_with('[') && let Some((label, href, consumed)) = parse_markdown_link(rest) { @@ -1720,7 +1751,7 @@ impl InlineParser<'_> { fn next_special_or_preimage(&self, start: usize, closing: Option<&str>) -> usize { let mut next = self.input.len(); - for marker in ["**", "~~", "", "`", "$", "@date(", "[", "_"] { + for marker in ["**", "~~", "", "`", "$", "@date(", "@user(", "[", "_"] { if let Some(offset) = self.input[start..].find(marker) { next = next.min(start + offset); } @@ -1815,6 +1846,17 @@ fn invalid_date_mention_syntax() -> AfsError { ) } +fn parse_user_mention_arg(input: &str) -> AfsResult { + let id = parse_named_id_entry(input).trim(); + if valid_notion_id(id) { + Ok(id.to_string()) + } else { + Err(AfsError::Unsupported( + "user mention syntax; use @user()", + )) + } +} + fn parse_markdown_link(input: &str) -> Option<(&str, &str, usize)> { if !input.starts_with('[') { return None; diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index 0d68f95..f8f1937 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -641,7 +641,7 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { vec![RemoteId::new("page-1")], vec![PushOperation::UpdateBlock { block_id: RemoteId::new("paragraph-1"), - content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and database [Tasks updated](https://www.notion.so/33333333333333333333333333333333) and @date(2026-06-11 to 2026-06-12, tz=America/Chicago) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), + content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and database [Tasks updated](https://www.notion.so/33333333333333333333333333333333) and @date(2026-06-11 to 2026-06-12, tz=America/Chicago) and @user(Ada <55555555-5555-5555-5555-555555555555>) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), }], ); let push_id = PushId("push-1".to_string()); @@ -749,6 +749,21 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { "content": " and ", }, }, + { + "type": "mention", + "mention": { + "type": "user", + "user": { + "id": "55555555-5555-5555-5555-555555555555", + }, + }, + }, + { + "type": "text", + "text": { + "content": " and ", + }, + }, { "type": "equation", "equation": { diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 3d14cf7..0d368a5 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded same-shape Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing tables with unchanged width/header mode/row count, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, explicit date mentions written as `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)`, and legacy `afs://` page links. Unchanged preimage mentions, such as existing date mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded same-shape Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing tables with unchanged width/header mode/row count, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, explicit date mentions written as `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)`, explicit user mentions written as `@user()`, and legacy `afs://` page links. Unchanged preimage mentions, such as existing date/user mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index c34aee0..4f8de5a 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -112,7 +112,7 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, same-shape existing tables, existing bookmark/embed URL blocks, and existing URL-backed media blocks; -- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, explicit `@date(...)` date mentions, legacy `afs://` page links, and unchanged preimage mentions such as dates; +- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, explicit `@date(...)` date mentions, explicit `@user(...)` user mentions, legacy `afs://` page links, and unchanged preimage mentions such as dates/users; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, explicit people user IDs, and explicit relation page IDs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; - unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index df5a093..8132c06 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -203,3 +203,17 @@ and what Markdown shape agents should expect. `mention.date` request payload. The live supported-edit cycle edits a Notion date mention through the mounted Markdown file and verifies the fresh Notion render shows the updated date. + +### Explicit User Mention Writes + +- **Notion input:** Rich-text user mentions. +- **Markdown output:** User mentions continue to render as readable + `@name`/fallback text. +- **Write behavior:** Unchanged rendered user mentions preserve their typed + Notion mention payload through the preimage. When an agent needs to create or + reassert a typed user mention, it can use `@user()` or + `@user(Name )`. Name/email lookup remains deferred. +- **Verification:** Fixture apply tests assert `@user(...)` produces a typed + `mention.user` request payload. The live supported-edit cycle rewrites a + real Notion user mention through mounted Markdown and verifies the fresh + Notion render still contains the user mention line. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 9367fb8..473bc9d 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -80,7 +80,7 @@ Sources used for the baseline: | Bold, italic, strikethrough, underline, code | Markdown/HTML inline formatting | Yes for emitted shapes | fixture, live | Underline uses ``. | | Page mention | Markdown link to Notion URL | Read; write via Notion-hosted URL or legacy `afs://` parsing path | fixture, live | Stable ID is preserved; external UUID-shaped links remain ordinary links. | | Database mention | Markdown link to Notion URL | Read; label edits preserve database type when target ID is unchanged | fixture, live read | Stable ID is preserved. Arbitrary new database-link creation needs an explicit typed link form. | -| User mention | Plain `@name`/fallback | Read only | fixture | Needs identity lookup before safe writes. | +| User mention | Plain `@name`/fallback; explicit `@user(...)` write syntax | Yes through explicit ID syntax | fixture, live read/write | Agents can write `@user(11111111-1111-1111-1111-111111111111)` or `@user(Name <11111111-1111-1111-1111-111111111111>)`; name/email lookup is deferred. | | Date mention | Plain date/range text; explicit `@date(...)` write syntax | Yes through explicit syntax | fixture, live read/write | Agents can write `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)` when the result must remain a typed Notion date mention. Plain dates stay plain text unless preserved from the preimage. | | Link preview mention | Markdown link | Read only | fixture | Preserves URL. | | Unknown mention variants | Plain text fallback | No | fixture | Avoids losing visible content while blocking typed edits. | From ceaf5555edb8c994155ea5ceb0ce857e9a070111 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 09:06:16 -0500 Subject: [PATCH 15/18] Support Notion table row edits --- crates/afs-cli/tests/e2e_push_workflow.rs | 3 +- crates/afs-notion/src/apply.rs | 44 ++++++- crates/afs-notion/tests/apply.rs | 150 ++++++++++++++++++++++ docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 4 +- docs/notion-cyclic-support-journal.md | 15 ++- docs/notion-object-support.md | 10 +- 7 files changed, 206 insertions(+), 22 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index ce2ff86..987ede1 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -349,7 +349,7 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { ) .replace( "| Editable table item | Editable table state |", - "| Editable table item changed | Editable table state done |", + "| Editable table item changed | Editable table state done |\n| Editable table added | Editable table added state |", ) .replace( "[Editable bookmark](https://example.com/editable-bookmark)", @@ -436,6 +436,7 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "> Editable quote changed", "> [!NOTE]\n> Editable callout changed", "| Editable table item changed | Editable table state done |", + "| Editable table added | Editable table added state |", "[Editable bookmark changed](https://example.com/editable-bookmark-changed)", "[Editable embed changed](https://example.com/editable-embed-changed)", "![Editable image changed](https://www.w3.org/assets/logos/w3c-2025-transitional/w3c-72x48.png)", diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 0a7cc22..0f3600f 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -367,13 +367,12 @@ fn apply_table_update( let current_rows = current_table_rows(bundles, table_id)?; let parsed = parse_markdown_table(markdown, table)?; - if parsed.rows.len() != current_rows.len() { - return Err(AfsError::Unsupported( - "writing Notion table row additions or deletions", - )); - } - - for (row_block, cells) in current_rows.iter().zip(parsed.rows) { + let rows_to_update = current_rows.len().min(parsed.rows.len()); + for (row_block, cells) in current_rows + .iter() + .zip(parsed.rows.iter()) + .take(rows_to_update) + { let current_row = row_block.table_row.as_ref().ok_or_else(|| { AfsError::InvalidState(format!( "notion table row block `{}` is missing its `table_row` payload", @@ -404,6 +403,27 @@ fn apply_table_update( )?; } + let mut append_after = current_rows.last().map(|row| RemoteId::new(row.id.clone())); + for cells in parsed.rows.iter().skip(current_rows.len()) { + let cells = cells + .iter() + .map(|cell| rich_text_payload(cell, None)) + .collect::>>()?; + let result = api.append_block_children( + table_id.as_str(), + append_body(table_row_append_child(cells), append_after.as_ref()), + )?; + append_after = result + .results + .first() + .map(|row| RemoteId::new(row.id.clone())) + .or(append_after); + } + + for row_block in current_rows.iter().skip(parsed.rows.len()) { + api.delete_block(&row_block.id)?; + } + Ok(()) } @@ -1288,6 +1308,16 @@ fn append_body(child: Value, after: Option<&RemoteId>) -> Value { } } +fn table_row_append_child(cells: Vec) -> Value { + json!({ + "object": "block", + "type": "table_row", + "table_row": { + "cells": cells, + }, + }) +} + fn rich_text(content: &str) -> Value { json!([ { diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index f8f1937..81e2ad5 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -1123,6 +1123,156 @@ fn apply_updates_simple_table_rows_from_markdown_table() { ); } +#[test] +fn apply_adds_table_rows_from_markdown_table() { + let api = Arc::new(RecordingNotionApi::with_table( + "2026-06-10T00:00:00.000Z", + table_block("table-1", 2, true), + vec![ + table_row_block("row-1", &["Name", "Status"]), + table_row_block("row-2", &["Old task", "Todo"]), + ], + )); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("page-1")], + vec![PushOperation::UpdateBlock { + block_id: RemoteId::new("table-1"), + content: "| Name | Status |\n| --- | --- |\n| New task | Done |\n| Added task | Next |" + .to_string(), + }], + ); + let push_id = PushId("push-1".to_string()); + let operation_ids = operation_ids(&push_id, &plan); + let mount_id = MountId::new("notion-main"); + + connector + .apply(ApplyPlanRequest { + push_id: &push_id, + mount_id: &mount_id, + plan: &plan, + operation_ids: &operation_ids, + remote_preconditions: &[], + }) + .expect("apply table row addition"); + + let writes = api.writes.lock().expect("writes"); + assert_eq!( + writes.as_slice(), + [ + WriteCall::Update { + block_id: "row-1".to_string(), + body: json!({ + "table_row": { + "cells": [ + rich_text_json("Name"), + rich_text_json("Status"), + ], + }, + }), + }, + WriteCall::Update { + block_id: "row-2".to_string(), + body: json!({ + "table_row": { + "cells": [ + rich_text_json("New task"), + rich_text_json("Done"), + ], + }, + }), + }, + WriteCall::Append { + block_id: "table-1".to_string(), + body: json!({ + "children": [{ + "object": "block", + "type": "table_row", + "table_row": { + "cells": [ + rich_text_json("Added task"), + rich_text_json("Next"), + ], + }, + }], + "position": { + "type": "after_block", + "after_block": { + "id": "row-2", + }, + }, + }), + }, + ] + ); +} + +#[test] +fn apply_deletes_table_rows_from_markdown_table() { + let api = Arc::new(RecordingNotionApi::with_table( + "2026-06-10T00:00:00.000Z", + table_block("table-1", 2, true), + vec![ + table_row_block("row-1", &["Name", "Status"]), + table_row_block("row-2", &["Keep task", "Todo"]), + table_row_block("row-3", &["Drop task", "Later"]), + ], + )); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("page-1")], + vec![PushOperation::UpdateBlock { + block_id: RemoteId::new("table-1"), + content: "| Name | Status |\n| --- | --- |\n| Kept task | Done |".to_string(), + }], + ); + let push_id = PushId("push-1".to_string()); + let operation_ids = operation_ids(&push_id, &plan); + let mount_id = MountId::new("notion-main"); + + connector + .apply(ApplyPlanRequest { + push_id: &push_id, + mount_id: &mount_id, + plan: &plan, + operation_ids: &operation_ids, + remote_preconditions: &[], + }) + .expect("apply table row deletion"); + + let writes = api.writes.lock().expect("writes"); + assert_eq!( + writes.as_slice(), + [ + WriteCall::Update { + block_id: "row-1".to_string(), + body: json!({ + "table_row": { + "cells": [ + rich_text_json("Name"), + rich_text_json("Status"), + ], + }, + }), + }, + WriteCall::Update { + block_id: "row-2".to_string(), + body: json!({ + "table_row": { + "cells": [ + rich_text_json("Kept task"), + rich_text_json("Done"), + ], + }, + }), + }, + WriteCall::Delete { + block_id: "row-3".to_string(), + }, + ] + ); +} + #[test] fn apply_updates_supported_page_properties() { let api = Arc::new(RecordingNotionApi::with_page_properties( diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 0d368a5..2d72021 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded same-shape Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing tables with unchanged width/header mode/row count, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, explicit date mentions written as `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)`, explicit user mentions written as `@user()`, and legacy `afs://` page links. Unchanged preimage mentions, such as existing date/user mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded existing Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing stable-width/header-mode tables including row add/delete, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, explicit date mentions written as `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)`, explicit user mentions written as `@user()`, and legacy `afs://` page links. Unchanged preimage mentions, such as existing date/user mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 4f8de5a..93c35fd 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -111,11 +111,11 @@ Nested children are fetched recursively and rendered after their parent, except The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; -- supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, same-shape existing tables, existing bookmark/embed URL blocks, and existing URL-backed media blocks; +- supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, existing stable-width/header-mode tables including row add/delete, existing bookmark/embed URL blocks, and existing URL-backed media blocks; - supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, explicit `@date(...)` date mentions, explicit `@user(...)` user mentions, legacy `afs://` page links, and unchanged preimage mentions such as dates/users; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, explicit people user IDs, and explicit relation page IDs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; -- unsupported write forms fail before API mutation, including table row add/delete or width changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; +- unsupported write forms fail before API mutation, including table width or header-mode changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; - appends use Notion's current position object, with `start` for prepends and `after_block` for inserts after a known block; - before apply, the connector re-reads the page and compares the current Notion edit timestamp against the last-synced timestamp carried by the push executor; - after apply, the CLI reconciler fetches changed and created pages, rewrites local files atomically, saves refreshed shadows, updates `remote_edited_at`, and removes the temporary source filename when a created row moves into its projected path. diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 8132c06..b2cebf6 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -96,7 +96,7 @@ and what Markdown shape agents should expect. - **Verification:** Fixture render coverage asserts that a returned `link_preview` block renders to Markdown link syntax. -### Same-Shape Table Cell Edits +### Table Cell And Row Edits - **Notion input:** Simple `table` blocks with `table_row` children and no nested row children. @@ -105,13 +105,16 @@ and what Markdown shape agents should expect. headerless tables keep the renderer's empty Markdown header marker and map data lines to Notion rows. - **Write behavior:** A Markdown edit to an existing table updates the - corresponding Notion `table_row.cells` values when table width, row count, and - header flags are unchanged. Row additions, row deletions, width changes, and - header-mode changes fail before API mutation. + corresponding Notion `table_row.cells` values when table width and header + flags are unchanged. Additional Markdown rows append Notion `table_row` + children under the table, and removed trailing rows archive the corresponding + Notion row blocks. Width changes and header-mode changes still fail before API + mutation. - **Verification:** Core diff coverage asserts that table edits produce a table block update rather than archive/recreate. Fixture apply tests assert exact - row update payloads, and the live mounted edit cycle updates a table cell then - verifies the rendered Notion result through the API. + row update/append/delete payloads, and the live mounted edit cycle updates a + table cell, appends a table row, then verifies the rendered Notion result + through the API. - **Bug fixed during live testing:** The live database fixtures used a fixed Notion unique-ID prefix, which can collide at workspace scope on repeated runs. The live fixtures now generate a short unique alphanumeric prefix for diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 473bc9d..9f8be8e 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -42,7 +42,7 @@ Sources used for the baseline: | `code` | Native fenced code | Yes | fixture, live | Language is preserved on simple code fences. | | `divider` | Native Markdown rule | Yes | fixture, live | `---`. | | `equation` | Native display math | Yes | fixture, live | `$$ ... $$`. | -| `table` | Native Markdown table | Yes for same-shape existing tables | fixture, live read/write | Existing cell edits update table rows when width, header mode, and row count stay unchanged. Row add/delete and width changes are still blocked. | +| `table` | Native Markdown table | Yes for existing tables with stable width/header mode | fixture, live read/write | Existing cell edits update table rows. Added Markdown rows append Notion `table_row` children; removed trailing rows archive row blocks. Width and header-mode changes are still blocked. | | `table_row` | Structural inside tables | No | fixture | Standalone/malformed rows render as directives. | | `child_page` | Directive and structural enumeration | No direct block write | fixture, live read | New child pages are created through page/entity creation, not block edits. | | `child_database` | Directive and structural enumeration | No direct block write | fixture, live read | Databases are created through the database API, not Markdown block writes. | @@ -117,8 +117,8 @@ Sources used for the baseline: - Media upload, hosted file rewrites, and non-image downloads are deferred until AFS has size limits, retention rules, and local path ownership semantics. -- Table structural writes are deferred until the planner can produce row-level - operations for row add/delete, width changes, and header-mode changes. +- Table width changes and header-mode changes are deferred until the planner can + represent them without losing Notion table semantics. - Layout and generated blocks (`column_*`, `breadcrumb`, `table_of_contents`, tabs) stay as directives because Markdown cannot represent their semantics. - Comments are not mounted because they need a separate thread model and push @@ -132,8 +132,8 @@ Sources used for the baseline: 1. Add fixture-backed write tests before widening any block type. The Tier 1 writer suite now covers headings, numbered lists, to-dos, quotes, callouts, code fences, dividers, and equations. -2. Extend table writes beyond same-shape cell edits. Row add/delete and width - changes need row-level diff/apply rather than whole-table replacement. +2. Extend table writes beyond stable-width row edits. Width changes and header + mode changes need a safer representation than whole-table replacement. 3. Keep layout, generated, synced, and unknown future blocks directive-backed until their Notion semantics can be represented without content loss. 4. Design media writes separately from text block writes. Upload support needs From ccc890d34ca3172378e86f361d355f75e8885ee3 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 09:17:41 -0500 Subject: [PATCH 16/18] Support explicit Notion page and database mentions --- crates/afs-cli/tests/e2e_push_workflow.rs | 46 ++++++++++++++++- crates/afs-notion/src/apply.rs | 62 ++++++++++++++++++++++- crates/afs-notion/tests/apply.rs | 32 +++++++++++- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 6 +-- docs/notion-cyclic-support-journal.md | 27 ++++++---- docs/notion-object-support.md | 4 +- 7 files changed, 159 insertions(+), 20 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index 987ede1..3ff855a 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -315,10 +315,19 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { let api = HttpNotionApi::new(NotionConfig::default()); let mut cleanup = LiveCleanup::new(api); let user_id = cleanup.current_user_id(); + let target = cleanup.create_page( + &env.parent_page_id, + &format!("AFS cyclic supported link target {}", unique_suffix()), + vec![paragraph_child("Target page for supported edit links.")], + ); + let linked_database = cleanup.create_database( + &env.parent_page_id, + &format!("AFS cyclic supported linked database {}", unique_suffix()), + ); let source = cleanup.create_page( &env.parent_page_id, &format!("AFS cyclic supported edits {}", unique_suffix()), - supported_edit_children(&user_id), + supported_edit_children(&user_id, &target.id, &linked_database.id), ); let connector = NotionConnector::new(NotionConfig::default()); @@ -386,6 +395,14 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { "Editable user ", &format!("Editable user @user({user_id})"), ); + let edited = replace_line_with_prefix( + edited, + "Editable typed links ", + &format!( + "Editable typed links @page({}) and @database({})", + target.id, linked_database.id + ), + ); fs::write(&page_path, edited).expect("write cyclic edits"); let dirty_status = run_status( @@ -422,10 +439,15 @@ fn live_cyclic_supported_block_edits_push_and_verify_notion() { assert!(clean_status.clean, "{clean_status:#?}"); let verified = render_live_page(&connector, &source.id, &page_path); + let target_url = notion_object_url(&target.id); + let linked_database_url = notion_object_url(&linked_database.id); for expected in [ "Editable paragraph changed.", "Editable date 2026-06-14", "Editable user ", + "Editable typed links ", + target_url.as_str(), + linked_database_url.as_str(), "# Editable heading one changed", "## Editable heading two changed", "### Editable heading three changed", @@ -1206,7 +1228,11 @@ fn diverse_page_children(target_page_id: &str, database_id: &str) -> Vec ] } -fn supported_edit_children(user_id: &str) -> Vec { +fn supported_edit_children( + user_id: &str, + target_page_id: &str, + linked_database_id: &str, +) -> Vec { vec![ paragraph_child("Editable paragraph original."), json!({ @@ -1223,6 +1249,18 @@ fn supported_edit_children(user_id: &str) -> Vec { "rich_text": [text_part("Editable user "), user_mention_part(user_id)] } }), + json!({ + "object": "block", + "type": "paragraph", + "paragraph": { + "rich_text": [ + text_part("Editable typed links "), + page_mention_part("Target page", target_page_id), + text_part(" and "), + database_mention_part("Linked database", linked_database_id), + ] + } + }), rich_text_child("heading_1", "Editable heading one"), rich_text_child("heading_2", "Editable heading two"), rich_text_child("heading_3", "Editable heading three"), @@ -1751,6 +1789,10 @@ fn normalize_notion_id(input: &str) -> String { } } +fn notion_object_url(id: &str) -> String { + format!("https://www.notion.so/{}", normalize_notion_id(id)) +} + fn replace_line_with_prefix(markdown: String, prefix: &str, replacement: &str) -> String { let mut replaced = false; let lines = markdown diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 0f3600f..096903e 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -1704,6 +1704,32 @@ impl InlineParser<'_> { ))); } + if rest.starts_with("@page(") + && let Some(end) = find_closing(rest, 6, ")") + { + let id = parse_page_mention_arg(&rest[6..end])?; + return Ok(Some(( + vec![RichTextWritePart::PageMention { + id, + annotations: InlineAnnotations::default(), + }], + end + 1, + ))); + } + + if rest.starts_with("@database(") + && let Some(end) = find_closing(rest, 10, ")") + { + let id = parse_database_mention_arg(&rest[10..end])?; + return Ok(Some(( + vec![RichTextWritePart::DatabaseMention { + id, + annotations: InlineAnnotations::default(), + }], + end + 1, + ))); + } + if rest.starts_with("@user(") && let Some(end) = find_closing(rest, 6, ")") { @@ -1781,7 +1807,19 @@ impl InlineParser<'_> { fn next_special_or_preimage(&self, start: usize, closing: Option<&str>) -> usize { let mut next = self.input.len(); - for marker in ["**", "~~", "", "`", "$", "@date(", "@user(", "[", "_"] { + for marker in [ + "**", + "~~", + "", + "`", + "$", + "@date(", + "@page(", + "@database(", + "@user(", + "[", + "_", + ] { if let Some(offset) = self.input[start..].find(marker) { next = next.min(start + offset); } @@ -1887,6 +1925,28 @@ fn parse_user_mention_arg(input: &str) -> AfsResult { } } +fn parse_page_mention_arg(input: &str) -> AfsResult { + let id = parse_named_id_entry(input).trim(); + if valid_notion_id(id) { + Ok(id.to_string()) + } else { + Err(AfsError::Unsupported( + "page mention syntax; use @page()", + )) + } +} + +fn parse_database_mention_arg(input: &str) -> AfsResult { + let id = parse_named_id_entry(input).trim(); + if valid_notion_id(id) { + Ok(id.to_string()) + } else { + Err(AfsError::Unsupported( + "database mention syntax; use @database()", + )) + } +} + fn parse_markdown_link(input: &str) -> Option<(&str, &str, usize)> { if !input.starts_with('[') { return None; diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index 81e2ad5..e03a7d8 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -641,7 +641,7 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { vec![RemoteId::new("page-1")], vec![PushOperation::UpdateBlock { block_id: RemoteId::new("paragraph-1"), - content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and database [Tasks updated](https://www.notion.so/33333333333333333333333333333333) and @date(2026-06-11 to 2026-06-12, tz=America/Chicago) and @user(Ada <55555555-5555-5555-5555-555555555555>) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), + content: "**Boldly** and 2026-06-10 plus [Docs](https://example.com/) and database [Tasks updated](https://www.notion.so/33333333333333333333333333333333) and @date(2026-06-11 to 2026-06-12, tz=America/Chicago) and @user(Ada <55555555-5555-5555-5555-555555555555>) and @page(Roadmap <44444444-4444-4444-4444-444444444444>) and @database(66666666666666666666666666666666) and $E=mc^2$ [Hex docs](https://example.com/22222222222222222222222222222222) [Roadmap](https://www.notion.so/Project-22222222222222222222222222222222)".to_string(), }], ); let push_id = PushId("push-1".to_string()); @@ -764,6 +764,36 @@ fn apply_preserves_unchanged_mentions_and_parses_edited_rich_spans() { "content": " and ", }, }, + { + "type": "mention", + "mention": { + "type": "page", + "page": { + "id": "44444444-4444-4444-4444-444444444444", + }, + }, + }, + { + "type": "text", + "text": { + "content": " and ", + }, + }, + { + "type": "mention", + "mention": { + "type": "database", + "database": { + "id": "66666666666666666666666666666666", + }, + }, + }, + { + "type": "text", + "text": { + "content": " and ", + }, + }, { "type": "equation", "equation": { diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 2d72021..5e42936 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -38,7 +38,7 @@ Media blocks with a Notion `file.url` or `external.url` render as ordinary Markd When rendered through a filesystem-aware pull or reconcile path, image files are also downloaded into the mount-level `media/` directory so agents can open a local copy without cluttering the Markdown page directory. URL-less media payloads still render as directives, for example `::afs{id=image-id type=image title="Architecture diagram"}`. -The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded existing Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing stable-width/header-mode tables including row add/delete, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, explicit date mentions written as `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)`, explicit user mentions written as `@user()`, and legacy `afs://` page links. Unchanged preimage mentions, such as existing date/user mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. +The first writer supports block bodies whose Markdown shape maps to one Notion block or a guarded existing Notion table: paragraphs, headings, single list items, to-dos, quotes, code fences, dividers, display equations, existing stable-width/header-mode tables including row add/delete, existing bookmark/embed URL blocks, and existing URL-backed media blocks. Media edits write external URLs; uploads and appending new media blocks are deferred. It also parses the rich inline Markdown emitted by the renderer for bold, italic, strikethrough, underline, code, external links, equations, Notion page links, database links whose target ID matches a rendered database mention, explicit page/database mentions written as `@page()` and `@database()`, explicit date mentions written as `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)`, explicit user mentions written as `@user()`, and legacy `afs://` page links. Unchanged preimage mentions, such as existing date/user mentions, are preserved during block updates; unsupported inline shapes fail rather than being flattened silently. ## Database Rows diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 93c35fd..2abc1db 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -101,7 +101,7 @@ Inline rich text is represented with Notion DTOs first, then rendered through on - `RichTextDto` mirrors Notion's `text`, `mention`, and `equation` variants plus shared annotations and links. - `TextRichTextDto`, `MentionRichTextDto`, and `EquationRichTextDto` keep variant-specific payloads out of renderer control flow. - The renderer preserves whitespace around annotated spans so text like ` bold ` becomes ` **bold** ` instead of pulling spaces into Markdown delimiters. -- Page and database mentions render as normal Markdown links to Notion object URLs. Unchanged mention preimages still preserve typed Notion mentions during block updates. +- Page and database mentions render as normal Markdown links to Notion object URLs. Unchanged mention preimages still preserve typed Notion mentions during block updates, and agents can create or reassert typed links with `@page()` and `@database()`. - Unknown or partially populated rich text falls back to `plain_text` so live API additions remain readable. Nested children are fetched recursively and rendered after their parent, except valid table rows, which are folded into their parent table's Markdown block. This preserves content and block IDs for the first read path, but it does not yet preserve every Notion nesting/layout nuance. Layout-rich blocks should stay directive-backed until the renderer can round-trip them safely. @@ -112,7 +112,7 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, existing stable-width/header-mode tables including row add/delete, existing bookmark/embed URL blocks, and existing URL-backed media blocks; -- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, explicit `@date(...)` date mentions, explicit `@user(...)` user mentions, legacy `afs://` page links, and unchanged preimage mentions such as dates/users; +- supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, explicit `@page(...)` page mentions, explicit `@database(...)` database mentions, explicit `@date(...)` date mentions, explicit `@user(...)` user mentions, legacy `afs://` page links, and unchanged preimage mentions such as dates/users; - supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, explicit people user IDs, and explicit relation page IDs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; - unsupported write forms fail before API mutation, including table width or header-mode changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; @@ -120,7 +120,7 @@ The first Notion apply path is intentionally conservative: - before apply, the connector re-reads the page and compares the current Notion edit timestamp against the last-synced timestamp carried by the push executor; - after apply, the CLI reconciler fetches changed and created pages, rewrites local files atomically, saves refreshed shadows, updates `remote_edited_at`, and removes the temporary source filename when a created row moves into its projected path. -This gives the end-to-end write loop while preserving the rich inline shapes that the renderer emits. The next fidelity step is widening the inline parser to cover additional mention types, nested annotation/link combinations, and relative-file link resolution. +This gives the end-to-end write loop while preserving the rich inline shapes that the renderer emits. The next fidelity step is widening the inline parser to cover nested annotation/link combinations, local relative-file link resolution, and remaining specialized Notion mention variants. ## Schema-Backed Property Validation diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index b2cebf6..463b259 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -174,22 +174,29 @@ and what Markdown shape agents should expect. result. The live direct integrity test creates a people value and then clears it through the API writer. -### Database Mentions As Markdown Links +### Page And Database Mention Writes - **Notion input:** Rich-text database mentions and `link_to_page` blocks that - target databases. -- **Markdown output:** Both render as normal Markdown links to Notion URLs, - matching page mention/link behavior. + target databases; rich-text page mentions and page `link_to_page` blocks use + the same URL shape. +- **Markdown output:** Page and database mentions render as normal Markdown + links to Notion URLs. - **Write behavior:** When a rendered database mention link is edited only in label text and the Notion target ID is unchanged, the rich-text parser writes it back as a typed Notion database mention instead of accidentally converting - it to a page mention. Creating arbitrary new database mentions from a plain - Notion URL still needs an explicit typed-link syntax because Notion page and - database URLs are not distinguishable from the ID alone. + it to a page mention. Agents can create or reassert typed page/database + mentions with `@page()` and + `@database()`; both forms also accept + `Name ` labels for readability. Plain Notion URLs are still ambiguous + without a preimage, so the explicit database form is the canonical way to + create a new database mention. - **Verification:** Fixture apply tests assert edited database mention links - produce `mention.database` payloads. The live diverse page cyclic test creates - both a rich-text database mention and a database `link_to_page` block, then - verifies the mounted read/no-op push does not mutate the Notion block JSON. + and explicit page/database mention syntax produce typed request payloads. The + live diverse page cyclic test creates both a rich-text database mention and a + database `link_to_page` block, then verifies the mounted read/no-op push does + not mutate the Notion block JSON. The live supported-edit cycle rewrites a + paragraph with `@page(...)` and `@database(...)`, pushes it, and verifies the + fresh Notion render includes the target object links. ### Explicit Date Mention Writes diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 9f8be8e..8e5bb21 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -78,8 +78,8 @@ Sources used for the baseline: | External text link | Markdown link | Yes | fixture, live | Link URL is preserved. | | Equation span | Inline math | Yes | fixture, live | `$...$`. | | Bold, italic, strikethrough, underline, code | Markdown/HTML inline formatting | Yes for emitted shapes | fixture, live | Underline uses ``. | -| Page mention | Markdown link to Notion URL | Read; write via Notion-hosted URL or legacy `afs://` parsing path | fixture, live | Stable ID is preserved; external UUID-shaped links remain ordinary links. | -| Database mention | Markdown link to Notion URL | Read; label edits preserve database type when target ID is unchanged | fixture, live read | Stable ID is preserved. Arbitrary new database-link creation needs an explicit typed link form. | +| Page mention | Markdown link to Notion URL; explicit `@page(...)` write syntax | Yes through Notion-hosted URL, explicit ID syntax, or legacy `afs://` parsing path | fixture, live read/write | Stable ID is preserved; external UUID-shaped links remain ordinary links. Agents can write `@page(11111111-1111-1111-1111-111111111111)` or `@page(Name <11111111-1111-1111-1111-111111111111>)`. | +| Database mention | Markdown link to Notion URL; explicit `@database(...)` write syntax | Yes through explicit ID syntax; label edits preserve database type when target ID is unchanged | fixture, live read/write | Stable ID is preserved. Agents can write `@database(11111111-1111-1111-1111-111111111111)` or `@database(Name <11111111-1111-1111-1111-111111111111>)`. | | User mention | Plain `@name`/fallback; explicit `@user(...)` write syntax | Yes through explicit ID syntax | fixture, live read/write | Agents can write `@user(11111111-1111-1111-1111-111111111111)` or `@user(Name <11111111-1111-1111-1111-111111111111>)`; name/email lookup is deferred. | | Date mention | Plain date/range text; explicit `@date(...)` write syntax | Yes through explicit syntax | fixture, live read/write | Agents can write `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)` when the result must remain a typed Notion date mention. Plain dates stay plain text unless preserved from the preimage. | | Link preview mention | Markdown link | Read only | fixture | Preserves URL. | From 4a03c31700145856487b69514f0aabd520ec372f Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 09:34:49 -0500 Subject: [PATCH 17/18] Preserve rich text Markdown in Notion properties --- crates/afs-cli/tests/e2e_push_workflow.rs | 8 +-- crates/afs-notion/src/apply.rs | 16 ++++- crates/afs-notion/src/render.rs | 2 +- crates/afs-notion/tests/apply.rs | 74 +++++++++++++++++++++++ crates/afs-notion/tests/fetch_render.rs | 13 +++- docs/notion-canonical-format.md | 2 +- docs/notion-connector.md | 2 +- docs/notion-cyclic-support-journal.md | 16 +++++ docs/notion-object-support.md | 2 +- 9 files changed, 123 insertions(+), 12 deletions(-) diff --git a/crates/afs-cli/tests/e2e_push_workflow.rs b/crates/afs-cli/tests/e2e_push_workflow.rs index 3ff855a..cb7d754 100644 --- a/crates/afs-cli/tests/e2e_push_workflow.rs +++ b/crates/afs-cli/tests/e2e_push_workflow.rs @@ -620,7 +620,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { let edited = original .replace( "\"Notes\": \"Initial row notes\"", - "\"Notes\": \"Updated row notes\"", + "\"Notes\": \"**Updated** row notes and @date(2026-06-14)\"", ) .replace("\"Points\": 7", "\"Points\": 8") .replace("\"Status\": \"Todo\"", "\"Status\": \"Done\"") @@ -668,7 +668,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { let verified = render_live_markdown(&connector, &existing_row.id, &row_path); for expected in [ - "\"Notes\": \"Updated row notes\"", + "\"Notes\": \"**Updated** row notes and 2026-06-14\"", "\"Points\": 8", "\"Status\": \"Done\"", "\"State\": \"In progress\"", @@ -690,7 +690,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { fs::write( &new_row_path, &format!( - "---\ntitle: AFS cyclic created row\nNotes: Created row notes\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\nFiles:\n - Created file \nPeople:\n - \"{}\"\nRelated:\n - \"{}\"\n---\n# Created row body\n\nCreated from mounted markdown.\n", + "---\ntitle: AFS cyclic created row\nNotes: \"Created **row** notes and [docs](https://example.com/created-notes)\"\nPoints: 13\nStatus: Todo\nState: Not started\nTags:\n - Alpha\nDone: false\nDue: \"2026-06-13\"\nURL: https://example.com/afs-created-row\nEmail: cyclic@example.com\nPhone: \"+1 415 555 0199\"\nFiles:\n - Created file \nPeople:\n - \"{}\"\nRelated:\n - \"{}\"\n---\n# Created row body\n\nCreated from mounted markdown.\n", people_user_id, related_row.id ), ) @@ -719,7 +719,7 @@ fn live_cyclic_database_rows_mount_edit_create_and_verify_notion() { let created = render_live_markdown(&connector, &created_row_id, &new_row_path); for expected in [ "title: \"AFS cyclic created row\"", - "\"Notes\": \"Created row notes\"", + "\"Notes\": \"Created **row** notes and [docs](https://example.com/created-notes)\"", "\"Points\": 13", "\"Status\": \"Todo\"", "\"State\": \"Not started\"", diff --git a/crates/afs-notion/src/apply.rs b/crates/afs-notion/src/apply.rs index 096903e..44a51ec 100644 --- a/crates/afs-notion/src/apply.rs +++ b/crates/afs-notion/src/apply.rs @@ -662,13 +662,16 @@ fn property_update_value( value: &PropertyValue, key: &str, ) -> AfsResult { - property_value_for_kind(&property.kind, value, key) + match property.kind.as_str() { + "rich_text" => rich_text_property(value, key, Some(property.rich_text.as_slice())), + _ => property_value_for_kind(&property.kind, value, key), + } } fn property_value_for_kind(kind: &str, value: &PropertyValue, key: &str) -> AfsResult { match kind { "title" => Ok(json!({ "title": rich_text(&required_string(value, key)?) })), - "rich_text" => Ok(json!({ "rich_text": rich_text(&required_string(value, key)?) })), + "rich_text" => rich_text_property(value, key, None), "number" => number_property(value, key), "select" => option_property("select", value, key), "status" => option_property("status", value, key), @@ -683,6 +686,15 @@ fn property_value_for_kind(kind: &str, value: &PropertyValue, key: &str) -> AfsR } } +fn rich_text_property( + value: &PropertyValue, + key: &str, + preimage: Option<&[RichTextDto]>, +) -> AfsResult { + let content = required_string(value, key)?; + Ok(json!({ "rich_text": rich_text_payload(&content, preimage)? })) +} + fn number_property(value: &PropertyValue, key: &str) -> AfsResult { match value { PropertyValue::Null => Ok(json!({ "number": Value::Null })), diff --git a/crates/afs-notion/src/render.rs b/crates/afs-notion/src/render.rs index 53a45d8..4994528 100644 --- a/crates/afs-notion/src/render.rs +++ b/crates/afs-notion/src/render.rs @@ -918,7 +918,7 @@ fn append_property_frontmatter(out: &mut String, page: &PageDto) { fn property_frontmatter_value(property: &PagePropertyDto) -> Option { match property.kind.as_str() { "rich_text" => Some(FrontmatterValue::Scalar(yaml_string( - &rich_text_plain_text(&property.rich_text), + &rich_text_to_markdown(&property.rich_text), ))), "number" => Some(number_value(property.number.as_ref())), "select" => Some(option_name(property.select.as_ref())), diff --git a/crates/afs-notion/tests/apply.rs b/crates/afs-notion/tests/apply.rs index e03a7d8..6467b55 100644 --- a/crates/afs-notion/tests/apply.rs +++ b/crates/afs-notion/tests/apply.rs @@ -1313,6 +1313,7 @@ fn apply_updates_supported_page_properties() { ("Tags".to_string(), page_property("multi_select")), ("Done".to_string(), page_property("checkbox")), ("Points".to_string(), page_property("number")), + ("Notes".to_string(), page_property("rich_text")), ("Due".to_string(), page_property("date")), ("URL".to_string(), page_property("url")), ("Files".to_string(), page_property("files")), @@ -1340,6 +1341,10 @@ fn apply_updates_supported_page_properties() { ), ("Done".to_string(), PropertyValue::Bool(false)), ("Points".to_string(), PropertyValue::Number("3".to_string())), + ( + "Notes".to_string(), + PropertyValue::String("**Updated** notes and @date(2026-06-14)".to_string()), + ), ( "Due".to_string(), PropertyValue::String("2026-06-10".to_string()), @@ -1398,6 +1403,7 @@ fn apply_updates_supported_page_properties() { "Done".to_string(), "Due".to_string(), "Files".to_string(), + "Notes".to_string(), "People".to_string(), "Points".to_string(), "Relation".to_string(), @@ -1435,6 +1441,39 @@ fn apply_updates_supported_page_properties() { "Points": { "number": 3.0, }, + "Notes": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "Updated", + }, + "annotations": { + "bold": true, + "italic": false, + "strikethrough": false, + "underline": false, + "code": false, + "color": "default", + }, + }, + { + "type": "text", + "text": { + "content": " notes and ", + }, + }, + { + "type": "mention", + "mention": { + "type": "date", + "date": { + "start": "2026-06-14", + }, + }, + }, + ], + }, "Due": { "date": { "start": "2026-06-10", @@ -1488,6 +1527,7 @@ fn apply_creates_database_row_with_properties_and_children() { ("Tags".to_string(), data_source_property("multi_select")), ("Done".to_string(), data_source_property("checkbox")), ("Points".to_string(), data_source_property("number")), + ("Notes".to_string(), data_source_property("rich_text")), ("Files".to_string(), data_source_property("files")), ("People".to_string(), data_source_property("people")), ("Relation".to_string(), data_source_property("relation")), @@ -1511,6 +1551,10 @@ fn apply_creates_database_row_with_properties_and_children() { ), ("Done".to_string(), PropertyValue::Bool(false)), ("Points".to_string(), PropertyValue::Number("5".to_string())), + ( + "Notes".to_string(), + PropertyValue::String("Created **rich** notes".to_string()), + ), ( "Files".to_string(), PropertyValue::List(vec![ @@ -1586,6 +1630,36 @@ fn apply_creates_database_row_with_properties_and_children() { "Done": { "checkbox": false, }, + "Notes": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "Created ", + }, + }, + { + "type": "text", + "text": { + "content": "rich", + }, + "annotations": { + "bold": true, + "italic": false, + "strikethrough": false, + "underline": false, + "code": false, + "color": "default", + }, + }, + { + "type": "text", + "text": { + "content": " notes", + }, + }, + ], + }, "Points": { "number": 5.0, }, diff --git a/crates/afs-notion/tests/fetch_render.rs b/crates/afs-notion/tests/fetch_render.rs index 43d7153..fd8a274 100644 --- a/crates/afs-notion/tests/fetch_render.rs +++ b/crates/afs-notion/tests/fetch_render.rs @@ -921,12 +921,21 @@ fn render_database_row_properties_as_frontmatter() { #[test] fn render_all_supported_page_property_values_as_frontmatter() { let mut row = page("row-1", "Property Coverage"); + let mut rich_property = rich_text("Notes"); + rich_property.annotations = RichTextAnnotationsDto { + bold: true, + ..Default::default() + }; row.properties.extend(BTreeMap::from([ ( "Rich Text".to_string(), PagePropertyDto { kind: "rich_text".to_string(), - rich_text: vec![rich_text("Notes")], + rich_text: vec![ + rich_property, + rich_text(" and "), + linked_text("docs", "https://example.com/docs"), + ], ..Default::default() }, ), @@ -1178,7 +1187,7 @@ fn render_all_supported_page_property_values_as_frontmatter() { let frontmatter = &rendered.document.frontmatter; for expected in [ - "\"Rich Text\": \"Notes\"", + "\"Rich Text\": \"**Notes** and [docs](https://example.com/docs)\"", "\"Number\": 7", "\"Select\": \"Selected\"", "\"Multi Select\":\n - \"Alpha\"\n - \"Beta\"", diff --git a/docs/notion-canonical-format.md b/docs/notion-canonical-format.md index 5e42936..c0ca641 100644 --- a/docs/notion-canonical-format.md +++ b/docs/notion-canonical-format.md @@ -42,7 +42,7 @@ The first writer supports block bodies whose Markdown shape maps to one Notion b ## Database Rows -A Notion database row renders as the same page document shape with row properties flattened into frontmatter keys: +A Notion database row renders as the same page document shape with row properties flattened into frontmatter keys. Rich-text properties use the same inline Markdown contract as page bodies, so annotations, links, equations, and supported explicit mention syntax can be edited from frontmatter: ```markdown --- diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 2abc1db..9c2a6dd 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -113,7 +113,7 @@ The first Notion apply path is intentionally conservative: - supported operations: block update, block append, block archive, supported page property update, and database row creation; - supported writable block forms: paragraphs, headings 1-4, bulleted list items, numbered list items, to-dos, quotes, callouts, code fences, dividers, display equations, existing stable-width/header-mode tables including row add/delete, existing bookmark/embed URL blocks, and existing URL-backed media blocks; - supported rich-text spans: bold, italic, strikethrough, underline, code, external links, inline equations, Notion page links, database links whose target ID matches a rendered database mention, explicit `@page(...)` page mentions, explicit `@database(...)` database mentions, explicit `@date(...)` date mentions, explicit `@user(...)` user mentions, legacy `afs://` page links, and unchanged preimage mentions such as dates/users; -- supported page property writes: title, rich text, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, explicit people user IDs, and explicit relation page IDs; +- supported page property writes: title, rich text with the same inline Markdown parser used by page bodies, number, select, status, multi-select, checkbox, date, URL, email, phone, external file URLs, explicit people user IDs, and explicit relation page IDs; - new row creation accepts a new Markdown file under a projected database directory, uses the file's `title` as the row title, maps supported frontmatter properties through the live data source schema, creates initial children from directly supported Markdown blocks, and then reconciles the created page into its stable `slug ~shortid.md` path; - unsupported write forms fail before API mutation, including table width or header-mode changes, page/database creation outside database-row files, computed/read-only properties, hosted file uploads/rewrites, multi-data-source row creation, and rich inline shapes that cannot be represented by the current Markdown parser; - appends use Notion's current position object, with `start` for prepends and `after_block` for inserts after a known block; diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 463b259..6451b58 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -227,3 +227,19 @@ and what Markdown shape agents should expect. `mention.user` request payload. The live supported-edit cycle rewrites a real Notion user mention through mounted Markdown and verifies the fresh Notion render still contains the user mention line. + +### Rich-Text Property Markdown + +- **Notion input:** Page and data-source row `rich_text` properties. +- **Markdown output:** Rich-text properties render in frontmatter with the same + inline Markdown contract as page bodies, including annotations, external + links, equations, and supported mentions. +- **Write behavior:** Frontmatter rich-text property edits now use the shared + rich-text parser instead of flattening values to a single plain text span. + Existing property preimages are passed to the parser during updates so typed + mention payloads can be preserved when their rendered text is unchanged. +- **Verification:** Fixture render tests cover annotated/link-rich + frontmatter. Fixture apply tests cover rich-text property updates and database + row creation. The live mounted database-row cycle edits and creates rich-text + properties with bold text, a typed date mention, and an external text link, + then verifies the fresh Notion render. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 8e5bb21..5500cf6 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -90,7 +90,7 @@ Sources used for the baseline: | Property type | Read/frontmatter | Write | Tests | Notes | |---|---:|---:|---|---| | `title` | Yes | Yes | fixture, live, mounted live, schema | Title is the canonical `title` frontmatter field. | -| `rich_text` | Yes | Yes | fixture, live, mounted live, schema | Written as plain rich text today. | +| `rich_text` | Yes with inline Markdown | Yes with the body rich-text Markdown parser | fixture, live, mounted live, schema | Frontmatter preserves supported annotations, external links, equations, and explicit typed mention syntax instead of flattening to plain text. | | `number` | Yes | Yes | fixture, live, mounted live, schema | Numeric validation happens before API call. | | `select` | Yes | Yes | fixture, live, mounted live, schema | Option names must exist in `_schema.yaml`. | | `status` | Yes | Yes | fixture, live, mounted live, schema | Option names must exist in `_schema.yaml`. | From a84302589af30f9c5ff29dbba86584cb724730ba Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Sat, 13 Jun 2026 09:47:30 -0500 Subject: [PATCH 18/18] Document link preview mention write deferral --- docs/notion-cyclic-support-journal.md | 15 +++++++++++++++ docs/notion-object-support.md | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/notion-cyclic-support-journal.md b/docs/notion-cyclic-support-journal.md index 6451b58..817ae08 100644 --- a/docs/notion-cyclic-support-journal.md +++ b/docs/notion-cyclic-support-journal.md @@ -243,3 +243,18 @@ and what Markdown shape agents should expect. row creation. The live mounted database-row cycle edits and creates rich-text properties with bold text, a typed date mention, and an external text link, then verifies the fresh Notion render. + +### Link Preview Mention Write Deferral + +- **Notion input:** Rich-text `link_preview` mentions. +- **Markdown output:** Link-preview mentions render as ordinary Markdown links + so the URL remains visible to agents. +- **Write behavior:** Kept read-only. A live write probe against the current + Notion API rejected `mention.link_preview` in page child rich text payloads; + the validation response allowed user/date/page/database/template/custom emoji + mention payloads instead. Until the API exposes a writable shape, AFS should + treat edited link-preview mentions as unsupported rather than silently + converting them to ordinary text links. +- **Verification:** The failed live probe happened while attempting to extend + `live_cyclic_supported_block_edits_push_and_verify_notion`; no writer support + was kept in this PR because fixture-only support would be misleading. diff --git a/docs/notion-object-support.md b/docs/notion-object-support.md index 5500cf6..3e4f455 100644 --- a/docs/notion-object-support.md +++ b/docs/notion-object-support.md @@ -82,7 +82,7 @@ Sources used for the baseline: | Database mention | Markdown link to Notion URL; explicit `@database(...)` write syntax | Yes through explicit ID syntax; label edits preserve database type when target ID is unchanged | fixture, live read/write | Stable ID is preserved. Agents can write `@database(11111111-1111-1111-1111-111111111111)` or `@database(Name <11111111-1111-1111-1111-111111111111>)`. | | User mention | Plain `@name`/fallback; explicit `@user(...)` write syntax | Yes through explicit ID syntax | fixture, live read/write | Agents can write `@user(11111111-1111-1111-1111-111111111111)` or `@user(Name <11111111-1111-1111-1111-111111111111>)`; name/email lookup is deferred. | | Date mention | Plain date/range text; explicit `@date(...)` write syntax | Yes through explicit syntax | fixture, live read/write | Agents can write `@date(2026-06-14)` or `@date(2026-06-14 to 2026-06-21, tz=America/Chicago)` when the result must remain a typed Notion date mention. Plain dates stay plain text unless preserved from the preimage. | -| Link preview mention | Markdown link | Read only | fixture | Preserves URL. | +| Link preview mention | Markdown link | Read only | fixture, live API probe | Preserves URL on read. Current Notion write validation rejects `mention.link_preview` in page child rich text payloads, so AFS must not synthesize or preserve it through edited writes yet. | | Unknown mention variants | Plain text fallback | No | fixture | Avoids losing visible content while blocking typed edits. | ## Page And Data Source Properties