oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package notion
2
3import (
4 "encoding/json"
5 "errors"
6 "strconv"
7 "strings"
8)
9
10func 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.
15type 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.
26func (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
38type 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.
61func 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
69func 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
158func 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.
171func 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
199func 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.
290func 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
319func 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