| 1 | package notion |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | func asErr(err error, target **Error) bool { return errors.As(err, target) } |
| 11 | |
| 12 | // block is one Notion block. The payload for a block's own type lives |
| 13 | // under a key named after the type, so it is decoded on demand from raw |
| 14 | // rather than modelled as sixty optional fields. |
| 15 | type block struct { |
| 16 | ID string `json:"id"` |
| 17 | Type string `json:"type"` |
| 18 | HasChildren bool `json:"has_children"` |
| 19 | InTrash bool `json:"in_trash"` |
| 20 | |
| 21 | raw json.RawMessage |
| 22 | children []block |
| 23 | } |
| 24 | |
| 25 | // payload decodes the type-specific body of a block. |
| 26 | func (b block) payload() blockBody { |
| 27 | var wrap map[string]json.RawMessage |
| 28 | if err := json.Unmarshal(b.raw, &wrap); err != nil { |
| 29 | return blockBody{} |
| 30 | } |
| 31 | var body blockBody |
| 32 | if v, ok := wrap[b.Type]; ok { |
| 33 | _ = json.Unmarshal(v, &body) |
| 34 | } |
| 35 | return body |
| 36 | } |
| 37 | |
| 38 | type blockBody struct { |
| 39 | RichText []richText `json:"rich_text"` |
| 40 | Caption []richText `json:"caption"` |
| 41 | Title string `json:"title"` |
| 42 | Language string `json:"language"` |
| 43 | Checked bool `json:"checked"` |
| 44 | URL string `json:"url"` |
| 45 | Name string `json:"name"` |
| 46 | External struct { |
| 47 | URL string `json:"url"` |
| 48 | } `json:"external"` |
| 49 | File struct { |
| 50 | URL string `json:"url"` |
| 51 | } `json:"file"` |
| 52 | Cells [][]richText `json:"cells"` |
| 53 | } |
| 54 | |
| 55 | // render turns a page's blocks into Markdown, collecting the child pages |
| 56 | // it must not inline. |
| 57 | // |
| 58 | // childPath is the ancestry a nested page inherits: this page's own path |
| 59 | // plus this page's title. Traversal knows it because it arrived here from |
| 60 | // the parent, which is why nothing walks parent links upward. |
| 61 | func render(blocks []block, childPath []string) (string, []Ref, int) { |
| 62 | var b strings.Builder |
| 63 | var kids []Ref |
| 64 | unread := 0 |
| 65 | renderInto(&b, blocks, childPath, &kids, &unread, 0) |
| 66 | return strings.TrimSpace(b.String()), kids, unread |
| 67 | } |
| 68 | |
| 69 | func renderInto(b *strings.Builder, blocks []block, childPath []string, kids *[]Ref, unread *int, depth int) { |
| 70 | numbered := 0 |
| 71 | for _, blk := range blocks { |
| 72 | if blk.InTrash { |
| 73 | continue |
| 74 | } |
| 75 | p := blk.payload() |
| 76 | text := plain(p.RichText) |
| 77 | indent := strings.Repeat(" ", depth) |
| 78 | |
| 79 | if blk.Type != "numbered_list_item" { |
| 80 | numbered = 0 |
| 81 | } |
| 82 | |
| 83 | switch blk.Type { |
| 84 | case "paragraph": |
| 85 | writeLine(b, indent+text) |
| 86 | case "heading_1": |
| 87 | writeLine(b, "# "+text) |
| 88 | case "heading_2": |
| 89 | writeLine(b, "## "+text) |
| 90 | case "heading_3": |
| 91 | writeLine(b, "### "+text) |
| 92 | case "bulleted_list_item", "toggle": |
| 93 | writeLine(b, indent+"- "+text) |
| 94 | case "numbered_list_item": |
| 95 | numbered++ |
| 96 | writeLine(b, indent+strconv.Itoa(numbered)+". "+text) |
| 97 | case "to_do": |
| 98 | mark := " " |
| 99 | if p.Checked { |
| 100 | mark = "x" |
| 101 | } |
| 102 | writeLine(b, indent+"- ["+mark+"] "+text) |
| 103 | case "quote": |
| 104 | writeLine(b, indent+"> "+text) |
| 105 | case "callout": |
| 106 | writeLine(b, indent+"> "+text) |
| 107 | case "code": |
| 108 | writeLine(b, "```"+p.Language) |
| 109 | writeLine(b, text) |
| 110 | writeLine(b, "```") |
| 111 | case "divider": |
| 112 | writeLine(b, "---") |
| 113 | case "table_row": |
| 114 | var cells []string |
| 115 | for _, c := range p.Cells { |
| 116 | cells = append(cells, plain(c)) |
| 117 | } |
| 118 | writeLine(b, "| "+strings.Join(cells, " | ")+" |") |
| 119 | case "table", "column_list", "column", "synced_block": |
| 120 | // Containers with no text of their own. Their children carry |
| 121 | // the content and are rendered below. |
| 122 | case "image", "file", "video", "pdf", "audio", "meeting_notes", "embed", "bookmark": |
| 123 | // memoria indexes text. A caption or filename is text; the |
| 124 | // asset is not, and its signed URL expires anyway. |
| 125 | if label := firstNonEmpty(plain(p.Caption), p.Name, p.Title); label != "" { |
| 126 | writeLine(b, indent+label) |
| 127 | } |
| 128 | case "child_page", "child_database": |
| 129 | // NOT inlined: its own document. Inlining would duplicate |
| 130 | // every passage into all of its ancestors. |
| 131 | title := firstNonEmpty(p.Title, text, "Untitled") |
| 132 | ref := Ref{ID: blk.ID, Title: title, Path: childPath, Kind: KindPage} |
| 133 | if blk.Type == "child_database" { |
| 134 | ref.Kind, ref.DataSourceID = KindDataSource, blk.ID |
| 135 | } |
| 136 | *kids = append(*kids, ref) |
| 137 | writeLine(b, indent+"- "+title) |
| 138 | default: |
| 139 | // Notion adds block types. Failing the run on an unknown one |
| 140 | // would break memoria on Notion's release schedule; silence |
| 141 | // would make a page look thin for no stated reason. Count it. |
| 142 | *unread++ |
| 143 | if text != "" { |
| 144 | writeLine(b, indent+text) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | if len(blk.children) > 0 { |
| 149 | nested := depth + 1 |
| 150 | if blk.Type == "table" || blk.Type == "column_list" || blk.Type == "column" || blk.Type == "synced_block" { |
| 151 | nested = depth |
| 152 | } |
| 153 | renderInto(b, blk.children, childPath, kids, unread, nested) |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func writeLine(b *strings.Builder, s string) { |
| 159 | if strings.TrimSpace(s) == "" { |
| 160 | return |
| 161 | } |
| 162 | b.WriteString(s) |
| 163 | b.WriteString("\n\n") |
| 164 | } |
| 165 | |
| 166 | // Properties renders a database row's properties as a leading key/value |
| 167 | // block, so a row carries its own columns into the passage that cites it. |
| 168 | // |
| 169 | // A row page's body alone is usually empty — the information is in the |
| 170 | // columns — so without this a 40-row database becomes 40 blank documents. |
| 171 | func Properties(raw json.RawMessage) string { |
| 172 | if len(raw) == 0 { |
| 173 | return "" |
| 174 | } |
| 175 | var props map[string]json.RawMessage |
| 176 | if err := json.Unmarshal(raw, &props); err != nil { |
| 177 | return "" |
| 178 | } |
| 179 | names := make([]string, 0, len(props)) |
| 180 | for k := range props { |
| 181 | names = append(names, k) |
| 182 | } |
| 183 | sortStrings(names) |
| 184 | |
| 185 | var b strings.Builder |
| 186 | for _, name := range names { |
| 187 | v := renderProperty(props[name]) |
| 188 | if v == "" { |
| 189 | continue |
| 190 | } |
| 191 | b.WriteString(name) |
| 192 | b.WriteString(": ") |
| 193 | b.WriteString(v) |
| 194 | b.WriteString("\n") |
| 195 | } |
| 196 | return strings.TrimSpace(b.String()) |
| 197 | } |
| 198 | |
| 199 | func renderProperty(raw json.RawMessage) string { |
| 200 | var p struct { |
| 201 | Type string `json:"type"` |
| 202 | Title []richText `json:"title"` |
| 203 | RichText []richText `json:"rich_text"` |
| 204 | Number *float64 `json:"number"` |
| 205 | Checkbox *bool `json:"checkbox"` |
| 206 | URL string `json:"url"` |
| 207 | Email string `json:"email"` |
| 208 | Phone string `json:"phone_number"` |
| 209 | Select *struct { |
| 210 | Name string `json:"name"` |
| 211 | } `json:"select"` |
| 212 | MultiSelect []struct { |
| 213 | Name string `json:"name"` |
| 214 | } `json:"multi_select"` |
| 215 | Status *struct { |
| 216 | Name string `json:"name"` |
| 217 | } `json:"status"` |
| 218 | Date *struct { |
| 219 | Start string `json:"start"` |
| 220 | End string `json:"end"` |
| 221 | } `json:"date"` |
| 222 | People []struct { |
| 223 | Name string `json:"name"` |
| 224 | } `json:"people"` |
| 225 | Formula json.RawMessage `json:"formula"` |
| 226 | Rollup json.RawMessage `json:"rollup"` |
| 227 | } |
| 228 | if err := json.Unmarshal(raw, &p); err != nil { |
| 229 | return "" |
| 230 | } |
| 231 | switch p.Type { |
| 232 | case "title": |
| 233 | return plain(p.Title) |
| 234 | case "rich_text": |
| 235 | return plain(p.RichText) |
| 236 | case "number": |
| 237 | if p.Number == nil { |
| 238 | return "" |
| 239 | } |
| 240 | return strconv.FormatFloat(*p.Number, 'f', -1, 64) |
| 241 | case "checkbox": |
| 242 | if p.Checkbox != nil && *p.Checkbox { |
| 243 | return "yes" |
| 244 | } |
| 245 | return "no" |
| 246 | case "url": |
| 247 | return p.URL |
| 248 | case "email": |
| 249 | return p.Email |
| 250 | case "phone_number": |
| 251 | return p.Phone |
| 252 | case "select": |
| 253 | if p.Select == nil { |
| 254 | return "" |
| 255 | } |
| 256 | return p.Select.Name |
| 257 | case "status": |
| 258 | if p.Status == nil { |
| 259 | return "" |
| 260 | } |
| 261 | return p.Status.Name |
| 262 | case "multi_select": |
| 263 | var names []string |
| 264 | for _, s := range p.MultiSelect { |
| 265 | names = append(names, s.Name) |
| 266 | } |
| 267 | return strings.Join(names, ", ") |
| 268 | case "date": |
| 269 | if p.Date == nil { |
| 270 | return "" |
| 271 | } |
| 272 | if p.Date.End != "" { |
| 273 | return p.Date.Start + " – " + p.Date.End |
| 274 | } |
| 275 | return p.Date.Start |
| 276 | case "people": |
| 277 | var names []string |
| 278 | for _, s := range p.People { |
| 279 | names = append(names, s.Name) |
| 280 | } |
| 281 | return strings.Join(names, ", ") |
| 282 | case "formula", "rollup": |
| 283 | return scalarOf(p.Formula) + scalarOf(p.Rollup) |
| 284 | } |
| 285 | return "" |
| 286 | } |
| 287 | |
| 288 | // scalarOf pulls a displayable value out of a formula or rollup result, |
| 289 | // which nests its answer under its own result type. |
| 290 | func scalarOf(raw json.RawMessage) string { |
| 291 | if len(raw) == 0 { |
| 292 | return "" |
| 293 | } |
| 294 | var v struct { |
| 295 | Type string `json:"type"` |
| 296 | String string `json:"string"` |
| 297 | Number *float64 `json:"number"` |
| 298 | Date *struct { |
| 299 | Start string `json:"start"` |
| 300 | } `json:"date"` |
| 301 | Boolean *bool `json:"boolean"` |
| 302 | } |
| 303 | if err := json.Unmarshal(raw, &v); err != nil { |
| 304 | return "" |
| 305 | } |
| 306 | switch { |
| 307 | case v.String != "": |
| 308 | return v.String |
| 309 | case v.Number != nil: |
| 310 | return strconv.FormatFloat(*v.Number, 'f', -1, 64) |
| 311 | case v.Date != nil: |
| 312 | return v.Date.Start |
| 313 | case v.Boolean != nil && *v.Boolean: |
| 314 | return "yes" |
| 315 | } |
| 316 | return "" |
| 317 | } |
| 318 | |
| 319 | func sortStrings(s []string) { |
| 320 | for i := 1; i < len(s); i++ { |
| 321 | for j := i; j > 0 && s[j] < s[j-1]; j-- { |
| 322 | s[j], s[j-1] = s[j-1], s[j] |
| 323 | } |
| 324 | } |
| 325 | } |
| 326 | |