diff --git a/custom-skills/32-notion-writer/code/scripts/notion_writer.py b/custom-skills/32-notion-writer/code/scripts/notion_writer.py index 20b3409..3622341 100644 --- a/custom-skills/32-notion-writer/code/scripts/notion_writer.py +++ b/custom-skills/32-notion-writer/code/scripts/notion_writer.py @@ -135,6 +135,81 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]: blocks.append(create_toggle_block(summary_text, children)) continue + # Columns (Pandoc fenced div ::: columns) + if line.strip() == '::: columns': + columns_lines: List[List[str]] = [] + current_col: Optional[List[str]] = None + i += 1 + depth = 1 # we are inside our own wrapper + while i < len(lines) and depth > 0: + stripped_col = lines[i].strip() + if stripped_col == '::: columns': + depth += 1 + if current_col is not None: + current_col.append(lines[i]) + i += 1 + continue + if stripped_col == '::: column': + depth += 1 + if depth == 2: + # Top-level column inside our wrapper + if current_col is not None: + columns_lines.append(current_col) + current_col = [] + else: + # Column inside a nested wrapper — record verbatim + if current_col is not None: + current_col.append(lines[i]) + i += 1 + continue + if stripped_col == ':::': + depth -= 1 + if depth == 0: + # Closing our wrapper + if current_col is not None: + columns_lines.append(current_col) + current_col = None + i += 1 + break + if depth == 1: + # Closing a top-level column + if current_col is not None: + columns_lines.append(current_col) + current_col = None + else: + # Closing something nested — record verbatim + if current_col is not None: + current_col.append(lines[i]) + i += 1 + continue + # Regular content line — append to current column if open + if current_col is not None: + current_col.append(lines[i]) + i += 1 + if depth > 0: + # Unclosed wrapper at EOF — degrade to paragraphs + print("Warning: unclosed ::: columns at EOF; emitting as paragraphs", + file=sys.stderr) + if current_col is not None: + columns_lines.append(current_col) + for col in columns_lines: + for inner in col: + if inner.strip(): + blocks.append(create_paragraph_block(inner)) + continue + # Drop columns that are pure whitespace + columns_lines = [c for c in columns_lines if any(li.strip() for li in c)] + if len(columns_lines) < 2: + # Notion requires >= 2 columns; single column degrades to paragraphs + for col in columns_lines: + for inner in col: + if inner.strip(): + blocks.append(create_paragraph_block(inner)) + continue + column_blocks = [_parse_lines(col_lines) for col_lines in columns_lines] + blocks.append(create_column_list_block(column_blocks)) + continue + if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]): header_cells = _split_table_row(line) i += 2 @@ -382,6 +457,20 @@ def create_toggle_block(summary_text: str, children_blocks: List[Dict[str, Any]] } +def create_column_list_block(columns: List[List[Dict[str, Any]]]) -> Dict[str, Any]: + """Create a column_list block; each item in `columns` is the children-list for one column.""" + return { + "object": "block", + "type": "column_list", + "column_list": { + "children": [ + {"object": "block", "type": "column", "column": {"children": col_blocks}} + for col_blocks in columns + ], + }, + } + + def create_code_block(code: str, language: str) -> Dict[str, Any]: return { "object": "block", diff --git a/custom-skills/32-notion-writer/code/scripts/test_parser.py b/custom-skills/32-notion-writer/code/scripts/test_parser.py index 1a9a7a2..863a938 100644 --- a/custom-skills/32-notion-writer/code/scripts/test_parser.py +++ b/custom-skills/32-notion-writer/code/scripts/test_parser.py @@ -280,6 +280,69 @@ def test_toggle_unclosed_falls_through(): _assert("body line" in joined, "body preserved as paragraph") +def test_columns_two(): + md = """::: columns +::: column +left content +::: +::: column +right content +::: +:::""" + blocks = markdown_to_notion_blocks(md) + _assert(len(blocks) == 1, "one block emitted") + _assert(blocks[0]["type"] == "column_list", "block type is column_list") + cols = blocks[0]["column_list"]["children"] + _assert(len(cols) == 2, "two columns") + _assert(cols[0]["type"] == "column", "child 0 is column") + _assert(cols[1]["type"] == "column", "child 1 is column") + left_para = cols[0]["column"]["children"][0]["paragraph"]["rich_text"][0]["text"]["content"] + right_para = cols[1]["column"]["children"][0]["paragraph"]["rich_text"][0]["text"]["content"] + _assert(left_para == "left content", "left column content preserved") + _assert(right_para == "right content", "right column content preserved") + + +def test_columns_with_blocks(): + """Columns can hold any block type via per-column _parse_lines recursion.""" + md = """::: columns +::: column +- bullet a +- bullet b +::: +::: column +```python +print(1) +``` +::: +:::""" + blocks = markdown_to_notion_blocks(md) + _assert(blocks[0]["type"] == "column_list", "outer is column_list") + cols = blocks[0]["column_list"]["children"] + _assert(len(cols) == 2, "two columns") + left_types = [c["type"] for c in cols[0]["column"]["children"]] + right_types = [c["type"] for c in cols[1]["column"]["children"]] + _assert(left_types.count("bulleted_list_item") == 2, "left column has 2 bullets") + _assert("code" in right_types, "right column has code block") + + +def test_columns_single_degrades(): + """Single-column ::: columns block degrades to paragraphs (Notion requires >= 2).""" + md = """::: columns +::: column +only one column here +::: +:::""" + blocks = markdown_to_notion_blocks(md) + types = [b["type"] for b in blocks] + _assert("column_list" not in types, "no column_list emitted for single column") + paragraph_texts = [ + b["paragraph"]["rich_text"][0]["text"]["content"] + for b in blocks if b["type"] == "paragraph" + ] + joined = " ".join(paragraph_texts) + _assert("only one column here" in joined, "content preserved as paragraph") + + def test_rich_text_anchor_link_becomes_bold(): """TOC-style fragment links must not be sent as Notion link annotations.""" spans = parse_rich_text("see [Section A](#section-a) below") @@ -339,6 +402,9 @@ def run_all(): test_toggle_nested_blocks, test_toggle_nested_toggle, test_toggle_unclosed_falls_through, + test_columns_two, + test_columns_with_blocks, + test_columns_single_degrades, test_rich_text_anchor_link_becomes_bold, test_rich_text_relative_link_becomes_plain, test_rich_text_absolute_link_preserved,