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+
797911func 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.
8101073func 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