Skip to content

Commit f043629

Browse files
authored
Merge pull request #10 from ahnbu/main
feat(block): add GFM table and inline formatting support
2 parents c30978b + 9e8b807 commit f043629

4 files changed

Lines changed: 563 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Binary
22
notion
3+
notion.exe
34

45
# OS
56
.DS_Store

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# CHANGELOG
2+
3+
모든 Git 커밋 이력을 최신순으로 기록합니다. 새 커밋은 표 최상단에 추가합니다.
4+
5+
| 일시 | 유형 | 범위 | 변경내용 (목적 포함) |
6+
|---|---|---|---|
7+
| 2026-03-14 21:00 | chore | gitignore | notion.exe 바이너리 gitignore 추가 — Windows 빌드 결과물 추적 방지 |
8+
| 2026-03-14 20:10 | fix | block | table_row children을 table{} 내부로 이동 — Notion API 스펙 준수 ('table.children should be defined' 오류 수정) |
9+
| 2026-03-14 19:52 | feat | block | GFM 테이블 파싱 + 인라인 서식(bold/italic/code/link/strike) 지원 추가 — 노션 CLI로 마크다운 표 업로드 시 깨지던 문제 근본 해결 |

cmd/block.go

Lines changed: 304 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"regexp"
78
"strings"
89

910
"github.com/4ier/notion-cli/internal/client"
@@ -786,6 +787,29 @@ func parseMarkdownToBlocks(content string) []map[string]interface{} {
786787
continue
787788
}
788789

790+
// GFM Table: starts with '|'
791+
if strings.HasPrefix(strings.TrimSpace(line), "|") {
792+
// Collect all consecutive pipe-starting lines
793+
var tableLines []string
794+
for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|") {
795+
tableLines = append(tableLines, lines[i])
796+
i++
797+
}
798+
// Need at least header + separator + 1 data row to be a valid GFM table
799+
if len(tableLines) >= 2 && isTableSeparator(tableLines[1]) {
800+
tableBlock := buildTableBlock(tableLines)
801+
if tableBlock != nil {
802+
blocks = append(blocks, tableBlock)
803+
continue
804+
}
805+
}
806+
// Not a valid table — treat each line as a paragraph
807+
for _, tl := range tableLines {
808+
blocks = append(blocks, makeTextBlock("paragraph", tl))
809+
}
810+
continue
811+
}
812+
789813
// Default: paragraph
790814
blocks = append(blocks, makeTextBlock("paragraph", line))
791815
i++
@@ -794,18 +818,257 @@ func parseMarkdownToBlocks(content string) []map[string]interface{} {
794818
return blocks
795819
}
796820

821+
// isTableSeparator returns true when a line looks like a GFM table separator (|---|---|).
822+
func isTableSeparator(line string) bool {
823+
trimmed := strings.TrimSpace(line)
824+
if !strings.HasPrefix(trimmed, "|") {
825+
return false
826+
}
827+
// Strip leading/trailing '|', split cells, check each cell is only -/:/space
828+
inner := strings.Trim(trimmed, "|")
829+
cells := strings.Split(inner, "|")
830+
for _, cell := range cells {
831+
cell = strings.TrimSpace(cell)
832+
if cell == "" {
833+
continue
834+
}
835+
// Must consist of dashes and optional colons (alignment markers)
836+
for _, ch := range cell {
837+
if ch != '-' && ch != ':' {
838+
return false
839+
}
840+
}
841+
}
842+
return true
843+
}
844+
845+
// splitTableRow splits a pipe-delimited table row into trimmed cell strings.
846+
func splitTableRow(line string) []string {
847+
trimmed := strings.TrimSpace(line)
848+
// Strip leading/trailing '|'
849+
trimmed = strings.Trim(trimmed, "|")
850+
parts := strings.Split(trimmed, "|")
851+
cells := make([]string, len(parts))
852+
for i, p := range parts {
853+
cells[i] = strings.TrimSpace(p)
854+
}
855+
return cells
856+
}
857+
858+
// buildTableBlock converts collected GFM table lines into a Notion table block.
859+
// tableLines[0] = header row, tableLines[1] = separator, tableLines[2:] = data rows.
860+
func buildTableBlock(tableLines []string) map[string]interface{} {
861+
headerCells := splitTableRow(tableLines[0])
862+
tableWidth := len(headerCells)
863+
if tableWidth == 0 {
864+
return nil
865+
}
866+
867+
var rows []map[string]interface{}
868+
869+
// Header row (index 0), skip separator (index 1), then data rows
870+
for idx, line := range tableLines {
871+
if idx == 1 {
872+
continue // separator — skip
873+
}
874+
cells := splitTableRow(line)
875+
// Pad or trim to tableWidth
876+
for len(cells) < tableWidth {
877+
cells = append(cells, "")
878+
}
879+
cells = cells[:tableWidth]
880+
881+
notionCells := make([]interface{}, tableWidth)
882+
for j, cellText := range cells {
883+
notionCells[j] = parseInlineFormatting(cellText)
884+
}
885+
rows = append(rows, map[string]interface{}{
886+
"object": "block",
887+
"type": "table_row",
888+
"table_row": map[string]interface{}{
889+
"cells": notionCells,
890+
},
891+
})
892+
}
893+
894+
if len(rows) == 0 {
895+
return nil
896+
}
897+
898+
// Notion API requires table_row children INSIDE table{}, not at block top-level.
899+
return map[string]interface{}{
900+
"object": "block",
901+
"type": "table",
902+
"table": map[string]interface{}{
903+
"table_width": tableWidth,
904+
"has_column_header": true,
905+
"has_row_header": false,
906+
"children": rows,
907+
},
908+
}
909+
}
910+
797911
func makeTextBlock(blockType, text string) map[string]interface{} {
798912
return map[string]interface{}{
799913
"object": "block",
800914
"type": blockType,
801915
blockType: map[string]interface{}{
802-
"rich_text": []map[string]interface{}{
803-
{"text": map[string]interface{}{"content": strings.TrimSpace(text)}},
804-
},
916+
"rich_text": parseInlineFormatting(strings.TrimSpace(text)),
805917
},
806918
}
807919
}
808920

