diff --git a/.gitignore b/.gitignore index a4f1825..6b70856 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Binary notion +notion.exe # OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7a3dee8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# CHANGELOG + +모든 Git 커밋 이력을 최신순으로 기록합니다. 새 커밋은 표 최상단에 추가합니다. + +| 일시 | 유형 | 범위 | 변경내용 (목적 포함) | +|---|---|---|---| +| 2026-03-14 21:00 | chore | gitignore | notion.exe 바이너리 gitignore 추가 — Windows 빌드 결과물 추적 방지 | +| 2026-03-14 20:10 | fix | block | table_row children을 table{} 내부로 이동 — Notion API 스펙 준수 ('table.children should be defined' 오류 수정) | +| 2026-03-14 19:52 | feat | block | GFM 테이블 파싱 + 인라인 서식(bold/italic/code/link/strike) 지원 추가 — 노션 CLI로 마크다운 표 업로드 시 깨지던 문제 근본 해결 | diff --git a/cmd/block.go b/cmd/block.go index 39f02cc..28ef3e3 100644 --- a/cmd/block.go +++ b/cmd/block.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "regexp" "strings" "github.com/4ier/notion-cli/internal/client" @@ -786,6 +787,29 @@ func parseMarkdownToBlocks(content string) []map[string]interface{} { continue } + // GFM Table: starts with '|' + if strings.HasPrefix(strings.TrimSpace(line), "|") { + // Collect all consecutive pipe-starting lines + var tableLines []string + for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|") { + tableLines = append(tableLines, lines[i]) + i++ + } + // Need at least header + separator + 1 data row to be a valid GFM table + if len(tableLines) >= 2 && isTableSeparator(tableLines[1]) { + tableBlock := buildTableBlock(tableLines) + if tableBlock != nil { + blocks = append(blocks, tableBlock) + continue + } + } + // Not a valid table — treat each line as a paragraph + for _, tl := range tableLines { + blocks = append(blocks, makeTextBlock("paragraph", tl)) + } + continue + } + // Default: paragraph blocks = append(blocks, makeTextBlock("paragraph", line)) i++ @@ -794,18 +818,257 @@ func parseMarkdownToBlocks(content string) []map[string]interface{} { return blocks } +// isTableSeparator returns true when a line looks like a GFM table separator (|---|---|). +func isTableSeparator(line string) bool { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") { + return false + } + // Strip leading/trailing '|', split cells, check each cell is only -/:/space + inner := strings.Trim(trimmed, "|") + cells := strings.Split(inner, "|") + for _, cell := range cells { + cell = strings.TrimSpace(cell) + if cell == "" { + continue + } + // Must consist of dashes and optional colons (alignment markers) + for _, ch := range cell { + if ch != '-' && ch != ':' { + return false + } + } + } + return true +} + +// splitTableRow splits a pipe-delimited table row into trimmed cell strings. +func splitTableRow(line string) []string { + trimmed := strings.TrimSpace(line) + // Strip leading/trailing '|' + trimmed = strings.Trim(trimmed, "|") + parts := strings.Split(trimmed, "|") + cells := make([]string, len(parts)) + for i, p := range parts { + cells[i] = strings.TrimSpace(p) + } + return cells +} + +// buildTableBlock converts collected GFM table lines into a Notion table block. +// tableLines[0] = header row, tableLines[1] = separator, tableLines[2:] = data rows. +func buildTableBlock(tableLines []string) map[string]interface{} { + headerCells := splitTableRow(tableLines[0]) + tableWidth := len(headerCells) + if tableWidth == 0 { + return nil + } + + var rows []map[string]interface{} + + // Header row (index 0), skip separator (index 1), then data rows + for idx, line := range tableLines { + if idx == 1 { + continue // separator — skip + } + cells := splitTableRow(line) + // Pad or trim to tableWidth + for len(cells) < tableWidth { + cells = append(cells, "") + } + cells = cells[:tableWidth] + + notionCells := make([]interface{}, tableWidth) + for j, cellText := range cells { + notionCells[j] = parseInlineFormatting(cellText) + } + rows = append(rows, map[string]interface{}{ + "object": "block", + "type": "table_row", + "table_row": map[string]interface{}{ + "cells": notionCells, + }, + }) + } + + if len(rows) == 0 { + return nil + } + + // Notion API requires table_row children INSIDE table{}, not at block top-level. + return map[string]interface{}{ + "object": "block", + "type": "table", + "table": map[string]interface{}{ + "table_width": tableWidth, + "has_column_header": true, + "has_row_header": false, + "children": rows, + }, + } +} + func makeTextBlock(blockType, text string) map[string]interface{} { return map[string]interface{}{ "object": "block", "type": blockType, blockType: map[string]interface{}{ - "rich_text": []map[string]interface{}{ - {"text": map[string]interface{}{"content": strings.TrimSpace(text)}}, - }, + "rich_text": parseInlineFormatting(strings.TrimSpace(text)), }, } } +// parseInlineFormatting converts inline markdown (bold, italic, code, link, strikethrough) +// into a Notion rich_text array. +func parseInlineFormatting(text string) []map[string]interface{} { + // token pattern: **bold**, *italic*, _italic_, `code`, ~~strike~~, [text](url) + tokenRe := regexp.MustCompile(`\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|` + "`" + `(.+?)` + "`" + `|~~(.+?)~~|\[([^\]]+)\]\(([^)]+)\)`) + + var result []map[string]interface{} + remaining := text + for len(remaining) > 0 { + loc := tokenRe.FindStringIndex(remaining) + if loc == nil { + // No more tokens — append remaining as plain text + if remaining != "" { + result = append(result, plainRichText(remaining)) + } + break + } + // Plain text before the match + if loc[0] > 0 { + result = append(result, plainRichText(remaining[:loc[0]])) + } + match := tokenRe.FindStringSubmatch(remaining[loc[0]:loc[1]]) + rt := buildAnnotatedRichText(match) + result = append(result, rt) + remaining = remaining[loc[1]:] + } + if len(result) == 0 { + return []map[string]interface{}{plainRichText(text)} + } + return result +} + +func plainRichText(text string) map[string]interface{} { + return map[string]interface{}{ + "text": map[string]interface{}{"content": text}, + } +} + +func buildAnnotatedRichText(match []string) map[string]interface{} { + // match[0] = full match + // match[1] = **bold** content + // match[2] = *italic* content + // match[3] = _italic_ content + // match[4] = `code` content + // match[5] = ~~strike~~ content + // match[6] = [text](url) text part + // match[7] = [text](url) url part + switch { + case match[1] != "": // **bold** + return map[string]interface{}{ + "text": map[string]interface{}{"content": match[1]}, + "annotations": map[string]interface{}{"bold": true}, + } + case match[2] != "": // *italic* + return map[string]interface{}{ + "text": map[string]interface{}{"content": match[2]}, + "annotations": map[string]interface{}{"italic": true}, + } + case match[3] != "": // _italic_ + return map[string]interface{}{ + "text": map[string]interface{}{"content": match[3]}, + "annotations": map[string]interface{}{"italic": true}, + } + case match[4] != "": // `code` + return map[string]interface{}{ + "text": map[string]interface{}{"content": match[4]}, + "annotations": map[string]interface{}{"code": true}, + } + case match[5] != "": // ~~strike~~ + return map[string]interface{}{ + "text": map[string]interface{}{"content": match[5]}, + "annotations": map[string]interface{}{"strikethrough": true}, + } + case match[6] != "": // [text](url) + return map[string]interface{}{ + "text": map[string]interface{}{ + "content": match[6], + "link": map[string]interface{}{"url": match[7]}, + }, + } + default: + return plainRichText(match[0]) + } +} + +// richTextToMarkdown converts a Notion rich_text cell ([]interface{} of rich_text objects) +// into a plain markdown string, applying inline annotations. +func richTextToMarkdown(cell interface{}) string { + items, ok := cell.([]interface{}) + if !ok { + // Could be []map[string]interface{} from our own buildTableBlock + if maps, ok := cell.([]map[string]interface{}); ok { + var sb strings.Builder + for _, m := range maps { + sb.WriteString(richTextItemToMarkdown(m)) + } + return sb.String() + } + return "" + } + var sb strings.Builder + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + sb.WriteString(richTextItemToMarkdown(m)) + } + return sb.String() +} + +func richTextItemToMarkdown(m map[string]interface{}) string { + textObj, _ := m["text"].(map[string]interface{}) + content, _ := textObj["content"].(string) + link, hasLink := textObj["link"].(map[string]interface{}) + + ann, _ := m["annotations"].(map[string]interface{}) + bold, _ := ann["bold"].(bool) + italic, _ := ann["italic"].(bool) + code, _ := ann["code"].(bool) + strike, _ := ann["strikethrough"].(bool) + + // plain_text fallback (from Notion API responses) + if content == "" { + content, _ = m["plain_text"].(string) + // For API responses, check href for links + if href, ok := m["href"].(string); ok && href != "" { + return fmt.Sprintf("[%s](%s)", content, href) + } + } + + result := content + if code { + return "`" + result + "`" + } + if strike { + result = "~~" + result + "~~" + } + if bold { + result = "**" + result + "**" + } + if italic { + result = "*" + result + "*" + } + if hasLink { + url, _ := link["url"].(string) + result = fmt.Sprintf("[%s](%s)", result, url) + } + return result +} + // renderBlockMarkdown outputs a block as clean Markdown. func renderBlockMarkdown(block map[string]interface{}, indent int) { blockType, _ := block["type"].(string) @@ -928,6 +1191,44 @@ func renderBlockMarkdown(block map[string]interface{}, indent int) { expr, _ := data["expression"].(string) fmt.Printf("%s$$\n%s%s\n%s$$\n\n", prefix, prefix, expr, prefix) } + case "table": + // Table is rendered by iterating its _children (table_row blocks) + // We reconstruct the GFM table including the separator after header row. + tableData, _ := block["table"].(map[string]interface{}) + hasColHeader, _ := tableData["has_column_header"].(bool) + children, _ := block["_children"].([]interface{}) + for rowIdx, child := range children { + rowBlock, ok := child.(map[string]interface{}) + if !ok { + continue + } + renderBlockMarkdown(rowBlock, indent) + // Insert GFM separator after header row + if rowIdx == 0 && hasColHeader { + width := 0 + if rowData, ok := rowBlock["table_row"].(map[string]interface{}); ok { + if cells, ok := rowData["cells"].([]interface{}); ok { + width = len(cells) + } + } + if width > 0 { + sep := prefix + "|" + strings.Repeat("---|", width) + fmt.Println(sep) + } + } + } + fmt.Println() + return // children already handled above + case "table_row": + rowData, _ := block["table_row"].(map[string]interface{}) + cells, _ := rowData["cells"].([]interface{}) + var parts []string + for _, cell := range cells { + cellText := richTextToMarkdown(cell) + parts = append(parts, cellText) + } + fmt.Printf("%s| %s |\n", prefix, strings.Join(parts, " | ")) + return case "column_list", "synced_block": // Container blocks — just render children default: diff --git a/cmd/block_test.go b/cmd/block_test.go index 3cb59a4..32b6c10 100644 --- a/cmd/block_test.go +++ b/cmd/block_test.go @@ -205,6 +205,255 @@ func TestMakeTextBlock(t *testing.T) { } } +func TestParseMarkdownTable(t *testing.T) { + tests := []struct { + name string + input string + wantCount int + checkBlock func(t *testing.T, block map[string]interface{}) + }{ + { + name: "basic GFM table", + input: "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |", + wantCount: 1, + checkBlock: func(t *testing.T, b map[string]interface{}) { + if b["type"] != "table" { + t.Errorf("type = %v, want table", b["type"]) + } + tableData, ok := b["table"].(map[string]interface{}) + if !ok { + t.Fatal("missing table data") + } + if tableData["table_width"] != 2 { + t.Errorf("table_width = %v, want 2", tableData["table_width"]) + } + if tableData["has_column_header"] != true { + t.Errorf("has_column_header should be true") + } + children, ok := tableData["children"].([]map[string]interface{}) + if !ok { + t.Fatal("missing table.children") + } + // header + 2 data rows = 3 rows (separator skipped) + if len(children) != 3 { + t.Errorf("got %d rows, want 3", len(children)) + } + // Check first row type + if children[0]["type"] != "table_row" { + t.Errorf("child type = %v, want table_row", children[0]["type"]) + } + }, + }, + { + name: "table with alignment markers", + input: "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |", + wantCount: 1, + checkBlock: func(t *testing.T, b map[string]interface{}) { + if b["type"] != "table" { + t.Errorf("type = %v, want table", b["type"]) + } + tableData := b["table"].(map[string]interface{}) + if tableData["table_width"] != 3 { + t.Errorf("table_width = %v, want 3", tableData["table_width"]) + } + children := tableData["children"].([]map[string]interface{}) + if len(children) != 2 { // header + 1 data row + t.Errorf("got %d rows, want 2", len(children)) + } + }, + }, + { + name: "table is not parsed without separator", + // No separator row → should NOT produce a table block + input: "| Name | Age |\n| Alice | 30 |", + wantCount: 2, // treated as 2 paragraphs + checkBlock: func(t *testing.T, b map[string]interface{}) { + if b["type"] == "table" { + t.Error("should not produce table without separator") + } + }, + }, + { + name: "table mixed with text", + input: "Intro\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nOutro", + wantCount: 3, // paragraph + table + paragraph + checkBlock: func(t *testing.T, b map[string]interface{}) { + if b["type"] != "paragraph" { + t.Errorf("first block type = %v, want paragraph", b["type"]) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blocks := parseMarkdownToBlocks(tt.input) + if len(blocks) != tt.wantCount { + t.Errorf("got %d blocks, want %d", len(blocks), tt.wantCount) + for i, b := range blocks { + t.Logf(" block[%d]: type=%v", i, b["type"]) + } + return + } + if tt.checkBlock != nil && len(blocks) > 0 { + tt.checkBlock(t, blocks[0]) + } + }) + } +} + +func TestParseInlineFormatting(t *testing.T) { + tests := []struct { + name string + input string + wantParts int + checkParts func(t *testing.T, parts []map[string]interface{}) + }{ + { + name: "plain text", + input: "hello world", + wantParts: 1, + checkParts: func(t *testing.T, parts []map[string]interface{}) { + text := parts[0]["text"].(map[string]interface{}) + if text["content"] != "hello world" { + t.Errorf("content = %v", text["content"]) + } + if _, hasAnn := parts[0]["annotations"]; hasAnn { + t.Error("plain text should have no annotations") + } + }, + }, + { + name: "bold", + input: "**bold text**", + wantParts: 1, + checkParts: func(t *testing.T, parts []map[string]interface{}) { + ann := parts[0]["annotations"].(map[string]interface{}) + if ann["bold"] != true { + t.Error("expected bold=true") + } + text := parts[0]["text"].(map[string]interface{}) + if text["content"] != "bold text" { + t.Errorf("content = %v, want 'bold text'", text["content"]) + } + }, + }, + { + name: "italic with asterisk", + input: "*italic*", + wantParts: 1, + checkParts: func(t *testing.T, parts []map[string]interface{}) { + ann := parts[0]["annotations"].(map[string]interface{}) + if ann["italic"] != true { + t.Error("expected italic=true") + } + }, + }, + { + name: "italic with underscore", + input: "_italic_", + wantParts: 1, + checkParts: func(t *testing.T, parts []map[string]interface{}) { + ann := parts[0]["annotations"].(map[string]interface{}) + if ann["italic"] != true { + t.Error("expected italic=true") + } + }, + }, + { + name: "inline code", + input: "`some code`", + wantParts: 1, + checkParts: func(t *testing.T, parts []map[string]interface{}) { + ann := parts[0]["annotations"].(map[string]interface{}) + if ann["code"] != true { + t.Error("expected code=true") + } + }, + }, + { + name: "strikethrough", + input: "~~deleted~~", + wantParts: 1, + checkParts: func(t *testing.T, parts []map[string]interface{}) { + ann := parts[0]["annotations"].(map[string]interface{}) + if ann["strikethrough"] != true { + t.Error("expected strikethrough=true") + } + }, + }, + { + name: "link", + input: "[Notion](https://notion.so)", + wantParts: 1, + checkParts: func(t *testing.T, parts []map[string]interface{}) { + text := parts[0]["text"].(map[string]interface{}) + if text["content"] != "Notion" { + t.Errorf("link text = %v, want 'Notion'", text["content"]) + } + link := text["link"].(map[string]interface{}) + if link["url"] != "https://notion.so" { + t.Errorf("link url = %v", link["url"]) + } + }, + }, + { + name: "mixed inline", + input: "Hello **world** and *you*", + wantParts: 4, // "Hello ", "world"(bold), " and ", "you"(italic) + }, + { + name: "bold in table cell", + input: "**Header**", + wantParts: 1, + checkParts: func(t *testing.T, parts []map[string]interface{}) { + ann := parts[0]["annotations"].(map[string]interface{}) + if ann["bold"] != true { + t.Error("expected bold=true in table cell") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parts := parseInlineFormatting(tt.input) + if len(parts) != tt.wantParts { + t.Errorf("got %d parts, want %d", len(parts), tt.wantParts) + for i, p := range parts { + t.Logf(" part[%d]: %v", i, p) + } + return + } + if tt.checkParts != nil { + tt.checkParts(t, parts) + } + }) + } +} + +func TestIsTableSeparator(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"| --- | --- |", true}, + {"|---|---|", true}, + {"| :--- | ---: | :---: |", true}, + {"| Name | Age |", false}, + {"---", false}, + {"| - |", true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := isTableSeparator(tt.input) + if got != tt.want { + t.Errorf("isTableSeparator(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + func TestMapBlockTypeAliases(t *testing.T) { tests := []struct { input string