921+
// parseInlineFormatting converts inline markdown (bold, italic, code, link, strikethrough)
922+
// into a Notion rich_text array.
923+
func parseInlineFormatting(text string) []map[string]interface{} {
924+
// token pattern: **bold**, *italic*, _italic_, `code`, ~~strike~~, [text](url)
925+
tokenRe := regexp.MustCompile(`\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|` + "`" + `(.+?)` + "`" + `|~~(.+?)~~|\[([^\]]+)\]\(([^)]+)\)`)
926+
927+
var result []map[string]interface{}
928+
remaining := text
929+
for len(remaining) > 0 {
930+
loc := tokenRe.FindStringIndex(remaining)
931+
if loc == nil {
932+
// No more tokens — append remaining as plain text
933+
if remaining != "" {
934+
result = append(result, plainRichText(remaining))
935+
}
936+
break
937+
}
938+
// Plain text before the match
939+
if loc[0] > 0 {
940+
result = append(result, plainRichText(remaining[:loc[0]]))
941+
}
942+
match := tokenRe.FindStringSubmatch(remaining[loc[0]:loc[1]])
943+
rt := buildAnnotatedRichText(match)
944+
result = append(result, rt)
945+
remaining = remaining[loc[1]:]
946+
}
947+
if len(result) == 0 {
948+
return []map[string]interface{}{plainRichText(text)}
949+
}
950+
return result
951+
}
952+
953+
func plainRichText(text string) map[string]interface{} {
954+
return map[string]interface{}{
955+
"text": map[string]interface{}{"content": text},
956+
}
957+
}
958+
959+
func buildAnnotatedRichText(match []string) map[string]interface{} {
960+
// match[0] = full match
961+
// match[1] = **bold** content
962+
// match[2] = *italic* content
963+
// match[3] = _italic_ content
964+
// match[4] = `code` content
965+
// match[5] = ~~strike~~ content
966+
// match[6] = [text](url) text part
967+
// match[7] = [text](url) url part
968+
switch {
969+
case match[1] != "": // **bold**
970+
return map[string]interface{}{
971+
"text": map[string]interface{}{"content": match[1]},
972+
"annotations": map[string]interface{}{"bold": true},
973+
}
974+
case match[2] != "": // *italic*
975+
return map[string]interface{}{
976+
"text": map[string]interface{}{"content": match[2]},
977+
"annotations": map[string]interface{}{"italic": true},
978+
}
979+
case match[3] != "": // _italic_
980+
return map[string]interface{}{
981+
"text": map[string]interface{}{"content": match[3]},
982+
"annotations": map[string]interface{}{"italic": true},
983+
}
984+
case match[4] != "": // `code`
985+
return map[string]interface{}{
986+
"text": map[string]interface{}{"content": match[4]},
987+
"annotations": map[string]interface{}{"code": true},
988+
}
989+
case match[5] != "": // ~~strike~~
990+
return map[string]interface{}{
991+
"text": map[string]interface{}{"content": match[5]},
992+
"annotations": map[string]interface{}{"strikethrough": true},
993+
}
994+
case match[6] != "": // [text](url)
995+
return map[string]interface{}{
996+
"text": map[string]interface{}{
997+
"content": match[6],
998+
"link": map[string]interface{}{"url": match[7]},
999+
},
1000+
}
1001+
default:
1002+
return plainRichText(match[0])
1003+
}
1004+
}
1005+
1006+
// richTextToMarkdown converts a Notion rich_text cell ([]interface{} of rich_text objects)
1007+
// into a plain markdown string, applying inline annotations.
1008+
func richTextToMarkdown(cell interface{}) string {
1009+
items, ok := cell.([]interface{})
1010+
if !ok {
1011+
// Could be []map[string]interface{} from our own buildTableBlock
1012+
if maps, ok := cell.([]map[string]interface{}); ok {
1013+
var sb strings.Builder
1014+
for _, m := range maps {
1015+
sb.WriteString(richTextItemToMarkdown(m))
1016+
}
1017+
return sb.String()
1018+
}
1019+
return ""
1020+
}
1021+
var sb strings.Builder
1022+
for _, item := range items {
1023+
m, ok := item.(map[string]interface{})
1024+
if !ok {
1025+
continue
1026+
}
1027+
sb.WriteString(richTextItemToMarkdown(m))
1028+
}
1029+
return sb.String()
1030+
}
1031+
1032+
func richTextItemToMarkdown(m map[string]interface{}) string {
1033+
textObj, _ := m["text"].(map[string]interface{})
1034+
content, _ := textObj["content"].(string)
1035+
link, hasLink := textObj["link"].(map[string]interface{})
1036+
1037+
ann, _ := m["annotations"].(map[string]interface{})
1038+
bold, _ := ann["bold"].(bool)
1039+
italic, _ := ann["italic"].(bool)
1040+
code, _ := ann["code"].(bool)
1041+
strike, _ := ann["strikethrough"].(bool)
1042+
1043+
// plain_text fallback (from Notion API responses)
1044+
if content == "" {
1045+
content, _ = m["plain_text"].(string)
1046+
// For API responses, check href for links
1047+
if href, ok := m["href"].(string); ok && href != "" {
1048+
return fmt.Sprintf("[%s](%s)", content, href)
1049+
}
1050+
}
1051+
1052+
result := content
1053+
if code {
1054+
return "`" + result + "`"
1055+
}
1056+
if strike {
1057+
result = "~~" + result + "~~"
1058+
}
1059+
if bold {
1060+
result = "**" + result + "**"
1061+
}
1062+
if italic {
1063+
result = "*" + result + "*"
1064+
}
1065+
if hasLink {
1066+
url, _ := link["url"].(string)
1067+
result = fmt.Sprintf("[%s](%s)", result, url)
1068+
}
1069+
return result
1070+
}
1071+
8091072
// renderBlockMarkdown outputs a block as clean Markdown.
8101073
func renderBlockMarkdown(block map[string]interface{}, indent int) {
8111074
blockType, _ := block["type"].(string)
@@ -928,6 +1191,44 @@ func renderBlockMarkdown(block map[string]interface{}, indent int) {
9281191
expr, _ := data["expression"].(string)
9291192
fmt.Printf("%s$$\n%s%s\n%s$$\n\n", prefix, prefix, expr, prefix)
9301193
}
1194+
case "table":
1195+
// Table is rendered by iterating its _children (table_row blocks)
1196+
// We reconstruct the GFM table including the separator after header row.
1197+
tableData, _ := block["table"].(map[string]interface{})
1198+
hasColHeader, _ := tableData["has_column_header"].(bool)
1199+
children, _ := block["_children"].([]interface{})
1200+
for rowIdx, child := range children {
1201+
rowBlock, ok := child.(map[string]interface{})
1202+
if !ok {
1203+
continue
1204+
}
1205+
renderBlockMarkdown(rowBlock, indent)
1206+
// Insert GFM separator after header row
1207+
if rowIdx == 0 && hasColHeader {
1208+
width := 0
1209+
if rowData, ok := rowBlock["table_row"].(map[string]interface{}); ok {
1210+
if cells, ok := rowData["cells"].([]interface{}); ok {
1211+
width = len(cells)
1212+
}
1213+
}
1214+
if width > 0 {
1215+
sep := prefix + "|" + strings.Repeat("---|", width)
1216+
fmt.Println(sep)
1217+
}
1218+
}
1219+
}
1220+
fmt.Println()
1221+
return // children already handled above
1222+
case "table_row":
1223+
rowData, _ := block["table_row"].(map[string]interface{})
1224+
cells, _ := rowData["cells"].([]interface{})
1225+
var parts []string
1226+
for _, cell := range cells {
1227+
cellText := richTextToMarkdown(cell)
1228+
parts = append(parts, cellText)
1229+
}
1230+
fmt.Printf("%s| %s |\n", prefix, strings.Join(parts, " | "))
1231+
return
9311232
case "column_list", "synced_block":
9321233
// Container blocks — just render children
9331234
default:

0 commit comments

Comments
 (0)