├── doit.sh ├── doit.bat ├── do ├── do.sh ├── preview.html.tmpl.html ├── .vscode │ └── launch.json ├── wc.go ├── do.bat ├── go.mod ├── util.go ├── to_html.go ├── preview.md.tmpl.html ├── to_md.go ├── test_page_marshal.go ├── notion_util.go ├── make_full_html.go ├── smoke.go ├── test_html_known_bad.go ├── sanity.go ├── main.css ├── handlers.go ├── test_to_html.go ├── test_to_md.go ├── tests_adhoc.go └── main.go ├── go.work ├── tracenotion ├── package.json └── trace.js ├── .idea ├── .gitignore ├── misc.xml ├── vcs.xml ├── modules.xml ├── notionapi.iml └── watcherTasks.xml ├── .gitignore ├── go.mod ├── discussion.go ├── all_test.go ├── .vscode └── launch.json ├── comment.go ├── .github └── workflows │ └── go.yml ├── api_createEmailUser.go ├── debug.go ├── tohtml ├── html_test.go └── css_notion.go ├── dump_structure.go ├── client_test.go ├── tomarkdown └── markdown_test.go ├── LICENSE ├── notes.md ├── api_loadUserContent.go ├── api_getSignedFileUrls.go ├── README.md ├── json.go ├── api_getActivityLog.go ├── space.go ├── constants.go ├── activity.go ├── api_syncRecordValues.go ├── caching_client_test.go ├── user.go ├── go.sum ├── api_queryCollection.go ├── api_syncRecordValues_test.go ├── api_getUploadFileUrl_test.go ├── record.go ├── date.go ├── api_getSubscriptionData.go ├── export_page.go ├── download_file.go ├── submit_transaction.go ├── api_loadCachedPageChunk.go ├── util.go ├── inline_block.go ├── api_getUploadFileUrl.go ├── inline_block_test.go ├── page.go ├── collection.go └── api_loadCachedPageChunk_test.go /doit.sh: -------------------------------------------------------------------------------- 1 | cd do 2 | go run . $@ 3 | -------------------------------------------------------------------------------- /doit.bat: -------------------------------------------------------------------------------- 1 | @cd do 2 | go run . %* 3 | -------------------------------------------------------------------------------- /do/do.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd do 4 | go run . $@ 5 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.21 2 | 3 | toolchain go1.22.2 4 | 5 | use ( 6 | . 7 | ./do 8 | ) 9 | -------------------------------------------------------------------------------- /tracenotion/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "puppeteer": "^1.20.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml 3 | # Default ignored files 4 | /workspace.xml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmpdata/ 2 | www/ 3 | exp.* 4 | got.* 5 | *.exe 6 | .DS_Store 7 | do/__debug_bin 8 | node_modules/ 9 | yarn.lock 10 | notion_api_trace.txt 11 | cached_notion/ 12 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /do/preview.html.tmpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | {{ .HTML }} 14 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/notionapi.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kjk/notionapi 2 | 3 | require ( 4 | github.com/google/uuid v1.6.0 5 | github.com/json-iterator/go v1.1.12 6 | github.com/kjk/common v0.0.0-20211010101831-6203abf05163 7 | github.com/kjk/siser v0.0.0-20220410204903-1b1e84ea1397 8 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 9 | github.com/tidwall/pretty v1.2.1 10 | ) 11 | 12 | go 1.21 13 | -------------------------------------------------------------------------------- /discussion.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | // Discussion represents a discussion 4 | type Discussion struct { 5 | ID string `json:"id"` 6 | Version int64 `json:"version"` 7 | ParentID string `json:"parent_id"` 8 | ParentTable string `json:"parent_table"` 9 | Resolved bool `json:"resolved"` 10 | Comments []string `json:"comments"` 11 | // set by us 12 | RawJSON map[string]interface{} `json:"-"` 13 | } 14 | -------------------------------------------------------------------------------- /all_test.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import "testing" 4 | 5 | func TestNormalizeID(t *testing.T) { 6 | var tests = []struct { 7 | s string 8 | exp string 9 | }{ 10 | {"2131b10cebf64938a1277089ff02dbe4", "2131b10c-ebf6-4938-a127-7089ff02dbe4"}, 11 | {"2131b10c-ebf6-4938-a127-7089ff02dbe4", "2131b10c-ebf6-4938-a127-7089ff02dbe4"}, 12 | {"2131b", "2131b"}, 13 | } 14 | for _, test := range tests { 15 | got := ToDashID(test.s) 16 | if got != test.exp { 17 | t.Errorf("s: %s got: %s, expected: %s\n", test.s, got, test.exp) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "name": "test-to-html", 10 | "type": "go", 11 | "request": "launch", 12 | "mode": "auto", 13 | "program": "${fileDirname}", 14 | "env": {}, 15 | "args": ["-to-html", "6682351e44bb4f9ca0e149b703265bdb"] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /do/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "adhoc", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}", 13 | "env": {}, 14 | "args": ["-to-html", "94167af6567043279811dc923edd1f04", "-verbose"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /do/wc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kjk/u" 7 | ) 8 | 9 | var srcFiles = u.MakeAllowedFileFilterForExts(".go", ".js", ".html", ".css") 10 | var excludeDirs = u.MakeExcludeDirsFilter("node_modules", "tmpdata") 11 | var allFiles = u.MakeFilterAnd(srcFiles, excludeDirs) 12 | 13 | func doLineCount() int { 14 | stats := u.NewLineStats() 15 | err := stats.CalcInDir(".", allFiles, true) 16 | if err != nil { 17 | fmt.Printf("doLineCount: stats.wcInDir() failed with '%s'\n", err) 18 | return 1 19 | } 20 | u.PrintLineStats(stats) 21 | return 0 22 | } 23 | -------------------------------------------------------------------------------- /do/do.bat: -------------------------------------------------------------------------------- 1 | @cd do 2 | @go run . %* 3 | 4 | @rem notable pages: 5 | @rem 0367c2db381a4f8b9ce360f388a6b2e3 : my test pages 6 | @rem d6eb49cfc68f402881af3aef391443e6 : Pokedex 7 | @rem 3b617da409454a52bc3a920ba8832bf7 : Blendle's Employee Handbook 8 | 9 | @rem notable flags 10 | @rem -test-to-html ${pageID} : compares html rendering of page with Notion 11 | @rem -test-to-md ${pageID} : c 12 | @rem -to-html ${pageID} : downloads page, converts to html and 13 | @rem -re-export : for -test-to-html and -test-to-md, re-export latest version from Notion 14 | @rem -no-cache : disables download cache 15 | -------------------------------------------------------------------------------- /comment.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | // Comment describes a single comment in a discussion 4 | type Comment struct { 5 | ID string `json:"id"` 6 | Version int64 `json:"version"` 7 | Alive bool `json:"alive"` 8 | ParentID string `json:"parent_id"` 9 | ParentTable string `json:"parent_table"` 10 | CreatedBy string `json:"created_by"` 11 | CreatedTime int64 `json:"created_time"` 12 | Text interface{} `json:"text"` 13 | LastEditedTime int64 `json:"last_edited_time"` 14 | 15 | // set by us 16 | RawJSON map[string]interface{} `json:"-"` 17 | } 18 | -------------------------------------------------------------------------------- /do/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kjk/notionapi/do 2 | 3 | require ( 4 | github.com/dustin/go-humanize v1.0.1 // indirect 5 | github.com/kjk/atomicfile v0.0.0-20220410204726-989ae30d2b66 // indirect 6 | github.com/kjk/fmthtml v0.0.0-20190816041536-39f5e479d32d 7 | github.com/kjk/notionapi v0.0.0-20240430144736-4d4b71e77a72 8 | github.com/kjk/u v0.0.0-20220410204605-ce4a95db4475 9 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 10 | github.com/minio/md5-simd v1.1.2 // indirect 11 | github.com/minio/sha256-simd v1.0.1 // indirect 12 | golang.org/x/net v0.24.0 // indirect 13 | gopkg.in/ini.v1 v1.67.0 // indirect 14 | ) 15 | 16 | replace github.com/kjk/notionapi => ./.. 17 | 18 | go 1.21 19 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Set up Go 9 | uses: actions/setup-go@v4 10 | with: 11 | go-version: "1.21" 12 | 13 | - name: Check out source code 14 | uses: actions/checkout@v4 15 | 16 | - name: Test 17 | run: go test -v ./... 18 | 19 | - name: Smoke test 20 | run: ./do/do.sh -smoke 21 | 22 | - name: Staticcheck 23 | run: | 24 | # need to disable because staticcheck doesn't work on go 1.16 25 | # and GitHub Actions still has 1.16 26 | # go install honnef.co/go/tools/cmd/staticcheck@latest 27 | # staticcheck ./... || true 28 | -------------------------------------------------------------------------------- /api_createEmailUser.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | // CreateEmailUser invites a new user through his email address 8 | func (c *Client) CreateEmailUser(email string) (*NotionUser, error) { 9 | req := struct { 10 | Email string `json:"email"` 11 | }{ 12 | Email: email, 13 | } 14 | 15 | var rsp struct { 16 | UserID string `json:"userId"` 17 | RecordMap *RecordMap `json:"recordMap"` 18 | } 19 | 20 | apiURL := "/api/v3/createEmailUser" 21 | err := c.doNotionAPI(apiURL, req, &rsp, nil) 22 | 23 | recordMap := rsp.RecordMap 24 | ParseRecordMap(recordMap) 25 | users, ok := recordMap.NotionUsers[rsp.UserID] 26 | if !ok { 27 | return nil, errors.New("error inviting user") 28 | } 29 | 30 | return users.NotionUser, err 31 | } 32 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import "fmt" 4 | 5 | var ( 6 | // PanicOnFailures will force panics on unexpected situations. 7 | // This is for debugging 8 | PanicOnFailures bool 9 | 10 | // TODO: maybe a logger io.Writer instead? 11 | // LogFunc allows intercepting debug logs 12 | LogFunc func(format string, args ...interface{}) 13 | ) 14 | 15 | // Logf is for debug logging, will log using LogFunc (if set) 16 | func Logf(format string, args ...interface{}) { 17 | if LogFunc != nil { 18 | LogFunc(format, args...) 19 | } 20 | } 21 | 22 | // MaybePanic will panic if PanicOnFailures is true 23 | func MaybePanic(format string, args ...interface{}) { 24 | if LogFunc != nil { 25 | LogFunc(format, args...) 26 | } 27 | if PanicOnFailures { 28 | panic(fmt.Sprintf(format, args...)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tohtml/html_test.go: -------------------------------------------------------------------------------- 1 | package tohtml 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kjk/common/assert" 7 | ) 8 | 9 | func TestHTMLFileNameForPage(t *testing.T) { 10 | tests := [][]string{ 11 | {"Blendle's Employee Handbook", "Blendle s Employee Handbook.html"}, 12 | } 13 | for _, test := range tests { 14 | got := htmlFileName(test[0]) 15 | assert.Equal(t, test[1], got) 16 | } 17 | } 18 | 19 | func TestFmtNumberWithCommas(t *testing.T) { 20 | tests := []string{ 21 | "1345.48", "1,345.48", 22 | "", "", 23 | "0", "0", 24 | ".32", ".32", 25 | "345", "345", 26 | "3.12", "3.12", 27 | "3467893.2213", "3,467,893.2213", 28 | } 29 | n := len(tests) / 2 30 | for i := 0; i < n; i++ { 31 | s := tests[i*2] 32 | got := fmtNumberWithCommas(s) 33 | exp := tests[i*2+1] 34 | assert.Equal(t, exp, got) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /do/util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "os/exec" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/kjk/u" 11 | ) 12 | 13 | var ( 14 | must = u.Must 15 | logf = u.Logf 16 | panicIf = u.PanicIf 17 | openBrowser = u.OpenBrowser 18 | ) 19 | 20 | func recreateDir(dir string) { 21 | _ = os.RemoveAll(dir) 22 | err := os.MkdirAll(dir, 0755) 23 | must(err) 24 | } 25 | 26 | func openNotepadWithFile(path string) { 27 | cmd := exec.Command("notepad.exe", path) 28 | err := cmd.Start() 29 | must(err) 30 | } 31 | 32 | func openCodeDiff(path1, path2 string) { 33 | if runtime.GOOS == "darwin" { 34 | path1 = strings.Replace(path1, ".\\", "./", -1) 35 | path2 = strings.Replace(path2, ".\\", "./", -1) 36 | } 37 | cmd := exec.Command("code", "--new-window", "--diff", path1, path2) 38 | logf("running: %s\n", strings.Join(cmd.Args, " ")) 39 | err := cmd.Start() 40 | must(err) 41 | } 42 | 43 | func writeFileMust(path string, data []byte) { 44 | err := ioutil.WriteFile(path, data, 0644) 45 | must(err) 46 | } 47 | -------------------------------------------------------------------------------- /dump_structure.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | type writer struct { 10 | level int 11 | w io.Writer 12 | } 13 | 14 | func (w *writer) writeString(s string) { 15 | _, _ = io.WriteString(w.w, s) 16 | } 17 | 18 | func (w *writer) writeLevel() { 19 | for n := 0; n < w.level; n++ { 20 | w.writeString(" ") 21 | } 22 | } 23 | 24 | func (w *writer) block(block *Block) { 25 | if block == nil { 26 | return 27 | } 28 | w.writeLevel() 29 | s := fmt.Sprintf("%s %s alive=%v\n", block.Type, block.ID, block.Alive) 30 | w.writeString(s) 31 | w.level++ 32 | for _, child := range block.Content { 33 | w.block(child) 34 | } 35 | w.level-- 36 | } 37 | 38 | // Dump writes a simple representation of Page to w. A debugging helper. 39 | func Dump(w io.Writer, page *Page) { 40 | wr := writer{w: w} 41 | wr.block(page.Root()) 42 | } 43 | 44 | // DumpToString returns a simple representation of Page as a string. 45 | // A debugging helper. 46 | func DumpToString(page *Page) string { 47 | buf := &bytes.Buffer{} 48 | Dump(buf, page) 49 | return buf.String() 50 | } 51 | -------------------------------------------------------------------------------- /do/to_html.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/kjk/notionapi" 8 | "github.com/kjk/notionapi/tohtml" 9 | ) 10 | 11 | func htmlPath(pageID string, n int) string { 12 | pageID = notionapi.ToNoDashID(pageID) 13 | name := fmt.Sprintf("%s.%d.page.html", pageID, n) 14 | return filepath.Join(cacheDir, name) 15 | } 16 | 17 | func toHTML(pageID string) *notionapi.Page { 18 | client := makeNotionClient() 19 | page, err := downloadPage(client, pageID) 20 | if err != nil { 21 | logf("toHTML: downloadPage() failed with '%s'\n", err) 22 | return nil 23 | } 24 | if page == nil { 25 | logf("toHTML: page is nil\n") 26 | return nil 27 | } 28 | 29 | notionapi.PanicOnFailures = true 30 | 31 | c := tohtml.NewConverter(page) 32 | c.FullHTML = true 33 | html, _ := c.ToHTML() 34 | path := htmlPath(pageID, 2) 35 | writeFileMust(path, html) 36 | logf("%s : HTML version of the page\n", path) 37 | if !flgNoOpen { 38 | path, err := filepath.Abs(path) 39 | must(err) 40 | uri := "file://" + path 41 | logf("Opening browser with %s\n", uri) 42 | openBrowser(uri) 43 | } 44 | return page 45 | } 46 | -------------------------------------------------------------------------------- /do/preview.md.tmpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 54 | 55 | 56 | 57 |
58 |
{{ .Markdown }}
59 |
60 | 61 |
{{ .HTML }}
62 | 63 | 64 | -------------------------------------------------------------------------------- /do/to_md.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/kjk/notionapi" 8 | "github.com/kjk/notionapi/tomarkdown" 9 | ) 10 | 11 | func mdPath(pageID string, n int) string { 12 | pageID = notionapi.ToNoDashID(pageID) 13 | name := fmt.Sprintf("%s.%d.page.html", pageID, n) 14 | return filepath.Join(cacheDir, name) 15 | } 16 | 17 | func toMd(pageID string) *notionapi.Page { 18 | client := makeNotionClient() 19 | page, err := downloadPage(client, pageID) 20 | if err != nil { 21 | logf("toHTML: downloadPage() failed with '%s'\n", err) 22 | return nil 23 | } 24 | if page == nil { 25 | logf("toHTML: page is nil\n") 26 | return nil 27 | } 28 | 29 | notionapi.PanicOnFailures = true 30 | 31 | c := tomarkdown.NewConverter(page) 32 | md := c.ToMarkdown() 33 | path := htmlPath(pageID, 2) 34 | writeFileMust(path, md) 35 | logf("%s : MarkDown version of the page\n", path) 36 | if !flgNoOpen { 37 | path, err := filepath.Abs(path) 38 | must(err) 39 | uri := "file://" + path 40 | logf("Opening browser with %s\n", uri) 41 | openBrowser(uri) 42 | } 43 | return page 44 | } 45 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kjk/common/assert" 7 | ) 8 | 9 | func TestExtractNoDashIDFromNotionURL(t *testing.T) { 10 | tests := [][]string{ 11 | { 12 | "https://www.notion.so/Advanced-web-spidering-with-Puppeteer-ea07db1b9bff415ab180b0525f3898f6", 13 | "ea07db1b9bff415ab180b0525f3898f6", 14 | }, 15 | { 16 | "https://www.notion.so/kjkpublic/Type-assertion-e945ebc2e0074ce49cef592e6c0f956e", 17 | "e945ebc2e0074ce49cef592e6c0f956e", 18 | }, 19 | { 20 | "https://notion.so/f400553890d34185ba795870807c2616", 21 | "f400553890d34185ba795870807c2616", 22 | }, 23 | { 24 | "f400553890d34185ba795870807c2617", 25 | "f400553890d34185ba795870807c2617", 26 | }, 27 | { 28 | "f400553890d34185ba795870-807c2618", 29 | "f400553890d34185ba795870807c2618", 30 | }, 31 | { 32 | "https://www.notion.so/kjkpublic/Empty-interface-c3315892508248fdb19b663bf8bff028#0500145a75da4464bca0d25da19af112", 33 | "c3315892508248fdb19b663bf8bff028", 34 | }, 35 | } 36 | for _, tc := range tests { 37 | got := ExtractNoDashIDFromNotionURL(tc[0]) 38 | exp := tc[1] 39 | assert.Equal(t, exp, got) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 28 | 29 | -------------------------------------------------------------------------------- /tomarkdown/markdown_test.go: -------------------------------------------------------------------------------- 1 | package tomarkdown 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kjk/common/assert" 7 | ) 8 | 9 | func TestMarkdownFileNameForPage(t *testing.T) { 10 | tests := [][]string{ 11 | {"Blendle's Employee Handbook", "3b617da409454a52bc3a920ba8832bf7", "Blendle-s-Employee-Handbook-3b617da4-0945-4a52-bc3a-920ba8832bf7.md"}, 12 | {"To Do/Read in your first week", "5fea9664-0720-4d90-80a5-b989360b205f", "To-Do-Read-in-your-first-week-5fea9664-0720-4d90-80a5-b989360b205f.md"}, 13 | {"DNA-&-culture", "9cc14382-e3c3-4037-bf80-a4936a9b6674", "DNA-culture-9cc14382-e3c3-4037-bf80-a4936a9b6674.md"}, 14 | {"General & practical ", "6d25f4e5-3b91-4df6-8630-c98ea5523692", "General-practical-6d25f4e5-3b91-4df6-8630-c98ea5523692.md"}, 15 | {"Time off: holidays and national holidays", "d0464f97-6364-48fd-8dab-5497f68394c2", "Time-off-holidays-and-national-holidays-d0464f97-6364-48fd-8dab-5497f68394c2.md"}, 16 | {"#letstalkaboutstress", "94a2bcc4-7fde-4dab-9229-68733b9a2a94", "letstalkaboutstress-94a2bcc4-7fde-4dab-9229-68733b9a2a94.md"}, 17 | {"The Matrix™ (job profiles)", "f495439c-3d54-409c-a714-fc3c7cc5711f", "The-Matrix-job-profiles-f495439c-3d54-409c-a714-fc3c7cc5711f.md"}, 18 | } 19 | for _, test := range tests { 20 | got := markdownFileName(test[0], test[1]) 21 | assert.Equal(t, test[2], got) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2018, Krzysztof Kowalczyk 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | This is how "removing from bookmarks" operation looks like: 2 | 3 | ```json 4 | { 5 | "operations": [ 6 | { 7 | "table": "space_view", 8 | "id": "4e548900-40d1-4140-a0a8-165f0c373f6d", 9 | "path": [ 10 | "bookmarked_pages" 11 | ], 12 | "command": "listRemove", 13 | "args": { 14 | "id": "7e825831-be07-487e-87e7-56e52914233b" 15 | } 16 | } 17 | ] 18 | } 19 | ``` 20 | 21 | Operation for updating "last edited". Looks like they are sent in pairs: 22 | for block and parent page block: 23 | ```json 24 | { 25 | "id":"c969c945-5d7c-4dd7-9c7f-860f3ace6429", 26 | "table":"block", 27 | "path":[], 28 | "command":"update", 29 | "args":{ 30 | "last_edited_time":1551762900000 31 | } 32 | } 33 | ``` 34 | 35 | Operation for changing page format: 36 | ```json 37 | { 38 | "id": "c969c945-5d7c-4dd7-9c7f-860f3ace6429", 39 | "table": "block", 40 | "path": [ 41 | "format" 42 | ], 43 | "command": "update", 44 | "args": { 45 | "page_small_text": true 46 | } 47 | } 48 | ``` 49 | 50 | Operation for changing language in a code block: 51 | ```json 52 | { 53 | "id": "e802296a-b0dc-41a8-8aa3-cf4212c3da0b", 54 | "table": "block", 55 | "path": [ "properties" ], 56 | "command": "update", 57 | "args": { 58 | "language": [ 59 | [ 60 | "Go" 61 | ] 62 | ] 63 | } 64 | } 65 | ``` 66 | -------------------------------------------------------------------------------- /api_loadUserContent.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import "encoding/json" 4 | 5 | type LoadUserResponse struct { 6 | ID string `json:"id"` 7 | Table string `json:"table"` 8 | Role string `json:"role"` 9 | 10 | Value json.RawMessage `json:"value"` 11 | 12 | Block *Block `json:"-"` 13 | Space *Space `json:"-"` 14 | User *NotionUser `json:"-"` 15 | 16 | RawJSON map[string]interface{} `json:"-"` 17 | } 18 | 19 | func (c *Client) LoadUserContent() (*LoadUserResponse, error) { 20 | req := struct{}{} 21 | 22 | var rsp struct { 23 | RecordMap map[string]map[string]*LoadUserResponse `json:"recordMap"` 24 | } 25 | apiURL := "/api/v3/loadUserContent" 26 | result := LoadUserResponse{} 27 | 28 | err := c.doNotionAPI(apiURL, req, &rsp, &result.RawJSON) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | for table, values := range rsp.RecordMap { 34 | for _, value := range values { 35 | var obj interface{} 36 | if table == TableNotionUser { 37 | result.User = &NotionUser{} 38 | obj = result.User 39 | } 40 | if table == TableBlock { 41 | result.Block = &Block{} 42 | obj = result.Block 43 | } 44 | if table == TableSpace { 45 | result.Space = &Space{} 46 | obj = result.Space 47 | } 48 | if obj == nil { 49 | continue 50 | } 51 | if err := jsonit.Unmarshal(value.Value, &obj); err != nil { 52 | return nil, err 53 | } 54 | } 55 | } 56 | 57 | return &result, nil 58 | } 59 | -------------------------------------------------------------------------------- /api_getSignedFileUrls.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | type permissionRecord struct { 4 | ID string `json:"id"` 5 | Table string `json:"table"` 6 | SpaceID string `json:"spaceId"` 7 | } 8 | 9 | type signedURLRequest struct { 10 | URL string `json:"url"` 11 | PermissionRecord *permissionRecord `json:"permissionRecord"` 12 | } 13 | 14 | // /api/v3/getSignedFileUrls request 15 | type getSignedFileURLsRequest struct { 16 | URLs []signedURLRequest `json:"urls"` 17 | } 18 | 19 | // GetSignedURLsResponse represents response to /api/v3/getSignedFileUrls api 20 | // Note: it depends on Table type in request 21 | type GetSignedURLsResponse struct { 22 | SignedURLS []string `json:"signedUrls"` 23 | RawJSON map[string]interface{} `json:"-"` 24 | } 25 | 26 | // GetSignedURLs executes a raw API call /api/v3/getSignedFileUrls 27 | func (c *Client) GetSignedURLs(urls []string, block *Block) (*GetSignedURLsResponse, error) { 28 | permRec := &permissionRecord{ 29 | ID: block.ID, 30 | Table: block.ParentTable, 31 | SpaceID: block.SpaceID, 32 | } 33 | var recs []signedURLRequest 34 | for _, url := range urls { 35 | srec := signedURLRequest{ 36 | URL: url, 37 | PermissionRecord: permRec, 38 | } 39 | recs = append(recs, srec) 40 | } 41 | req := &getSignedFileURLsRequest{ 42 | URLs: recs, 43 | } 44 | 45 | var rsp GetSignedURLsResponse 46 | var err error 47 | apiURL := "/api/v3/getSignedFileUrls" 48 | if err = c.doNotionAPI(apiURL, req, &rsp, &rsp.RawJSON); err != nil { 49 | return nil, err 50 | } 51 | return &rsp, nil 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About notionapi 2 | 3 | This is an unofficial, Go API for https://notion.so. Mostly for reading, limited write capabilities. 4 | 5 | It allows you to retrieve content of a Notion page in structured format. 6 | 7 | You can then e.g. convert that format to HTML. 8 | 9 | Note: official Notion API is still in beta and not as capable as this unofficial API. 10 | 11 | Documentation: 12 | 13 | - tutorial: https://blog.kowalczyk.info/article/c9df78cbeaae4e0cb2848c9964bcfc94/using-notion-api-go-client.html 14 | - API docs: https://pkg.go.dev/github.com/kjk/notionapi 15 | 16 | You can learn how [I reverse-engineered the Notion API](https://blog.kowalczyk.info/article/88aee8f43620471aa9dbcad28368174c/how-i-reverse-engineered-notion-api.html) in order to write this library. 17 | 18 | # Real-life usage 19 | 20 | I use this API to publish my [blog](https://blog.kowalczyk.info/) and series of [programming books](https://www.programming-books.io/) from content stored in Notion. 21 | 22 | Notion serves as a CMS (Content Management System). I write and edit pages in Notion. 23 | 24 | I use custom Go program to download Notion pages using this this library and converts pages to HTML. It then publishes the result to Netlify. 25 | 26 | You can see the code at https://github.com/kjk/blog and https://github.com/essentialbooks/tools/ 27 | 28 | # Implementations for other languages 29 | 30 | - https://github.com/jamalex/notion-py : library for Python 31 | - https://github.com/petersamokhin/knotion-api : library for Kotlin / Java 32 | - https://github.com/Nishan-Open-Source/Nishan : library for node.js, written in Typescript 33 | 34 | -------------------------------------------------------------------------------- /json.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | jsoniter "github.com/json-iterator/go" 7 | "github.com/tidwall/pretty" 8 | ) 9 | 10 | var ( 11 | PrettyPrintJS = PrettyPrintJSJsonit 12 | jsonit = jsoniter.ConfigCompatibleWithStandardLibrary 13 | prettyOpts = pretty.Options{ 14 | Width: 80, 15 | Prefix: "", 16 | Indent: " ", 17 | // sorting keys only slightly slower 18 | SortKeys: true, 19 | } 20 | ) 21 | 22 | // TODO: doesn't work with some of Notion json responses? 23 | // pretty-print if valid JSON. If not, return unchanged 24 | // about 4x faster than naive version using json.Unmarshal() + json.Marshal() 25 | func PrettyPrintJSJsonit(js []byte) []byte { 26 | if !jsonit.Valid(js) { 27 | return js 28 | } 29 | return pretty.PrettyOptions(js, &prettyOpts) 30 | } 31 | 32 | // pretty-print if valid JSON. If not, return unchanged 33 | // about 4x faster than naive version using json.Unmarshal() + json.Marshal() 34 | func PrettyPrintJSStd(js []byte) []byte { 35 | var m map[string]interface{} 36 | err := json.Unmarshal(js, &m) 37 | if err != nil { 38 | return js 39 | } 40 | d, err := json.MarshalIndent(m, "", " ") 41 | if err != nil { 42 | return js 43 | } 44 | return d 45 | } 46 | 47 | func jsonUnmarshalFromMap(m map[string]interface{}, v interface{}) error { 48 | d, err := jsonit.Marshal(m) 49 | if err != nil { 50 | return err 51 | } 52 | return json.Unmarshal(d, v) 53 | } 54 | 55 | func jsonGetMap(m map[string]interface{}, key string) map[string]interface{} { 56 | if v, ok := m[key]; ok { 57 | if m, ok := v.(map[string]interface{}); ok { 58 | return m 59 | } 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /do/test_page_marshal.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/kjk/notionapi" 7 | "github.com/kjk/notionapi/tohtml" 8 | "github.com/kjk/notionapi/tomarkdown" 9 | ) 10 | 11 | func pageToHTML(page *notionapi.Page) []byte { 12 | converter := tohtml.NewConverter(page) 13 | d, _ := converter.ToHTML() 14 | return d 15 | } 16 | 17 | func pageToMarkdown(page *notionapi.Page) []byte { 18 | converter := tomarkdown.NewConverter(page) 19 | d := converter.ToMarkdown() 20 | return d 21 | } 22 | 23 | func testCachingDownloads(pageID string) { 24 | // TODO: fix up for the new CachingClient 25 | if true { 26 | return 27 | } 28 | 29 | // Test that caching downloader works: 30 | // - download page using empty cache 31 | // - format as html and md 32 | // - download again using cache from previous download 33 | // - format as html and md 34 | // - compare they are identical 35 | logf("testCachingDownloads: '%s'\n", pageID) 36 | client := newClient() 37 | 38 | pageID = notionapi.ToNoDashID(pageID) 39 | page1, err := client.DownloadPage(pageID) 40 | must(err) 41 | html := pageToHTML(page1) 42 | md := pageToMarkdown(page1) 43 | 44 | // this should satisfy downloads using a cache 45 | page2, err := client.DownloadPage(pageID) 46 | must(err) 47 | 48 | // verify we made the same amount of requests 49 | //panicIf(nRequests != cache.RequestsNotFromCache, "nRequests: %d, cache.RequestsNotFromCache: %d", nRequests, cache.RequestsNotFromCache) 50 | 51 | html2 := pageToHTML(page2) 52 | md_2 := pageToMarkdown(page2) 53 | 54 | if !bytes.Equal(html, html2) { 55 | logf("html != html2!\n") 56 | return 57 | } 58 | 59 | if !bytes.Equal(md, md_2) { 60 | logf("md != md_2!\n") 61 | return 62 | } 63 | 64 | //logf("json:\n%s\n", string(d)) 65 | } 66 | -------------------------------------------------------------------------------- /api_getActivityLog.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | type navigableBlockID struct { 4 | ID string `json:"id"` 5 | } 6 | 7 | // /api/v3/getActivityLog request 8 | type getActivityLogRequest struct { 9 | SpaceID string `json:"spaceId"` 10 | StartingAfterID string `json:"startingAfterId,omitempty"` 11 | NavigableBlock navigableBlockID `json:"navigableBlock,omitempty"` 12 | Limit int `json:"limit"` 13 | } 14 | 15 | // GetActivityLogResponse is a response to /api/v3/getActivityLog api 16 | type GetActivityLogResponse struct { 17 | ActivityIDs []string `json:"activityIds"` 18 | RecordMap *RecordMap `json:"recordMap"` 19 | NextID string `json:"-"` 20 | 21 | RawJSON map[string]interface{} `json:"-"` 22 | } 23 | 24 | // GetActivityLog executes a raw API call /api/v3/getActivityLog. 25 | // If startingAfterId is "", starts at the most recent log entry. 26 | // navBlockID is the ID of a navigable block (like a page in a database) 27 | func (c *Client) GetActivityLog(spaceID string, startingAfterID string, navBlockID string, limit int) (*GetActivityLogResponse, error) { 28 | req := &getActivityLogRequest{ 29 | SpaceID: spaceID, 30 | StartingAfterID: startingAfterID, 31 | Limit: limit, 32 | NavigableBlock: navigableBlockID{ID: navBlockID}, 33 | } 34 | var rsp GetActivityLogResponse 35 | var err error 36 | apiURL := "/api/v3/getActivityLog" 37 | if err = c.doNotionAPI(apiURL, req, &rsp, &rsp.RawJSON); err != nil { 38 | return nil, err 39 | } 40 | if err = ParseRecordMap(rsp.RecordMap); err != nil { 41 | return nil, err 42 | } 43 | if len(rsp.ActivityIDs) > 0 { 44 | rsp.NextID = rsp.ActivityIDs[len(rsp.ActivityIDs)-1] 45 | } else { 46 | rsp.NextID = "" 47 | } 48 | return &rsp, nil 49 | } 50 | -------------------------------------------------------------------------------- /space.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | // SpacePermissions represents permissions for space 4 | type SpacePermissions struct { 5 | Role string `json:"role"` 6 | Type string `json:"type"` // e.g. "user_permission" 7 | UserID string `json:"user_id"` 8 | } 9 | 10 | // SpacePermissionGroups represesnts group permissions for space 11 | type SpacePermissionGroups struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | UserIds []string `json:"user_ids,omitempty"` 15 | } 16 | 17 | // Space describes Notion workspace. 18 | type Space struct { 19 | ID string `json:"id"` 20 | Version float64 `json:"version"` 21 | Name string `json:"name"` 22 | Domain string `json:"domain"` 23 | Permissions []*SpacePermissions `json:"permissions,omitempty"` 24 | PermissionGroups []SpacePermissionGroups `json:"permission_groups"` 25 | Icon string `json:"icon"` 26 | EmailDomains []string `json:"email_domains"` 27 | BetaEnabled bool `json:"beta_enabled"` 28 | Pages []string `json:"pages,omitempty"` 29 | DisablePublicAccess bool `json:"disable_public_access"` 30 | DisableGuests bool `json:"disable_guests"` 31 | DisableMoveToSpace bool `json:"disable_move_to_space"` 32 | DisableExport bool `json:"disable_export"` 33 | CreatedBy string `json:"created_by"` 34 | CreatedTime int64 `json:"created_time"` 35 | LastEditedBy string `json:"last_edited_by"` 36 | LastEditedTime int64 `json:"last_edited_time"` 37 | 38 | RawJSON map[string]interface{} `json:"-"` 39 | } 40 | -------------------------------------------------------------------------------- /do/notion_util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/kjk/notionapi" 5 | ) 6 | 7 | var ( 8 | didPrintTokenStatus bool 9 | ) 10 | 11 | func makeNotionClient() *notionapi.Client { 12 | client := newClient() 13 | 14 | if !didPrintTokenStatus { 15 | didPrintTokenStatus = true 16 | if client.AuthToken == "" { 17 | logf("NOTION_TOKEN env variable not set. Can only access public pages\n") 18 | } else { 19 | // TODO: validate that the token looks legit 20 | logf("NOTION_TOKEN env variable set, can access private pages\n") 21 | } 22 | } 23 | return client 24 | } 25 | 26 | var ( 27 | eventsPerID = map[string]string{} 28 | ) 29 | 30 | func downloadPage(client *notionapi.Client, pageID string) (*notionapi.Page, error) { 31 | d, err := notionapi.NewCachingClient(cacheDir, client) 32 | if err != nil { 33 | return nil, err 34 | } 35 | d.Policy = notionapi.PolicyDownloadNewer 36 | if flgNoCache { 37 | d.Policy = notionapi.PolicyDownloadAlways 38 | } 39 | return d.DownloadPage(pageID) 40 | } 41 | 42 | const ( 43 | idNoDashLength = 32 44 | ) 45 | 46 | // only hex chars seem to be valid 47 | func isValidNoDashIDChar(c byte) bool { 48 | switch { 49 | case c >= '0' && c <= '9': 50 | return true 51 | case c >= 'a' && c <= 'f': 52 | return true 53 | case c >= 'A' && c <= 'F': 54 | // currently not used but just in case notion starts using them 55 | return true 56 | } 57 | return false 58 | } 59 | 60 | // given e.g.: 61 | // /p/foo-395f6c6af50d44e48919a45fcc064d3e 62 | // returns: 63 | // 395f6c6af50d44e48919a45fcc064d3e 64 | func extractNotionIDFromURL(uri string) string { 65 | n := len(uri) 66 | if n < idNoDashLength { 67 | return "" 68 | } 69 | 70 | s := "" 71 | for i := n - 1; i > 0; i-- { 72 | c := uri[i] 73 | if c == '-' { 74 | continue 75 | } 76 | if isValidNoDashIDChar(c) { 77 | s = string(c) + s 78 | if len(s) == idNoDashLength { 79 | return s 80 | } 81 | } 82 | } 83 | return "" 84 | } 85 | -------------------------------------------------------------------------------- /constants.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | const ( 4 | // PermissionTypeUser describes permissions for a user 5 | PermissionTypeUser = "user_permission" 6 | // PermissionTypePublic describes permissions for public 7 | PermissionTypePublic = "public_permission" 8 | ) 9 | 10 | // for Schema.Type 11 | const ( 12 | ColumnTypeCheckbox = "checkbox" 13 | ColumnTypeCreatedBy = "created_by" 14 | ColumnTypeCreatedTime = "created_time" 15 | ColumnTypeDate = "date" 16 | ColumnTypeEmail = "email" 17 | ColumnTypeFile = "file" 18 | ColumnTypeFormula = "formula" 19 | ColumnTypeLastEditedBy = "last_edited_by" 20 | ColumnTypeLastEditedTime = "last_edited_time" 21 | ColumnTypeMultiSelect = "multi_select" 22 | ColumnTypeNumber = "number" 23 | ColumnTypePerson = "person" 24 | ColumnTypePhoneNumber = "phone_number" 25 | ColumnTypeRelation = "relation" 26 | ColumnTypeRollup = "rollup" 27 | ColumnTypeSelect = "select" 28 | ColumnTypeText = "text" 29 | ColumnTypeTitle = "title" 30 | ColumnTypeURL = "url" 31 | ) 32 | 33 | const ( 34 | // those are Record.Type and determine the type of Record.Value 35 | TableSpace = "space" 36 | TableActivity = "activity" 37 | TableBlock = "block" 38 | TableNotionUser = "notion_user" 39 | TableUserRoot = "user_root" 40 | TableUserSettings = "user_settings" 41 | TableCollection = "collection" 42 | TableCollectionView = "collection_view" 43 | TableComment = "comment" 44 | TableDiscussion = "discussion" 45 | ) 46 | 47 | const ( 48 | // RoleReader represents a reader 49 | RoleReader = "reader" 50 | // RoleEditor represents an editor 51 | RoleEditor = "editor" 52 | ) 53 | 54 | const ( 55 | // DateTypeDate represents a date in Date.Type 56 | DateTypeDate = "date" 57 | // DateTypeDateTime represents a datetime in Date.Type 58 | DateTypeDateTime = "datetime" 59 | ) 60 | -------------------------------------------------------------------------------- /do/make_full_html.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/kjk/u" 9 | ) 10 | 11 | /* 12 | Wrap HTML fragment generated by tohtml.ToHTML() if more HTML 13 | to make it fully stand-alone page 14 | */ 15 | 16 | var ( 17 | htmlWrap = ` 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | {{ htmlBody }} 30 | 31 | 32 | 33 | ` 34 | ) 35 | 36 | var ( 37 | cssFromFile []byte 38 | fullHTMLWrap string 39 | ) 40 | 41 | func loadCSS() string { 42 | if len(cssFromFile) > 0 { 43 | return string(cssFromFile) 44 | } 45 | currDir, err := filepath.Abs(".") 46 | must(err) 47 | path1 := filepath.Join("main.css") 48 | cssFromFile = u.ReadFileMust(path1) 49 | if len(cssFromFile) > 0 { 50 | return string(cssFromFile) 51 | } 52 | path2 := filepath.Join("do", "main.css") 53 | cssFromFile = u.ReadFileMust(path2) 54 | if len(cssFromFile) > 0 { 55 | return string(cssFromFile) 56 | } 57 | logf("couldn't load css from the following files:\n'%s'\n'%s'\nCurr directory: %s\n", path1, path2, currDir) 58 | os.Exit(1) 59 | return "" 60 | } 61 | 62 | func getFullHTMLWrap() string { 63 | if len(fullHTMLWrap) == 0 { 64 | css := loadCSS() 65 | fullHTMLWrap = strings.Replace(htmlWrap, "{{ css }}", css, -1) 66 | if fullHTMLWrap == htmlWrap { 67 | panic("failed to replace {{ css }} in htmlWrap") 68 | } 69 | } 70 | return fullHTMLWrap 71 | } 72 | 73 | // wrap HTML body in more HTML to create a full, stand-alone HTML page 74 | func makeFullHTML(htmlBody []byte) []byte { 75 | wrap := getFullHTMLWrap() 76 | html := strings.Replace(wrap, "{{ htmlBody }}", string(htmlBody), -1) 77 | if len(htmlBody) > 0 && wrap == html { 78 | panic("failed to replace {{ htmlBody }} in fullHTMLWrap") 79 | } 80 | return []byte(html) 81 | } 82 | -------------------------------------------------------------------------------- /activity.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | // Author represents the author of an Edit 4 | type Author struct { 5 | ID string `json:"id"` 6 | Table string `json:"table"` 7 | } 8 | 9 | // Edit represents a Notion edit (ie. a change made during an Activity) 10 | type Edit struct { 11 | SpaceID string `json:"space_id"` 12 | Authors []Author `json:"authors"` 13 | Timestamp int64 `json:"timestamp"` 14 | Type string `json:"type"` 15 | Version int `json:"version"` 16 | 17 | CommentData Comment `json:"comment_data"` 18 | CommentID string `json:"comment_id"` 19 | DiscussionID string `json:"discussion_id"` 20 | 21 | BlockID string `json:"block_id"` 22 | BlockData struct { 23 | BlockValue Block `json:"block_value"` 24 | Before struct { 25 | BlockValue Block `json:"block_value"` 26 | } `json:"before"` 27 | After struct { 28 | BlockValue Block `json:"block_value"` 29 | } `json:"after"` 30 | } `json:"block_data"` 31 | NavigableBlockID string `json:"navigable_block_id"` 32 | 33 | CollectionID string `json:"collection_id"` 34 | CollectionRowID string `json:"collection_row_id"` 35 | } 36 | 37 | // Activity represents a Notion activity (ie. event) 38 | type Activity struct { 39 | Role string `json:"role"` 40 | 41 | ID string `json:"id"` 42 | SpaceID string `json:"space_id"` 43 | StartTime string `json:"start_time"` 44 | EndTime string `json:"end_time"` 45 | Type string `json:"type"` 46 | Version int `json:"version"` 47 | 48 | ParentID string `json:"parent_id"` 49 | ParentTable string `json:"parent_table"` 50 | 51 | // If the edit was to a block inside a regular page 52 | NavigableBlockID string `json:"navigable_block_id"` 53 | 54 | // If the edit was to a block inside a collection or collection row 55 | CollectionID string `json:"collection_id"` 56 | CollectionRowID string `json:"collection_row_id"` 57 | 58 | Edits []Edit `json:"edits"` 59 | 60 | Index int `json:"index"` 61 | Invalid bool `json:"invalid"` 62 | 63 | RawJSON map[string]interface{} `json:"-"` 64 | } 65 | -------------------------------------------------------------------------------- /api_syncRecordValues.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | // /api/v3/syncRecordValues request 4 | type syncRecordRequest struct { 5 | Requests []PointerWithVersion `json:"requests"` 6 | } 7 | 8 | type Pointer struct { 9 | Table string `json:"table"` 10 | ID string `json:"id"` 11 | } 12 | 13 | type PointerWithVersion struct { 14 | Pointer Pointer `json:"pointer"` 15 | Version int `json:"version"` 16 | } 17 | 18 | // SyncRecordValuesResponse represents response to /api/v3/syncRecordValues api 19 | // Note: it depends on Table type in request 20 | type SyncRecordValuesResponse struct { 21 | RecordMap *RecordMap `json:"recordMap"` 22 | 23 | RawJSON map[string]interface{} `json:"-"` 24 | } 25 | 26 | // SyncRecordValues executes a raw API call /api/v3/syncRecordValues 27 | func (c *Client) SyncRecordValues(req syncRecordRequest) (*SyncRecordValuesResponse, error) { 28 | var rsp SyncRecordValuesResponse 29 | var err error 30 | apiURL := "/api/v3/syncRecordValues" 31 | if err = c.doNotionAPI(apiURL, req, &rsp, &rsp.RawJSON); err != nil { 32 | return nil, err 33 | } 34 | if err = ParseRecordMap(rsp.RecordMap); err != nil { 35 | return nil, err 36 | } 37 | return &rsp, nil 38 | } 39 | 40 | // GetBlockRecords emulates deprecated /api/v3/getRecordValues with /api/v3/syncRecordValues 41 | // Gets Block records with given ids 42 | // Used to retrieve version information for each block so that we can skip re-downloading pages 43 | // that didn't change 44 | func (c *Client) GetBlockRecords(ids []string) ([]*Block, error) { 45 | var req syncRecordRequest 46 | for _, id := range ids { 47 | id = ToDashID(id) 48 | p := Pointer{ 49 | ID: id, 50 | Table: TableBlock, 51 | } 52 | pver := PointerWithVersion{ 53 | Pointer: p, 54 | Version: -1, 55 | } 56 | req.Requests = append(req.Requests, pver) 57 | } 58 | 59 | rsp, err := c.SyncRecordValues(req) 60 | if err != nil { 61 | return nil, err 62 | } 63 | var res []*Block 64 | rm := rsp.RecordMap 65 | for _, id := range ids { 66 | id = ToDashID(id) 67 | 68 | // sometimes notion does not return the block ask by the API 69 | var b *Block 70 | if rm.Blocks[id] != nil { 71 | b = rm.Blocks[id].Block 72 | } 73 | 74 | res = append(res, b) 75 | } 76 | return res, nil 77 | } 78 | -------------------------------------------------------------------------------- /do/smoke.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/kjk/notionapi" 9 | "github.com/kjk/u" 10 | ) 11 | 12 | var ( 13 | smokeDir string 14 | smokeSeen map[string]bool 15 | ) 16 | 17 | // load the page, render to md and html. repeat for all sub-children 18 | func loadAndRenderPageRecur(pageID string) { 19 | id := notionapi.ToNoDashID(pageID) 20 | if smokeSeen[id] { 21 | return 22 | } 23 | smokeSeen[id] = true 24 | page := toHTML(pageID) 25 | _, md := toMarkdown(page) 26 | mdName := fmt.Sprintf("%s.page.md", id) 27 | mdPath := filepath.Join(cacheDir, mdName) 28 | writeFileMust(mdPath, md) 29 | logf("%s : md version of the page\n", mdPath) 30 | for _, pageID := range page.GetSubPages() { 31 | loadAndRenderPageRecur(pageID.NoDashID) 32 | } 33 | } 34 | 35 | // smoke test is meant to be run after non-trivial changes 36 | // it tries to exercise as many features as possible while still 37 | // being reasonably fast 38 | func smokeTest() { 39 | smokeDir = filepath.Join(dataDir, "smoke") 40 | recreateDir(smokeDir) 41 | // over-write cacheDir 42 | defer func(curr string) { 43 | cacheDir = curr 44 | }(cacheDir) 45 | 46 | // over-write cache dir location 47 | cacheDir = filepath.Join(smokeDir, "cache") 48 | err := os.MkdirAll(cacheDir, 0755) 49 | must(err) 50 | 51 | logFilePath := filepath.Join(smokeDir, "log.txt") 52 | logf("Running smokeTest(), log file: '%s', cache dir: '%s'\n", logFilePath, cacheDir) 53 | f, err := os.Create(logFilePath) 54 | must(err) 55 | defer f.Close() 56 | u.LogFile = f 57 | 58 | smokeSeen = map[string]bool{} 59 | flgNoOpen = true 60 | 61 | if false { 62 | // queryCollection api changed 63 | 64 | // https://www.notion.so/49d988a60c4a4592bce09938918e8e5b?v=ade5945063da49a3bc79128b06a0683e 65 | // collection_view_page 66 | loadAndRenderPageRecur("49d988a60c4a4592bce09938918e8e5b") 67 | } 68 | 69 | if false { 70 | // queryCollection api changed 71 | 72 | // https://www.notion.so/Relations-rollups-fd56bfc6a3f0471a9f0cc3110ff19a79 73 | // table with a rollup, used to crash 74 | loadAndRenderPageRecur("fd56bfc6a3f0471a9f0cc3110ff19a79") 75 | } 76 | 77 | if false { 78 | // queryCollection api changed 79 | 80 | // https://www.notion.so/Test-pages-for-notionapi-0367c2db381a4f8b9ce360f388a6b2e3 81 | // root page of my test pages 82 | loadAndRenderPageRecur("0367c2db381a4f8b9ce360f388a6b2e3") 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /do/test_html_known_bad.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var knownBadHTML = [][]string{ 4 | { 5 | // id of the start page 6 | "3b617da409454a52bc3a920ba8832bf7", 7 | 8 | // Notion is missing one link to page 9 | "13aa42a5a95d4357aa830c3e7ff35ae1", 10 | // TODO(1): Notion renders
inside

which is illegal and makes pretty-printing 11 | // not work, so can't compare results. Probably because they render children 12 | //

inside

instead of after

13 | "4f5ee5cf485048468db8dfbf5924409c", 14 | // Notion is missing one link to page 15 | "7a5df17b32e84686ae33bf01fa367da9", 16 | // Notion is malformed 17 | "7afdcc4fbede49bc9582469ad6e86fd3", 18 | // Notion is malformed 19 | "949f33cdba814fc4a288d81c6e7c810d", 20 | // Notion is missing one link to page 21 | "b1b31f6d3405466c988676f996ce03ad", 22 | // Notion is missong some link to page 23 | "ef850413bb53491eaebccaa09eeb8630", 24 | // Notion is malformed 25 | "f2d97c9cba804583838acf5d571313f5", 26 | // Notion is malformed 27 | "3c892714f4dc4d2194619fdccba48fc6", 28 | // Different ids 29 | "8f12cc5182a6437aac4dc518cb28b681", 30 | }, 31 | { 32 | "0367c2db381a4f8b9ce360f388a6b2e3", 33 | 34 | // TODO: Notion doen't export link to page 35 | "86b5223576104fa69dc03675e44571b7", 36 | // TODO: a date with time zone not formatted correctly 37 | "97100f9c17324fd7ba3d3c5f1832104d", 38 | // TODO: bad indent in toc 39 | "c969c9455d7c4dd79c7f860f3ace6429", 40 | // TODO: Notion exports a column "Title" marked as "not visible" 41 | "92dd7aedf1bb4121aaa8986735df3d13", 42 | // TODO: don't have name of the page 43 | "f97ffca91f8949b48004999df34ab1f7", 44 | }, 45 | { 46 | "d6eb49cfc68f402881af3aef391443e6", 47 | 48 | // TODO: I'm not formatting table correctly 49 | "00f68316d03c4830b00c453e542a1df7", 50 | // TODO: I'm not formatting table correctly 51 | "02bfca37eae5484ba942a00c99076b7a", 52 | // TODO: I'm not formatting table correctly 53 | "09e9c8f5c9df445f94d1cf3f39a1039f", 54 | // TODO: totally different export 55 | "0e684b2e45ea434293274c802b5ad702", 56 | // TODO: I'm not exporting a table the right way 57 | "141c2ef1718b471896c915ae622dae83", 58 | // TODO: Bad export 59 | "14d22d99fb074352a59d78751646cf3d", 60 | }, 61 | } 62 | 63 | func findKnownBadHTML(pageID string) []string { 64 | for _, a := range knownBadHTML { 65 | if a[0] == pageID { 66 | return a[1:] 67 | } 68 | } 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /caching_client_test.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kjk/common/require" 7 | ) 8 | 9 | /* 10 | Tests that use pages cached in testdata/ directory. 11 | Because they don't involve that they are good for unit tests. 12 | To create the file for a page, run: ./doit.bat -clean-cache -to-html ${pageID} 13 | and copy tmpdata/cache/${pageID}.txt to caching_client_testdata 14 | */ 15 | 16 | func testDownloadFromCache(t *testing.T, pageID string) *Page { 17 | client := &Client{} 18 | cc, err := NewCachingClient("caching_client_testdata", client) 19 | cc.Policy = PolicyCacheOnly 20 | require.NoError(t, err) 21 | p, err := cc.DownloadPage(pageID) 22 | require.NoError(t, err) 23 | require.True(t, cc.RequestsFromCache > 0) 24 | require.Equal(t, 0, cc.RequestsFromServer) 25 | return p 26 | } 27 | 28 | /* 29 | func convertToMdAndHTML(t *testing.T, page *Page) { 30 | { 31 | conv := tomarkdown.NewConverter(page) 32 | md := conv.ToMarkdown() 33 | require.NotEmpty(t, md) 34 | } 35 | 36 | { 37 | conv := tohtml.NewConverter(page) 38 | html, err := conv.ToHTML() 39 | require.NoError(t, err) 40 | require.NotEmpty(t, html) 41 | } 42 | } 43 | */ 44 | 45 | // https://www.notion.so/Test-headers-6682351e44bb4f9ca0e149b703265bdb 46 | // test that ForEachBlock() works 47 | func TestPage6682351e44bb4f9ca0e149b703265bdb(t *testing.T) { 48 | pid := "6682351e44bb4f9ca0e149b703265bdb" 49 | p := testDownloadFromCache(t, pid) 50 | blockTypes := []string{} 51 | cb := func(block *Block) { 52 | blockTypes = append(blockTypes, block.Type) 53 | } 54 | blocks := []*Block{p.Root()} 55 | ForEachBlock(blocks, cb) 56 | expected := []string{ 57 | BlockPage, 58 | BlockHeader, 59 | BlockSubHeader, 60 | BlockText, 61 | BlockSubSubHeader, 62 | BlockText, 63 | BlockText, 64 | } 65 | require.Equal(t, blockTypes, expected) 66 | } 67 | 68 | // https://www.notion.so/Test-table-94167af6567043279811dc923edd1f04 69 | // simple table 70 | func TestPage94167af6567043279811dc923edd1f04(t *testing.T) { 71 | pid := "94167af6567043279811dc923edd1f04" 72 | p := testDownloadFromCache(t, pid) 73 | require.Equal(t, 2, len(p.TableViews)) 74 | //convertToMdAndHTML(t, p) 75 | } 76 | 77 | // https://www.notion.so/Test-table-no-title-44f1a38eefe94336907c7576ef4dd19b 78 | // used to crash the API because it has no title column 79 | func TestPage44f1a38eefe94336907c7576ef4dd19b(t *testing.T) { 80 | // used to crash because has no title column 81 | pid := "44f1a38eefe94336907c7576ef4dd19b" 82 | p := testDownloadFromCache(t, pid) 83 | require.Equal(t, 1, len(p.TableViews)) 84 | //convertToMdAndHTML(t, p) 85 | } 86 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | type NotionUser struct { 4 | ID string `json:"id"` 5 | Version int `json:"version"` 6 | Email string `json:"email"` 7 | GivenName string `json:"given_name"` 8 | FamilyName string `json:"family_name"` 9 | ProfilePhoto string `json:"profile_photo"` 10 | OnboardingCompleted bool `json:"onboarding_completed"` 11 | MobileOnboardingCompleted bool `json:"mobile_onboarding_completed"` 12 | ClipperOnboardingCompleted bool `json:"clipper_onboarding_completed"` 13 | Name string `json:"name"` 14 | 15 | RawJSON map[string]interface{} `json:"-"` 16 | } 17 | 18 | type UserRoot struct { 19 | Role string `json:"role"` 20 | Value struct { 21 | ID string `json:"id"` 22 | Version int `json:"version"` 23 | SpaceViews []string `json:"space_views"` 24 | LeftSpaces []string `json:"left_spaces"` 25 | SpaceViewPointers []struct { 26 | ID string `json:"id"` 27 | Table string `json:"table"` 28 | SpaceID string `json:"spaceId"` 29 | } `json:"space_view_pointers"` 30 | } `json:"value"` 31 | 32 | RawJSON map[string]interface{} `json:"-"` 33 | } 34 | 35 | type UserSettings struct { 36 | ID string `json:"id"` 37 | Version int `json:"version"` 38 | Settings struct { 39 | Type string `json:"type"` 40 | Locale string `json:"locale"` 41 | Source string `json:"source"` 42 | Persona string `json:"persona"` 43 | TimeZone string `json:"time_zone"` 44 | UsedMacApp bool `json:"used_mac_app"` 45 | PreferredLocale string `json:"preferred_locale"` 46 | UsedAndroidApp bool `json:"used_android_app"` 47 | UsedWindowsApp bool `json:"used_windows_app"` 48 | StartDayOfWeek int `json:"start_day_of_week"` 49 | UsedMobileWebApp bool `json:"used_mobile_web_app"` 50 | UsedDesktopWebApp bool `json:"used_desktop_web_app"` 51 | SeenViewsIntroModal bool `json:"seen_views_intro_modal"` 52 | PreferredLocaleOrigin string `json:"preferred_locale_origin"` 53 | SeenCommentSidebarV2 bool `json:"seen_comment_sidebar_v2"` 54 | SeenPersonaCollection bool `json:"seen_persona_collection"` 55 | SeenFileAttachmentIntro bool `json:"seen_file_attachment_intro"` 56 | HiddenCollectionDescriptions []string `json:"hidden_collection_descriptions"` 57 | CreatedEvernoteGettingStarted bool `json:"created_evernote_getting_started"` 58 | } `json:"settings"` 59 | 60 | RawJSON map[string]interface{} `json:"-"` 61 | } 62 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= 2 | github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= 3 | github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= 4 | github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 12 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 13 | github.com/kjk/common v0.0.0-20211010101831-6203abf05163 h1:iwH0ioFLk58sU4CCis60VwIkrZksSeNP08+FFe9n50c= 14 | github.com/kjk/common v0.0.0-20211010101831-6203abf05163/go.mod h1:bZoW8+ube8gSUMxdvIMVBw97o5gepeZqlCD8V+0MWXg= 15 | github.com/kjk/siser v0.0.0-20220410204903-1b1e84ea1397 h1:OUgj4KSdIQeZfLSXR4WeUb3tRbWVGIXdlv9SfJJmbeg= 16 | github.com/kjk/siser v0.0.0-20220410204903-1b1e84ea1397/go.mod h1:k2Pzb2Ix2MoXVfVLt3SeCEhwammA4PTgLxjDBhIUHoA= 17 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 18 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 19 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 20 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 21 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 22 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 28 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 29 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 30 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 31 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 32 | -------------------------------------------------------------------------------- /api_queryCollection.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | const ( 4 | // key in LoaderReducer.Reducers map 5 | ReducerCollectionGroupResultsName = "collection_group_results" 6 | ) 7 | 8 | type ReducerCollectionGroupResults struct { 9 | Type string `json:"type"` 10 | Limit int `json:"limit"` 11 | } 12 | 13 | // /api/v3/queryCollection request 14 | type QueryCollectionRequest struct { 15 | Collection struct { 16 | ID string `json:"id"` 17 | SpaceID string `json:"spaceId"` 18 | } `json:"collection"` 19 | CollectionView struct { 20 | ID string `json:"id"` 21 | SpaceID string `json:"spaceId"` 22 | } `json:"collectionView"` 23 | Loader interface{} `json:"loader"` // e.g. LoaderReducer 24 | } 25 | 26 | type CollectionGroupResults struct { 27 | Type string `json:"type"` 28 | BlockIds []string `json:"blockIds"` 29 | Total int `json:"total"` 30 | } 31 | type ReducerResults struct { 32 | // TODO: probably more types 33 | CollectionGroupResults *CollectionGroupResults `json:"collection_group_results"` 34 | } 35 | 36 | // QueryCollectionResponse is json response for /api/v3/queryCollection 37 | type QueryCollectionResponse struct { 38 | RecordMap *RecordMap `json:"recordMap"` 39 | Result struct { 40 | Type string `json:"type"` 41 | // TODO: there's probably more 42 | ReducerResults *ReducerResults `json:"reducerResults"` 43 | } `json:"result"` 44 | RawJSON map[string]interface{} `json:"-"` 45 | } 46 | 47 | type LoaderReducer struct { 48 | Type string `json:"type"` //"reducer" 49 | Reducers map[string]interface{} `json:"reducers"` 50 | Sort []QuerySort `json:"sort,omitempty"` 51 | Filter map[string]interface{} `json:"filter,omitempty"` 52 | SearchQuery string `json:"searchQuery"` 53 | UserTimeZone string `json:"userTimeZone"` // e.g. "America/Los_Angeles" from User.Locale 54 | } 55 | 56 | func MakeLoaderReducer(query *Query) *LoaderReducer { 57 | res := &LoaderReducer{ 58 | Type: "reducer", 59 | Reducers: map[string]interface{}{}, 60 | } 61 | if query != nil { 62 | res.Sort = query.Sort 63 | res.Filter = query.Filter 64 | } 65 | res.Reducers[ReducerCollectionGroupResultsName] = &ReducerCollectionGroupResults{ 66 | Type: "results", 67 | Limit: 50, 68 | } 69 | // set some default value, should over-ride with User.TimeZone 70 | res.UserTimeZone = "America/Los_Angeles" 71 | return res 72 | } 73 | 74 | // QueryCollection executes a raw API call /api/v3/queryCollection 75 | func (c *Client) QueryCollection(req QueryCollectionRequest, query *Query) (*QueryCollectionResponse, error) { 76 | if req.Loader == nil { 77 | req.Loader = MakeLoaderReducer(query) 78 | } 79 | var rsp QueryCollectionResponse 80 | var err error 81 | apiURL := "/api/v3/queryCollection" 82 | err = c.doNotionAPI(apiURL, req, &rsp, &rsp.RawJSON) 83 | if err != nil { 84 | return nil, err 85 | } 86 | // TODO: fetch more if exceeded limit 87 | if err := ParseRecordMap(rsp.RecordMap); err != nil { 88 | return nil, err 89 | } 90 | return &rsp, nil 91 | } 92 | -------------------------------------------------------------------------------- /api_syncRecordValues_test.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kjk/common/require" 7 | ) 8 | 9 | const ( 10 | // TODO: I'm seeing a different format in the browser? 11 | syncRecordValuesJSON_1 = ` 12 | { 13 | "recordMap": { 14 | "block": { 15 | "c3039398-9ae5-49c3-a39f-21ca5a681d72": { 16 | "role": "reader", 17 | "value": { 18 | "alive": true, 19 | "content": [ 20 | "d28e6c26-bdb5-4c59-9bd2-d791a0dee0e6", 21 | "36566abc-f77c-42c9-b028-6aa03ec03d04", 22 | "46c47574-37bf-4139-8d72-4d25211eb55c" 23 | ], 24 | "copied_from": "dd5c0a81-3dfe-4487-a6cd-432f82c0c2fc", 25 | "created_by": "bb760e2d-d679-4b64-b2a9-03005b21870a", 26 | "created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a", 27 | "created_by_table": "notion_user", 28 | "created_time": 1570044083803, 29 | "format": { 30 | "copied_from_pointer": { 31 | "id": "dd5c0a81-3dfe-4487-a6cd-432f82c0c2fc", 32 | "spaceId": "bc202e06-6caa-4e3f-81eb-f226ab5deef7", 33 | "table": "block" 34 | }, 35 | "page_full_width": true, 36 | "page_small_text": true 37 | }, 38 | "id": "c3039398-9ae5-49c3-a39f-21ca5a681d72", 39 | "last_edited_by": "bb760e2d-d679-4b64-b2a9-03005b21870a", 40 | "last_edited_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a", 41 | "last_edited_by_table": "notion_user", 42 | "last_edited_time": 1633890540000, 43 | "parent_id": "0367c2db-381a-4f8b-9ce3-60f388a6b2e3", 44 | "parent_table": "block", 45 | "permissions": [ 46 | { 47 | "added_timestamp": 1633890567665, 48 | "allow_duplicate": false, 49 | "allow_search_engine_indexing": false, 50 | "role": "reader", 51 | "type": "public_permission" 52 | } 53 | ], 54 | "properties": { 55 | "title": [["Comparing prices of VPS servers"]] 56 | }, 57 | "space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7", 58 | "type": "page", 59 | "version": 30 60 | } 61 | } 62 | } 63 | } 64 | } 65 | ` 66 | ) 67 | 68 | func TestSyncRecordValues1(t *testing.T) { 69 | var rsp SyncRecordValuesResponse 70 | d := []byte(syncRecordValuesJSON_1) 71 | err := jsonit.Unmarshal(d, &rsp) 72 | require.NoError(t, err) 73 | err = jsonit.Unmarshal(d, &rsp.RawJSON) 74 | require.NoError(t, err) 75 | err = ParseRecordMap(rsp.RecordMap) 76 | require.NoError(t, err) 77 | blocks := rsp.RecordMap.Blocks 78 | require.Equal(t, 1, len(blocks)) 79 | 80 | { 81 | blockV := blocks["c3039398-9ae5-49c3-a39f-21ca5a681d72"] 82 | block := blockV.Block 83 | require.Equal(t, BlockPage, block.Type) 84 | require.Equal(t, 3, len(block.ContentIDs)) 85 | require.Equal(t, true, block.FormatPage().PageFullWidth) 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /api_getUploadFileUrl_test.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "testing" 7 | 8 | "github.com/kjk/common/assert" 9 | ) 10 | 11 | const ( 12 | getUploadFileURLJSON1 = ` 13 | { 14 | "url": "https://s3-us-west-2.amazonaws.com/secure.notion-static.com/246e2166-e2d6-4396-82b5-559c723f57f9/test_file.svg", 15 | "signedGetUrl": "https://s3.us-west-1.amazonaws.com/SignedGetUrl", 16 | "signedPutUrl": "https://s3.us-west-2.amazonaws.com/SignedPutUrl" 17 | } 18 | ` 19 | ) 20 | 21 | func isUploadTestEnabled() bool { 22 | v := os.Getenv("ENABLE_UPLOAD_TEST") 23 | return v != "" 24 | } 25 | 26 | func TestGetUploadFileURLResponse(t *testing.T) { 27 | // TODO: re-enable test 28 | if !isUploadTestEnabled() { 29 | return 30 | } 31 | var res GetUploadFileUrlResponse 32 | err := json.Unmarshal([]byte(getUploadFileURLJSON1), &res) 33 | assert.NoError(t, err) 34 | 35 | assert.Equal(t, res.URL, "https://s3-us-west-2.amazonaws.com/secure.notion-static.com/246e2166-e2d6-4396-82b5-559c723f57f9/test_file.svg") 36 | assert.Equal(t, res.SignedGetURL, "https://s3.us-west-1.amazonaws.com/SignedGetUrl") 37 | assert.Equal(t, res.SignedPutURL, "https://s3.us-west-2.amazonaws.com/SignedPutUrl") 38 | 39 | res.Parse() 40 | assert.Equal(t, res.FileID, "246e2166-e2d6-4396-82b5-559c723f57f9") 41 | } 42 | 43 | func TestUploadFile(t *testing.T) { 44 | // TODO: re-enable test 45 | if !isUploadTestEnabled() { 46 | return 47 | } 48 | 49 | const injectionPointText = "Graph (Autogenerated - DO NOT EDIT)" 50 | 51 | client := &Client{ 52 | AuthToken: "", 53 | Logger: os.Stdout, 54 | } 55 | 56 | page, err := client.DownloadPage("6b181fb69a7945ed8c5f424bcb34721c") 57 | assert.NoError(t, err) 58 | 59 | root := page.Root() 60 | var parent, embeddedBlock *Block 61 | for _, b := range root.Content { 62 | if b.Type != BlockToggle { 63 | continue 64 | } 65 | 66 | prop := b.GetProperty("title") 67 | if len(prop) != 1 || prop[0].Text != injectionPointText { 68 | continue 69 | } 70 | parent = b 71 | 72 | if len(b.Content) == 0 { 73 | break 74 | } 75 | 76 | assert.Len(t, b.Content, 1) 77 | assert.Equal(t, b.Content[0].Type, BlockEmbed) 78 | embeddedBlock = b.Content[0] 79 | 80 | break 81 | } 82 | assert.NotEmpty(t, parent) 83 | 84 | // "a485fd92-b373-47e8-a417-f298689e344b" 85 | userID := page.UserRecords[0].Block.ID 86 | 87 | file, err := os.Open("test_file.svg") 88 | assert.NoError(t, err) 89 | 90 | fileID, fileURL, err := client.UploadFile(file) 91 | assert.NoError(t, err) 92 | 93 | var ops []*Operation 94 | if embeddedBlock == nil { 95 | embeddedBlock, ops = parent.EmbedUploadedFileOps(client, userID, fileID, fileURL) 96 | assert.NoError(t, err) 97 | 98 | ops = append(ops, parent.ListAfterContentOp(embeddedBlock.ID, "")) 99 | } else { 100 | ops = embeddedBlock.UpdateEmbeddedFileOps(userID, fileID, fileURL) 101 | assert.NoError(t, err) 102 | } 103 | 104 | err = client.SubmitTransaction(ops) 105 | assert.NoError(t, err) 106 | 107 | t.Logf("got newBlock: %#v", embeddedBlock) 108 | } 109 | -------------------------------------------------------------------------------- /record.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // Record represents a polymorphic record 9 | type Record struct { 10 | // fields returned by the server 11 | Role string `json:"role"` 12 | // polymorphic value of the record, which we decode into Block, Space etc. 13 | Value json.RawMessage `json:"value"` 14 | 15 | // fields calculated from Value based on type 16 | ID string `json:"-"` 17 | Table string `json:"-"` 18 | Activity *Activity `json:"-"` 19 | Block *Block `json:"-"` 20 | Space *Space `json:"-"` 21 | NotionUser *NotionUser `json:"-"` 22 | UserRoot *UserRoot `json:"-"` 23 | UserSettings *UserSettings `json:"-"` 24 | Collection *Collection `json:"-"` 25 | CollectionView *CollectionView `json:"-"` 26 | Comment *Comment `json:"-"` 27 | Discussion *Discussion `json:"-"` 28 | // TODO: add more types 29 | } 30 | 31 | // table is not always present in Record returned by the server 32 | // so must be provided based on what was asked 33 | func parseRecord(table string, r *Record) error { 34 | // it's ok if some records don't return a value 35 | if len(r.Value) == 0 { 36 | return nil 37 | } 38 | if r.Table == "" { 39 | r.Table = table 40 | } else { 41 | // TODO: probably never happens 42 | panicIf(r.Table != table) 43 | } 44 | 45 | // set Block/Space etc. based on TableView type 46 | var pRawJSON *map[string]interface{} 47 | var obj interface{} 48 | switch table { 49 | case TableActivity: 50 | r.Activity = &Activity{} 51 | obj = r.Activity 52 | pRawJSON = &r.Activity.RawJSON 53 | case TableBlock: 54 | r.Block = &Block{} 55 | obj = r.Block 56 | pRawJSON = &r.Block.RawJSON 57 | case TableNotionUser: 58 | r.NotionUser = &NotionUser{} 59 | obj = r.NotionUser 60 | pRawJSON = &r.NotionUser.RawJSON 61 | case TableUserRoot: 62 | r.UserRoot = &UserRoot{} 63 | obj = r.UserRoot 64 | pRawJSON = &r.UserRoot.RawJSON 65 | case TableUserSettings: 66 | r.UserSettings = &UserSettings{} 67 | obj = r.UserSettings 68 | pRawJSON = &r.UserSettings.RawJSON 69 | case TableSpace: 70 | r.Space = &Space{} 71 | obj = r.Space 72 | pRawJSON = &r.Space.RawJSON 73 | case TableCollection: 74 | r.Collection = &Collection{} 75 | obj = r.Collection 76 | pRawJSON = &r.Collection.RawJSON 77 | case TableCollectionView: 78 | r.CollectionView = &CollectionView{} 79 | obj = r.CollectionView 80 | pRawJSON = &r.CollectionView.RawJSON 81 | case TableDiscussion: 82 | r.Discussion = &Discussion{} 83 | obj = r.Discussion 84 | pRawJSON = &r.Discussion.RawJSON 85 | case TableComment: 86 | r.Comment = &Comment{} 87 | obj = r.Comment 88 | pRawJSON = &r.Comment.RawJSON 89 | } 90 | if obj == nil { 91 | return fmt.Errorf("unsupported table '%s'", r.Table) 92 | } 93 | if err := jsonit.Unmarshal(r.Value, pRawJSON); err != nil { 94 | return err 95 | } 96 | id := (*pRawJSON)["id"] 97 | if id != nil { 98 | r.ID = id.(string) 99 | } 100 | if err := jsonit.Unmarshal(r.Value, &obj); err != nil { 101 | return err 102 | } 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /date.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // Date represents a date 10 | type Date struct { 11 | // "MMM DD, YYYY", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD", "relative" 12 | DateFormat string `json:"date_format"` 13 | Reminder *Reminder `json:"reminder,omitempty"` 14 | // "2018-07-12" 15 | StartDate string `json:"start_date"` 16 | // "09:00" 17 | StartTime string `json:"start_time,omitempty"` 18 | // "2018-07-12" 19 | EndDate string `json:"end_date,omitempty"` 20 | // "09:00" 21 | EndTime string `json:"end_time,omitempty"` 22 | // "America/Los_Angeles" 23 | TimeZone *string `json:"time_zone,omitempty"` 24 | // "H:mm" for 24hr, not given for 12hr 25 | TimeFormat string `json:"time_format,omitempty"` 26 | // "date", "datetime", "datetimerange", "daterange" 27 | Type string `json:"type"` 28 | } 29 | 30 | // Reminder represents a date reminder 31 | type Reminder struct { 32 | Time string `json:"time"` // e.g. "09:00" 33 | Unit string `json:"unit"` // e.g. "day" 34 | Value int64 `json:"value"` 35 | } 36 | 37 | // parseNotionDateTime parses date and time as sent in JSON by notion 38 | // server and returns time.Time 39 | // date is sent in "2019-04-09" format 40 | // time is optional and sent in "00:35" format 41 | func parseNotionDateTime(date string, t string) time.Time { 42 | s := date 43 | fmt := "2006-01-02" 44 | if t != "" { 45 | fmt += " 15:04" 46 | s += " " + t 47 | } 48 | dt, err := time.Parse(fmt, s) 49 | if err != nil { 50 | MaybePanic("time.Parse('%s', '%s') failed with %s", fmt, s, err) 51 | } 52 | return dt 53 | } 54 | 55 | // convertNotionTimeFormatToGoFormat converts a date format sent from Notion 56 | // server, e.g. "MMM DD, YYYY" to Go time format like "02 01, 2006" 57 | // YYYY is numeric year => 2006 in Go 58 | // MM is numeric month => 01 in Go 59 | // DD is numeric day => 02 in Go 60 | // MMM is named month => Jan in Go 61 | func convertNotionTimeFormatToGoFormat(d *Date, withTime bool) string { 62 | format := d.DateFormat 63 | // we don't support relative time, so use this fixed format 64 | if format == "relative" || format == "" { 65 | format = "MMM DD, YYYY" 66 | } 67 | s := format 68 | s = strings.Replace(s, "MMM", "Jan", -1) 69 | s = strings.Replace(s, "MM", "01", -1) 70 | s = strings.Replace(s, "DD", "02", -1) 71 | s = strings.Replace(s, "YYYY", "2006", -1) 72 | if withTime { 73 | // this is 24 hr format 74 | if d.TimeFormat == "H:mm" { 75 | s += " 15:04" 76 | } else { 77 | // use 12 hr format 78 | s += " 3:04 PM" 79 | } 80 | } 81 | return s 82 | } 83 | 84 | // formatDateTime formats date/time from Notion canonical format to 85 | // user-requested format 86 | func formatDateTime(d *Date, date string, t string) string { 87 | withTime := t != "" 88 | dt := parseNotionDateTime(date, t) 89 | goFormat := convertNotionTimeFormatToGoFormat(d, withTime) 90 | s := dt.Format(goFormat) 91 | // TODO: this is a lousy way of doing it 92 | for i := 0; i <= 9; i++ { 93 | toReplace := fmt.Sprintf("0%d:", i) 94 | replacement := fmt.Sprintf("%d:", i) 95 | s = strings.Replace(s, toReplace, replacement, 1) 96 | } 97 | // TODO: also timezone 98 | return s 99 | } 100 | 101 | // FormatDate provides default formatting for Date 102 | // TODO: add time zone, maybe 103 | func FormatDate(d *Date) string { 104 | s := formatDateTime(d, d.StartDate, d.StartTime) 105 | if strings.Contains(d.Type, "range") { 106 | s2 := formatDateTime(d, d.EndDate, d.EndTime) 107 | s += " → " + s2 108 | } 109 | return s 110 | } 111 | -------------------------------------------------------------------------------- /api_getSubscriptionData.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | type SubscriptionDataSpaceUsers struct { 4 | UserID string `json:"userId"` 5 | Role string `json:"role"` 6 | IsGuest bool `json:"isGuest"` 7 | GuestPageIds []interface{} `json:"guestPageIds"` 8 | } 9 | 10 | type SubscriptionDataCredits struct { 11 | ID string `json:"id"` 12 | Version int `json:"version"` 13 | UserID string `json:"user_id"` 14 | Amount int `json:"amount"` 15 | Activated bool `json:"activated"` 16 | CreatedTimestamp string `json:"created_timestamp"` 17 | Type string `json:"type"` 18 | } 19 | 20 | type SubscriptionDataAddress struct { 21 | Name string `json:"name"` 22 | BusinessName string `json:"businessName"` 23 | AddressLine1 string `json:"addressLine1"` 24 | AddressLine2 string `json:"addressLine2"` 25 | ZipCode string `json:"zipCode"` 26 | City string `json:"city"` 27 | State string `json:"state"` 28 | Country string `json:"country"` 29 | } 30 | 31 | type SubscriptionData struct { 32 | Type string `json:"type"` 33 | SpaceUsers []SubscriptionDataSpaceUsers `json:"spaceUsers"` 34 | Credits []SubscriptionDataCredits `json:"credits"` 35 | TotalCredit int `json:"totalCredit"` 36 | AvailableCredit int `json:"availableCredit"` 37 | CreditEnabled bool `json:"creditEnabled"` 38 | CustomerID string `json:"customerId"` 39 | CustomerName string `json:"customerName"` 40 | VatID string `json:"vatId"` 41 | IsDelinquent bool `json:"isDelinquent"` 42 | ProductID string `json:"productId"` 43 | BillingEmail string `json:"billingEmail"` 44 | Plan string `json:"plan"` 45 | PlanAmount int `json:"planAmount"` 46 | AccountBalance int `json:"accountBalance"` 47 | MonthlyPlanAmount int `json:"monthlyPlanAmount"` 48 | YearlyPlanAmount int `json:"yearlyPlanAmount"` 49 | Quantity int `json:"quantity"` 50 | Billing string `json:"billing"` 51 | Address SubscriptionDataAddress `json:"address"` 52 | Last4 string `json:"last4"` 53 | Brand string `json:"brand"` 54 | Interval string `json:"interval"` 55 | Created int64 `json:"created"` 56 | PeriodEnd int64 `json:"periodEnd"` 57 | NextInvoiceTime int64 `json:"nextInvoiceTime"` 58 | NextInvoiceAmount int `json:"nextInvoiceAmount"` 59 | IsPaid bool `json:"isPaid"` 60 | Members []interface{} `json:"members"` 61 | 62 | RawJSON map[string]interface{} `json:"-"` 63 | } 64 | 65 | // GetSubscriptionData executes a raw API call /api/v3/getSubscriptionData 66 | func (c *Client) GetSubscriptionData(spaceID string) (*SubscriptionData, error) { 67 | req := &struct { 68 | SpaceID string `json:"spaceId"` 69 | }{ 70 | SpaceID: spaceID, 71 | } 72 | 73 | var rsp SubscriptionData 74 | var err error 75 | apiURL := "/api/v3/getSubscriptionData" 76 | err = c.doNotionAPI(apiURL, req, &rsp, &rsp.RawJSON) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return &rsp, nil 82 | } 83 | -------------------------------------------------------------------------------- /do/sanity.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/kjk/notionapi" 8 | ) 9 | 10 | // https://www.notion.so/Comparing-prices-of-VPS-servers-c30393989ae549c3a39f21ca5a681d72 11 | func testGetBlockRecords() { 12 | c := newClient() 13 | ids := []string{"c30393989ae549c3a39f21ca5a681d72"} 14 | blocks, err := c.GetBlockRecords(ids) 15 | must(err) 16 | panicIf(len(blocks) != 1) 17 | dashID := notionapi.ToDashID(ids[0]) 18 | panicIf(blocks[0].ID != dashID) 19 | for _, r := range blocks { 20 | logf("testSyncRecordValues: id: '%s'\n", r.ID) 21 | } 22 | } 23 | 24 | // https://www.notion.so/Test-text-4c6a54c68b3e4ea2af9cfaabcc88d58d 25 | func testLoadCachePageChunk() { 26 | c := newClient() 27 | pageID := notionapi.ToDashID("4c6a54c68b3e4ea2af9cfaabcc88d58d") 28 | rsp, err := c.LoadCachedPageChunk(pageID, 0, nil) 29 | must(err) 30 | // fmt.Printf("rsp:\n%#v\n\n", rsp) 31 | for blockID, block := range rsp.RecordMap.Blocks { 32 | fmt.Printf("blockID: %s, block.ID: %s\n", blockID, block.ID) 33 | panicIf(blockID != block.ID) 34 | } 35 | } 36 | 37 | func testQueryDecode() { 38 | s := `{ 39 | "aggregate": [ 40 | { 41 | "aggregation_type": "count", 42 | "id": "count", 43 | "property": "title", 44 | "type": "title", 45 | "view_type": "table" 46 | } 47 | ] 48 | }` 49 | var v notionapi.Query 50 | err := json.Unmarshal([]byte(s), &v) 51 | must(err) 52 | } 53 | 54 | func testSubPages() { 55 | // test that GetSubPages() only returns direct children 56 | // of a page, not link to pages 57 | client := newClient() 58 | uri := "https://www.notion.so/Test-sub-pages-in-mono-font-381243f4ba4d4670ac491a3da87b8994" 59 | pageID := "381243f4ba4d4670ac491a3da87b8994" 60 | page, err := client.DownloadPage(pageID) 61 | must(err) 62 | subPages := page.GetSubPages() 63 | nExp := 7 64 | panicIf(len(subPages) != nExp, "expected %d sub-pages of '%s', got %d", nExp, uri, len(subPages)) 65 | logf("ok\ttestSubPages()\n") 66 | } 67 | 68 | // TODO: this fails now 69 | func testQueryCollection() { 70 | // test for table on https://www.notion.so/Comparing-prices-of-VPS-servers-c30393989ae549c3a39f21ca5a681d72 71 | c := newClient() 72 | spaceID := "bc202e06-6caa-4e3f-81eb-f226ab5deef7" 73 | collectionID := "0567b270-3cb1-44e4-847c-34a843f55dfc" 74 | collectionViewID := "74e9cd84-ff2d-4259-bd56-5f8478da8839" 75 | req := notionapi.QueryCollectionRequest{} 76 | req.Collection.ID = collectionID 77 | req.Collection.SpaceID = spaceID 78 | req.CollectionView.ID = collectionViewID 79 | req.CollectionView.SpaceID = spaceID 80 | sort := notionapi.QuerySort{ 81 | ID: "6e89c507-e0da-47c7-b8c8-fe2b336e0985", 82 | Type: "number", 83 | Property: "E13y", 84 | Direction: "ascending", 85 | } 86 | q := notionapi.Query{ 87 | Sort: []notionapi.QuerySort{sort}, 88 | } 89 | res, err := c.QueryCollection(req, &q) 90 | must(err) 91 | colRes := res.Result.ReducerResults.CollectionGroupResults 92 | panicIf(colRes.Total != 18, "colRes.Total == %d", colRes.Total) 93 | panicIf(len(colRes.BlockIds) != 18) 94 | panicIf(colRes.Type != "results") 95 | //fmt.Printf("%#v\n", colRes) 96 | } 97 | 98 | // sanity tests are basic tests to validate changes 99 | // meant to not take too long 100 | func sanityTests() { 101 | logf("Running sanity tests\n") 102 | testQueryDecode() 103 | 104 | runGoTests() 105 | testGetBlockRecords() 106 | testLoadCachePageChunk() 107 | testSubPages() 108 | 109 | // TODO: something must have changed on the server and this test fails now 110 | // testQueryCollection() 111 | 112 | // queryCollectionApi changed 113 | pageID := "c30393989ae549c3a39f21ca5a681d72" 114 | testCachingDownloads(pageID) 115 | logf("ok\ttestCachingDownloads() of %s ok!\n", pageID) 116 | // TODO: more tests? 117 | } 118 | -------------------------------------------------------------------------------- /export_page.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | const ( 9 | eventExportBlock = "exportBlock" 10 | defaultExportTimeZone = "America/Los_Angeles" 11 | statusComplete = "complete" 12 | ExportTypeMarkdown = "markdown" 13 | ExportTypeHTML = "html" 14 | ) 15 | 16 | type exportPageTaskRequest struct { 17 | Task *exportPageTask `json:"task"` 18 | } 19 | 20 | type exportPageTask struct { 21 | EventName string `json:"eventName"` 22 | Request *exportPageRequest `json:"request"` 23 | } 24 | 25 | type exportPageRequest struct { 26 | BlockID string `json:"blockId"` 27 | Recursive bool `json:"recursive"` 28 | ExportOptions *exportPageOptions `json:"exportOptions"` 29 | } 30 | 31 | type exportPageOptions struct { 32 | ExportType string `json:"exportType"` 33 | TimeZone string `json:"timeZone"` 34 | } 35 | 36 | type enqueueTaskResponse struct { 37 | TaskID string `json:"taskId"` 38 | RawJSON map[string]interface{} `json:"-"` 39 | } 40 | 41 | type getTasksExportPageResponse struct { 42 | Results []*exportPageResult `json:"results"` 43 | } 44 | 45 | type exportPageResult struct { 46 | ID string `json:"id"` 47 | EventName string `json:"eventName"` 48 | Request *exportPageRequest `json:"request"` 49 | UserID string `json:"userId"` 50 | State string `json:"state"` 51 | Status *exportPageStatus `json:"status"` 52 | } 53 | 54 | type exportPageStatus struct { 55 | Type string `json:"type"` 56 | ExportURL string `json:"exportURL"` 57 | PagesExported int64 `json:"pagesExported"` 58 | } 59 | 60 | type getTasksRequest struct { 61 | TaskIDS []string `json:"taskIds"` 62 | } 63 | 64 | // RequestPageExportURL executes a raw API call to enqueue an export of pages 65 | // and returns the URL to the exported data once the task is complete 66 | func (c *Client) RequestPageExportURL(id string, exportType string, recursive bool) (string, error) { 67 | id = ToDashID(id) 68 | if !IsValidDashID(id) { 69 | return "", fmt.Errorf("'%s' is not a valid notion id", id) 70 | } 71 | 72 | req := &exportPageTaskRequest{ 73 | Task: &exportPageTask{ 74 | EventName: eventExportBlock, 75 | Request: &exportPageRequest{ 76 | BlockID: id, 77 | Recursive: recursive, 78 | ExportOptions: &exportPageOptions{ 79 | ExportType: exportType, 80 | TimeZone: defaultExportTimeZone, 81 | }, 82 | }, 83 | }, 84 | } 85 | 86 | var rsp enqueueTaskResponse 87 | var err error 88 | apiURL := "/api/v3/enqueueTask" 89 | err = c.doNotionAPI(apiURL, req, &rsp, &rsp.RawJSON) 90 | if err != nil { 91 | return "", err 92 | } 93 | 94 | var exportURL string 95 | taskID := rsp.TaskID 96 | for { 97 | time.Sleep(250 * time.Millisecond) 98 | req := getTasksRequest{ 99 | TaskIDS: []string{taskID}, 100 | } 101 | var err error 102 | var rsp getTasksExportPageResponse 103 | apiURL = "/api/v3/getTasks" 104 | err = c.doNotionAPI(apiURL, req, &rsp, nil) 105 | if err != nil { 106 | return "", err 107 | } 108 | status := rsp.Results[0].Status 109 | if status != nil && status.Type == statusComplete { 110 | exportURL = status.ExportURL 111 | break 112 | } 113 | time.Sleep(750 * time.Millisecond) 114 | } 115 | 116 | return exportURL, nil 117 | } 118 | 119 | // ExportPages exports a page as html or markdown, potentially recursively 120 | func (c *Client) ExportPages(id string, exportType string, recursive bool) ([]byte, error) { 121 | exportURL, err := c.RequestPageExportURL(id, exportType, recursive) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | dlRsp, err := c.DownloadFile(exportURL, nil) 127 | if err != nil { 128 | return nil, err 129 | } 130 | return dlRsp.Data, nil 131 | } 132 | -------------------------------------------------------------------------------- /download_file.go: -------------------------------------------------------------------------------- 1 | package notionapi 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | ) 11 | 12 | // DownloadFileResponse is a result of DownloadFile() 13 | type DownloadFileResponse struct { 14 | URL string 15 | CacheFilePath string 16 | Data []byte 17 | Header http.Header 18 | FromCache bool 19 | } 20 | 21 | // DownloadURL downloads a given url with possibly authenticated client 22 | func (c *Client) DownloadURL(uri string) (*DownloadFileResponse, error) { 23 | req, err := http.NewRequest("GET", uri, nil) 24 | if err != nil { 25 | //fmt.Printf("DownloadURL: NewRequest() for '%s' failed with '%s'\n", uri, err) 26 | return nil, err 27 | } 28 | if c.AuthToken != "" { 29 | req.Header.Set("cookie", fmt.Sprintf("token_v2=%v", c.AuthToken)) 30 | } 31 | httpClient := c.getHTTPClient() 32 | resp, err := httpClient.Do(req) 33 | if err != nil { 34 | //fmt.Printf("DownloadFile: httpClient.Do() for '%s' failed with '%s'\n", uri, err) 35 | return nil, err 36 | } 37 | defer resp.Body.Close() 38 | if resp.StatusCode >= 400 { 39 | //fmt.Printf("DownloadFile: httpClient.Do() for '%s' failed with '%s'\n", uri, resp.Status) 40 | return nil, fmt.Errorf("http GET '%s' failed with status %s", uri, resp.Status) 41 | } 42 | var buf bytes.Buffer 43 | _, err = io.Copy(&buf, resp.Body) 44 | if err != nil { 45 | return nil, err 46 | } 47 | rsp := &DownloadFileResponse{ 48 | Data: buf.Bytes(), 49 | Header: resp.Header, 50 | } 51 | return rsp, nil 52 | } 53 | 54 | const ( 55 | notionImageProxy = "https://www.notion.so/image/" 56 | s3FileURLPrefix = "https://s3-us-west-2.amazonaws.com/secure.notion-static.com/" 57 | ) 58 | 59 | // sometimes image url in "source" is not accessible but can 60 | // be accessed when proxied via notion server as 61 | // www.notion.so/image/${source}?table=${parentTable}&id=${blockID} 62 | // This also allows resizing via ?width=${n} arguments 63 | func maybeProxyImageURL(uri string, block *Block) string { 64 | 65 | if strings.HasPrefix(uri, "https://cdn.dutchcowboys.nl/uploads") { 66 | return uri 67 | } 68 | if strings.HasPrefix(uri, "https://images.unsplash.com") { 69 | return uri 70 | } 71 | 72 | // TODO: not sure about this one anymore 73 | if strings.HasPrefix(uri, "https://www.notion.so/images/") { 74 | return uri 75 | } 76 | 77 | // from: /images/page-cover/met_vincent_van_gogh_cradle.jpg 78 | // => 79 | // https://www.notion.so/image/https%3A%2F%2Fwww.notion.so%2Fimages%2Fpage-cover%2Fmet_vincent_van_gogh_cradle.jpg?width=3290 80 | if strings.HasPrefix(uri, "/images/page-cover/") { 81 | return "https://www.notion.so" + uri 82 | } 83 | 84 | if block == nil { 85 | return uri 86 | } 87 | blockID := block.ID 88 | parentTable := block.ParentTable 89 | 90 | if strings.HasPrefix(uri, notionImageProxy) { 91 | uri = uri + "?table=" + parentTable + "&id=" + blockID 92 | return uri 93 | } 94 | 95 | if !strings.Contains(uri, s3FileURLPrefix) { 96 | return uri 97 | } 98 | 99 | uri = notionImageProxy + url.PathEscape(uri) + "?table=" + parentTable + "&id=" + blockID 100 | return uri 101 | } 102 | 103 | // DownloadFile downloads a file stored in Notion referenced 104 | // by a block with a given id and of a given block with a given 105 | // parent table (data present in Block) 106 | func (c *Client) DownloadFile(uri string, block *Block) (*DownloadFileResponse, error) { 107 | // first try downloading proxied url 108 | uri2 := maybeProxyImageURL(uri, block) 109 | res, err := c.DownloadURL(uri2) 110 | if err != nil && uri2 != uri { 111 | // otherwise just try your luck with original URL 112 | res, err = c.DownloadURL(uri) 113 | } 114 | if err != nil { 115 | rsp, err2 := c.GetSignedURLs([]string{uri}, block) 116 | if err2 != nil { 117 | return nil, err 118 | } 119 | if len(rsp.SignedURLS) == 0 { 120 | return nil, err 121 | } 122 | uri3 := rsp.SignedURLS[0] 123 | res, err = c.DownloadURL(uri3) 124 | } 125 | return res, err 126 | } 127 | -------------------------------------------------------------------------------- /do/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background: white; 4 | color: black; 5 | /* same as github.com */ 6 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, 7 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 8 | font-size: 12pt; 9 | line-height: 1.4; 10 | padding: 1em 2em; 11 | } 12 | 13 | .notion-callout { 14 | background-color: rgba(235, 236, 237, 0.3); 15 | padding: 16px 16px 16px 12px; 16 | width: 100%; 17 | display: flex; 18 | border-radius: 3px; 19 | border-width: 1px; 20 | border-style: solid; 21 | border-color: transparent; 22 | } 23 | 24 | /* set on
 element in 
 sequence */
 25 | .notion-code {
 26 |   display: block;
 27 |   padding: 0.5em;
 28 |   overflow-x: visible;
 29 | 
 30 |   tab-size: 2;
 31 | 
 32 |   font-size: 85%;
 33 | 
 34 |   border: 1px solid #e5e5e5;
 35 | 
 36 |   color: #657b83;
 37 |   /* background-color: #f9f9f9; */
 38 |   background-color: #fdfdfd;
 39 | }
 40 | 
 41 | .notion-code-inline {
 42 |   color: #657b83;
 43 |   /* background-color: #f9f9f9; */
 44 |   background-color: #fdfdfd;
 45 | }
 46 | 
 47 | .notion-todo-checked {
 48 |   color: lightgray;
 49 |   text-decoration: line-through;
 50 | }
 51 | 
 52 | /* sometimes we render children inside notion-wrap to provide indent */
 53 | div.notion-wrap {
 54 |   margin-left: 1em;
 55 | }
 56 | 
 57 | .notion-video {
 58 |   max-width: 100%;
 59 | }
 60 | 
 61 | .notion-page-title {
 62 |   font-size: 2em;
 63 |   font-weight: bold;
 64 |   padding: 4px 0;
 65 | }
 66 | 
 67 | .notion-sub-page::before {
 68 |   content: "\1f5cf";
 69 |   margin-right: 0.4em;
 70 | }
 71 | 
 72 | .notion-page-link::before {
 73 |   content: "\1f5cf\2197";
 74 |   margin-right: 0.4em;
 75 | }
 76 | 
 77 | .notion-todo-checked:before {
 78 |   content: "\2612";
 79 | }
 80 | 
 81 | .notion-todo::before {
 82 |   content: "\2610";
 83 | }
 84 | 
 85 | .header-anchor svg {
 86 |   opacity: 0.2;
 87 | }
 88 | 
 89 | .header-anchor:hover svg {
 90 |   opacity: 1;
 91 | }
 92 | 
 93 | /* Must explicitly size the "anchor" svg icon inside h* elements.
 94 | The sizes are chosen to look ok next to a lower-case letter, which is likely
 95 | to be at the end. The downside is that it won't look good if the last letter
 96 | is upper-case.
 97 | */
 98 | h1 a.header-anchor svg {
 99 |   height: 17px;
100 | }
101 | 
102 | h2 a.header-anchor svg {
103 |   height: 14px;
104 | }
105 | 
106 | h3 a.header-anchor svg {
107 |   height: 12px;
108 | }
109 | 
110 | /* Note: there might be more elements we need to add here */
111 | details.notion-toggle > div,
112 | details.notion-toggle > details {
113 |   margin-left: 1.4em;
114 | }
115 | 
116 | /* neutralize dy margin at the top and the bottom of lists
117 |    when nested inside toggle list */
118 | details.notion-toggle > ul,
119 | details.notion-toggle > ol {
120 |   margin-block-start: 0;
121 |   margin-block-end: 0;
122 | }
123 | 
124 | details.notion-toggle > summary::-webkit-details-marker:hover {
125 |   color: gray;
126 |   cursor: pointer;
127 | }
128 | 
129 | .notion-date {
130 |   opacity: 0.5;
131 | }
132 | 
133 | hr.notion-divider {
134 |   border: 0;
135 |   border-top: 1px solid #eee;
136 | }
137 | 
138 | /*
139 | style of an image  tag:
140 | - centered horizontally
141 | - limit width to container (i.e. the page)
142 | */
143 | img.notion-image {
144 |   margin-left: auto;
145 |   margin-right: auto;
146 |   max-width: 100%;
147 |   display: block; /* needed for margin-left/margin-right to work */
148 |   margin-top: 1em;
149 |   margin-bottom: 1em;
150 | }
151 | 
152 | div.notion-bookmark {
153 |   border: 1px solid #eee;
154 |   padding: 1em;
155 | }
156 | 
157 | div.notion-column-list {
158 |   display: flex;
159 |   width: 100%;
160 | }
161 | 
162 | /* size all columns equally */
163 | div.notion-column {
164 |   flex: 1;
165 | }
166 | 
167 | table.notion-collection-view {
168 |   background-color: white;
169 |   margin-bottom: 1em;
170 | }
171 | 
172 | table.notion-collection-view th {
173 |   color: rgb(165, 165, 165);
174 |   font-weight: normal;
175 |   border-right: 1px solid rgb(243, 243, 243);
176 |   border-bottom: 1px solid rgb(221, 225, 227);
177 |   border-top: 1px solid rgb(221, 225, 227);
178 |   padding: 1px 8px 1px;
179 |   margin: 0px;
180 | }
181 | 
182 | table.notion-collection-view td {
183 |   border-right: 1px solid rgb(243, 243, 243);
184 |   border-bottom: 1px solid rgb(221, 225, 227);
185 |   padding: 1px 8px 1px;
186 |   margin: 0px;
187 | }
188 | 
189 | div.notion-embed {
190 |   border: 1px solid #eee;
191 |   padding: 1em;
192 | }
193 | 


--------------------------------------------------------------------------------
/submit_transaction.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | import "time"
  4 | 
  5 | // Command Types
  6 | const (
  7 | 	CommandSet        = "set"
  8 | 	CommandUpdate     = "update"
  9 | 	CommandListAfter  = "listAfter"
 10 | 	CommandListRemove = "listRemove"
 11 | )
 12 | 
 13 | type submitTransactionRequest struct {
 14 | 	Operations []*Operation `json:"operations"`
 15 | }
 16 | 
 17 | // Operation describes a single operation sent
 18 | type Operation struct {
 19 | 	ID      string      `json:"id"`      // id of the block being modified
 20 | 	Table   string      `json:"table"`   // "block" etc.
 21 | 	Path    []string    `json:"path"`    // e.g. ["properties", "title"]
 22 | 	Command string      `json:"command"` // "set", "update", "listAfter"
 23 | 	Args    interface{} `json:"args"`
 24 | }
 25 | 
 26 | func (c *Client) SubmitTransaction(ops []*Operation) error {
 27 | 	req := &submitTransactionRequest{
 28 | 		Operations: ops,
 29 | 	}
 30 | 	// response is empty, as far as I can tell
 31 | 	var rsp map[string]interface{}
 32 | 	apiURL := "/api/v3/submitTransaction"
 33 | 	err := c.doNotionAPI(apiURL, req, &rsp, nil)
 34 | 	return err
 35 | }
 36 | 
 37 | // Now returns now in micro seconds as expected by the notion API
 38 | func Now() int64 {
 39 | 	return time.Now().Unix() * 1000
 40 | }
 41 | 
 42 | // buildOp creates an Operation for this block
 43 | func (b *Block) buildOp(command string, path []string, args interface{}) *Operation {
 44 | 	return &Operation{
 45 | 		ID:      b.ID,
 46 | 		Table:   "block",
 47 | 		Path:    path,
 48 | 		Command: command,
 49 | 		Args:    args,
 50 | 	}
 51 | }
 52 | 
 53 | // SetTitleOp creates an Operation to set the title property
 54 | func (b *Block) SetTitleOp(title string) *Operation {
 55 | 	return b.buildOp(CommandSet, []string{"properties", "title"}, [][]string{{title}})
 56 | }
 57 | 
 58 | // TODO: Generalize this for the other fields
 59 | // UpdatePropertiesOp creates an op to update the block's properties
 60 | func (b *Block) UpdatePropertiesOp(source string) *Operation {
 61 | 	return b.buildOp(CommandUpdate, []string{"properties"}, map[string]interface{}{
 62 | 		"source": [][]string{{source}},
 63 | 	})
 64 | }
 65 | 
 66 | // TODO: Make this work somehow for all of Block's fields
 67 | // UpdateOp creates an operation to update the block
 68 | func (b *Block) UpdateOp(block *Block) *Operation {
 69 | 	params := map[string]interface{}{}
 70 | 	if block.Type != "" {
 71 | 		params["type"] = block.Type
 72 | 	}
 73 | 	if block.LastEditedTime != 0 {
 74 | 		params["last_edited_time"] = block.LastEditedTime
 75 | 	}
 76 | 	if block.LastEditedBy != "" {
 77 | 		params["last_edited_by"] = block.LastEditedBy
 78 | 	}
 79 | 	return b.buildOp(CommandUpdate, []string{}, params)
 80 | }
 81 | 
 82 | // TODO: Make the input more strict
 83 | // UpdateFormatOp creates an operation to update the block's format
 84 | func (b *Block) UpdateFormatOp(params interface{}) *Operation {
 85 | 	return b.buildOp(CommandUpdate, []string{"format"}, params)
 86 | }
 87 | 
 88 | // ListAfterContentOp creates an operation to list a child block block after another one
 89 | // if afterID is empty the block will be listed as the last one
 90 | func (b *Block) ListAfterContentOp(id, afterID string) *Operation {
 91 | 	args := map[string]string{
 92 | 		"id": id,
 93 | 	}
 94 | 	if afterID != "" {
 95 | 		args["after"] = afterID
 96 | 	}
 97 | 	return b.buildOp(CommandListAfter, []string{"content"}, args)
 98 | }
 99 | 
100 | // ListRemoveContentOp creates an operation to remove a record from the block
101 | func (b *Block) ListRemoveContentOp(id string) *Operation {
102 | 	return b.buildOp(CommandListRemove, []string{"content"}, map[string]string{
103 | 		"id": id,
104 | 	})
105 | }
106 | 
107 | // ListAfterFileIDsOp creates an operation to set the file ID
108 | func (b *Block) ListAfterFileIDsOp(fileID string) *Operation {
109 | 	return b.buildOp(CommandListAfter, []string{"file_ids"}, map[string]string{
110 | 		"id": fileID,
111 | 	})
112 | }
113 | 
114 | /*
115 | func buildLastEditedTimeOp(id string) *Operation {
116 | 	args := map[string]interface{}{
117 | 		"last_edited_time": notionTimeNow(),
118 | 	}
119 | 	return &Operation{
120 | 		ID:      id,
121 | 		Table:   "block",
122 | 		Path:    []string{},
123 | 		Command: "update",
124 | 		Args:    args,
125 | 	}
126 | }
127 | 
128 | // TODO: add constants for known languages
129 | func buildUpdateCodeBlockLang(id string, lang string) *Operation {
130 | 	args := map[string]interface{}{
131 | 		"language": []string{lang},
132 | 	}
133 | 	return &Operation{
134 | 		ID:      id,
135 | 		Table:   "block",
136 | 		Path:    []string{"properties"},
137 | 		Command: "update",
138 | 		Args:    args,
139 | 	}
140 | }
141 | */
142 | 


--------------------------------------------------------------------------------
/api_loadCachedPageChunk.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | // /api/v3/loadCachedPageChunk request
  4 | type loadCachedPageChunkRequest struct {
  5 | 	Page            loadCachedPageChunkRequestPage `json:"page"`
  6 | 	ChunkNumber     int                            `json:"chunkNumber"`
  7 | 	Limit           int                            `json:"limit"`
  8 | 	Cursor          cursor                         `json:"cursor"`
  9 | 	VerticalColumns bool                           `json:"verticalColumns"`
 10 | }
 11 | 
 12 | type loadCachedPageChunkRequestPage struct {
 13 | 	ID string `json:"id"`
 14 | }
 15 | 
 16 | type cursor struct {
 17 | 	Stack [][]stack `json:"stack"`
 18 | }
 19 | 
 20 | type stack struct {
 21 | 	ID    string `json:"id"`
 22 | 	Index int    `json:"index"`
 23 | 	Table string `json:"table"`
 24 | }
 25 | 
 26 | // LoadPageChunkResponse is a response to /api/v3/loadPageChunk api
 27 | type LoadCachedPageChunkResponse struct {
 28 | 	RecordMap *RecordMap `json:"recordMap"`
 29 | 	Cursor    cursor     `json:"cursor"`
 30 | 
 31 | 	RawJSON map[string]interface{} `json:"-"`
 32 | }
 33 | 
 34 | // RecordMap contains a collections of blocks, a space, users, and collections.
 35 | type RecordMap struct {
 36 | 	Version         int                `json:"__version__"`
 37 | 	Activities      map[string]*Record `json:"activity"`
 38 | 	Blocks          map[string]*Record `json:"block"`
 39 | 	Spaces          map[string]*Record `json:"space"`
 40 | 	NotionUsers     map[string]*Record `json:"notion_user"`
 41 | 	UsersRoot       map[string]*Record `json:"user_root"`
 42 | 	UserSettings    map[string]*Record `json:"user_setting"`
 43 | 	Collections     map[string]*Record `json:"collection"`
 44 | 	CollectionViews map[string]*Record `json:"collection_view"`
 45 | 	Comments        map[string]*Record `json:"comment"`
 46 | 	Discussions     map[string]*Record `json:"discussion"`
 47 | }
 48 | 
 49 | // LoadPageChunk executes a raw API call /api/v3/loadCachedPageChunk
 50 | func (c *Client) LoadCachedPageChunk(pageID string, chunkNo int, cur *cursor) (*LoadCachedPageChunkResponse, error) {
 51 | 	// emulating notion's website api usage: 30 items on first request,
 52 | 	// 50 on subsequent requests
 53 | 	limit := 30
 54 | 	if cur == nil {
 55 | 		cur = &cursor{
 56 | 			// to mimic browser api which sends empty array for this argment
 57 | 			Stack: make([][]stack, 0),
 58 | 		}
 59 | 		limit = 50
 60 | 	}
 61 | 	req := &loadCachedPageChunkRequest{
 62 | 		ChunkNumber:     chunkNo,
 63 | 		Limit:           limit,
 64 | 		Cursor:          *cur,
 65 | 		VerticalColumns: false,
 66 | 	}
 67 | 	req.Page.ID = pageID
 68 | 	var rsp LoadCachedPageChunkResponse
 69 | 	var err error
 70 | 	apiURL := "/api/v3/loadCachedPageChunk"
 71 | 	if err = c.doNotionAPI(apiURL, req, &rsp, &rsp.RawJSON); err != nil {
 72 | 		return nil, err
 73 | 	}
 74 | 	if err = ParseRecordMap(rsp.RecordMap); err != nil {
 75 | 		return nil, err
 76 | 	}
 77 | 	return &rsp, nil
 78 | }
 79 | 
 80 | func ParseRecordMap(recordMap *RecordMap) error {
 81 | 	for _, r := range recordMap.Activities {
 82 | 		if err := parseRecord(TableActivity, r); err != nil {
 83 | 			return err
 84 | 		}
 85 | 	}
 86 | 
 87 | 	for _, r := range recordMap.Blocks {
 88 | 		if err := parseRecord(TableBlock, r); err != nil {
 89 | 			return err
 90 | 		}
 91 | 	}
 92 | 
 93 | 	for _, r := range recordMap.Spaces {
 94 | 		if err := parseRecord(TableSpace, r); err != nil {
 95 | 			return err
 96 | 		}
 97 | 	}
 98 | 
 99 | 	for _, r := range recordMap.NotionUsers {
100 | 		if err := parseRecord(TableNotionUser, r); err != nil {
101 | 			return err
102 | 		}
103 | 	}
104 | 
105 | 	for _, r := range recordMap.UsersRoot {
106 | 		if err := parseRecord(TableUserRoot, r); err != nil {
107 | 			return err
108 | 		}
109 | 	}
110 | 
111 | 	for _, r := range recordMap.UserSettings {
112 | 		if err := parseRecord(TableUserSettings, r); err != nil {
113 | 			return err
114 | 		}
115 | 	}
116 | 
117 | 	for _, r := range recordMap.CollectionViews {
118 | 		if err := parseRecord(TableCollectionView, r); err != nil {
119 | 			return err
120 | 		}
121 | 	}
122 | 
123 | 	for _, r := range recordMap.Collections {
124 | 		if err := parseRecord(TableCollection, r); err != nil {
125 | 			return err
126 | 		}
127 | 	}
128 | 
129 | 	for _, r := range recordMap.Discussions {
130 | 		if err := parseRecord(TableDiscussion, r); err != nil {
131 | 			return err
132 | 		}
133 | 	}
134 | 
135 | 	for _, r := range recordMap.Comments {
136 | 		if err := parseRecord(TableComment, r); err != nil {
137 | 			return err
138 | 		}
139 | 	}
140 | 
141 | 	return nil
142 | }
143 | 


--------------------------------------------------------------------------------
/util.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"io"
  6 | 	"strings"
  7 | )
  8 | 
  9 | type NotionID struct {
 10 | 	DashID   string
 11 | 	NoDashID string
 12 | }
 13 | 
 14 | func NewNotionID(maybeID string) *NotionID {
 15 | 	if IsValidDashID(maybeID) {
 16 | 		return &NotionID{
 17 | 			DashID:   maybeID,
 18 | 			NoDashID: ToNoDashID(maybeID),
 19 | 		}
 20 | 	}
 21 | 	if IsValidNoDashID(maybeID) {
 22 | 		return &NotionID{
 23 | 			DashID:   ToDashID(maybeID),
 24 | 			NoDashID: maybeID,
 25 | 		}
 26 | 	}
 27 | 	return nil
 28 | }
 29 | 
 30 | var (
 31 | 	dashIDLen   = len("2131b10c-ebf6-4938-a127-7089ff02dbe4")
 32 | 	noDashIDLen = len("2131b10cebf64938a1277089ff02dbe4")
 33 | )
 34 | 
 35 | // only hex chars seem to be valid
 36 | func isValidNoDashIDChar(c byte) bool {
 37 | 	switch {
 38 | 	case c >= '0' && c <= '9':
 39 | 		return true
 40 | 	case c >= 'a' && c <= 'f':
 41 | 		return true
 42 | 	case c >= 'A' && c <= 'F':
 43 | 		// currently not used but just in case notion starts using them
 44 | 		return true
 45 | 	}
 46 | 	return false
 47 | }
 48 | 
 49 | func isValidDashIDChar(c byte) bool {
 50 | 	if c == '-' {
 51 | 		return true
 52 | 	}
 53 | 	return isValidNoDashIDChar(c)
 54 | }
 55 | 
 56 | // IsValidDashID returns true if id looks like a valid Notion dash id
 57 | func IsValidDashID(id string) bool {
 58 | 	if len(id) != dashIDLen {
 59 | 		return false
 60 | 	}
 61 | 	if id[8] != '-' ||
 62 | 		id[13] != '-' ||
 63 | 		id[18] != '-' ||
 64 | 		id[23] != '-' {
 65 | 		return false
 66 | 	}
 67 | 	for i := range id {
 68 | 		if !isValidDashIDChar(id[i]) {
 69 | 			return false
 70 | 		}
 71 | 	}
 72 | 	return true
 73 | }
 74 | 
 75 | // IsValidNoDashID returns true if id looks like a valid Notion no dash id
 76 | func IsValidNoDashID(id string) bool {
 77 | 	if len(id) != noDashIDLen {
 78 | 		return false
 79 | 	}
 80 | 	for i := range id {
 81 | 		if !isValidNoDashIDChar(id[i]) {
 82 | 			return false
 83 | 		}
 84 | 	}
 85 | 	return true
 86 | }
 87 | 
 88 | // ToNoDashID converts 2131b10c-ebf6-4938-a127-7089ff02dbe4
 89 | // to 2131b10cebf64938a1277089ff02dbe4.
 90 | // If not in expected format, we leave it untouched
 91 | func ToNoDashID(id string) string {
 92 | 	s := strings.Replace(id, "-", "", -1)
 93 | 	if IsValidNoDashID(s) {
 94 | 		return s
 95 | 	}
 96 | 	return ""
 97 | }
 98 | 
 99 | // ToDashID convert id in format bb760e2dd6794b64b2a903005b21870a
100 | // to bb760e2d-d679-4b64-b2a9-03005b21870a
101 | // If id is not in that format, we leave it untouched.
102 | func ToDashID(id string) string {
103 | 	if IsValidDashID(id) {
104 | 		return id
105 | 	}
106 | 	s := strings.Replace(id, "-", "", -1)
107 | 	if len(s) != noDashIDLen {
108 | 		return id
109 | 	}
110 | 	res := id[:8] + "-" + id[8:12] + "-" + id[12:16] + "-" + id[16:20] + "-" + id[20:]
111 | 	return res
112 | }
113 | 
114 | func isIDEqual(id1, id2 string) bool {
115 | 	id1 = ToNoDashID(id1)
116 | 	id2 = ToNoDashID(id2)
117 | 	return id1 == id2
118 | }
119 | 
120 | func isSafeChar(r rune) bool {
121 | 	if r >= '0' && r <= '9' {
122 | 		return true
123 | 	}
124 | 	if r >= 'a' && r <= 'z' {
125 | 		return true
126 | 	}
127 | 	if r >= 'A' && r <= 'Z' {
128 | 		return true
129 | 	}
130 | 	return false
131 | }
132 | 
133 | // SafeName returns a file-system safe name
134 | func SafeName(s string) string {
135 | 	var res string
136 | 	for _, r := range s {
137 | 		if !isSafeChar(r) {
138 | 			res += "-"
139 | 		} else {
140 | 			res += string(r)
141 | 		}
142 | 	}
143 | 	// replace multi-dash with single dash
144 | 	for strings.Contains(res, "--") {
145 | 		res = strings.Replace(res, "--", "-", -1)
146 | 	}
147 | 	res = strings.TrimLeft(res, "-")
148 | 	res = strings.TrimRight(res, "-")
149 | 	return res
150 | }
151 | 
152 | // ErrPageNotFound is returned by Client.DownloadPage if page
153 | // cannot be found
154 | type ErrPageNotFound struct {
155 | 	PageID string
156 | }
157 | 
158 | func newErrPageNotFound(pageID string) *ErrPageNotFound {
159 | 	return &ErrPageNotFound{
160 | 		PageID: pageID,
161 | 	}
162 | }
163 | 
164 | // Error return error string
165 | func (e *ErrPageNotFound) Error() string {
166 | 	pageID := ToNoDashID(e.PageID)
167 | 	return fmt.Sprintf("couldn't retrieve page '%s'", pageID)
168 | }
169 | 
170 | // IsErrPageNotFound returns true if err is an instance of ErrPageNotFound
171 | func IsErrPageNotFound(err error) bool {
172 | 	_, ok := err.(*ErrPageNotFound)
173 | 	return ok
174 | }
175 | 
176 | func closeNoError(c io.Closer) {
177 | 	_ = c.Close()
178 | }
179 | 
180 | // log JSON after pretty printing it
181 | func logJSON(client *Client, js []byte) {
182 | 	pp := string(PrettyPrintJS(js))
183 | 	client.vlogf("%s\n\n", pp)
184 | 	// fmt.Printf("%s\n\n", pp)
185 | }
186 | 


--------------------------------------------------------------------------------
/do/handlers.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"errors"
  6 | 	"fmt"
  7 | 	"html/template"
  8 | 	"net/http"
  9 | 	"net/url"
 10 | 	"path/filepath"
 11 | 	"strings"
 12 | 	"time"
 13 | 
 14 | 	"github.com/kjk/notionapi/tohtml"
 15 | 
 16 | 	"github.com/kjk/notionapi/tomarkdown"
 17 | )
 18 | 
 19 | const (
 20 | 	mimeTypeHTML       = "text/html; charset=utf-8"
 21 | 	mimeTypeText       = "text/plain"
 22 | 	mimeTypeJavaScript = "text/javascript; charset=utf-8"
 23 | 	mimeTypeMarkdown   = "text/markdown; charset=UTF-8"
 24 | )
 25 | 
 26 | var (
 27 | 	templates *template.Template
 28 | )
 29 | 
 30 | func reloadTemplates() {
 31 | 	var err error
 32 | 	pattern := filepath.Join("do", "*.tmpl.html")
 33 | 	templates, err = template.ParseGlob(pattern)
 34 | 	must(err)
 35 | }
 36 | 
 37 | func previewToMD(pageID string) ([]byte, error) {
 38 | 	client := makeNotionClient()
 39 | 	page, err := downloadPage(client, pageID)
 40 | 	if err != nil {
 41 | 		logf("previewToMD: downloadPage() failed with '%s'\n", err)
 42 | 		return nil, err
 43 | 	}
 44 | 	if page == nil {
 45 | 		logf("toHTML: page is nil\n")
 46 | 		return nil, errors.New("page == nil")
 47 | 	}
 48 | 	conv := tomarkdown.NewConverter(page)
 49 | 	// change https://www.notion.so/Advanced-web-spidering-with-Puppeteer-ea07db1b9bff415ab180b0525f3898f6
 50 | 	// =>
 51 | 	// /testmarkdown#${pageID}
 52 | 	rewriteURL := func(uri string) string {
 53 | 		logf("rewriteURL: '%s'", uri)
 54 | 		// ExtractNoDashIDFromNotionURL() only checks if last part of the url
 55 | 		// is a valid id. We only want to
 56 | 		parsedURL, _ := url.Parse(uri)
 57 | 		if !strings.Contains(uri, "notion.so") {
 58 | 			logf("\n")
 59 | 			return uri
 60 | 		}
 61 | 		//idStr := notionapi.ExtractNoDashIDFromNotionURL(uri)
 62 | 		id := extractNotionIDFromURL(uri)
 63 | 		if id == "" {
 64 | 			if parsedURL != nil {
 65 | 				//idStr = notionapi.ExtractNoDashIDFromNotionURL(parsedURL.Path)
 66 | 				id = extractNotionIDFromURL(uri)
 67 | 			}
 68 | 			if id == "" {
 69 | 				logf("\n")
 70 | 				return uri
 71 | 			}
 72 | 		}
 73 | 
 74 | 		res := "/previewmd/" + id
 75 | 		logf("=> '%s'\n", res)
 76 | 		// TODO: maybe preserve ?queryargs
 77 | 		return res
 78 | 	}
 79 | 
 80 | 	conv.RewriteURL = rewriteURL
 81 | 	d := conv.ToMarkdown()
 82 | 	return d, nil
 83 | }
 84 | 
 85 | func previewToHTML(pageID string) ([]byte, error) {
 86 | 	client := makeNotionClient()
 87 | 	page, err := downloadPage(client, pageID)
 88 | 	if err != nil {
 89 | 		logf("previewToHTML: downloadPage() failed with '%s'\n", err)
 90 | 		return nil, err
 91 | 	}
 92 | 	if page == nil {
 93 | 		logf("toHTML: page is nil\n")
 94 | 		return nil, errors.New("page == nil")
 95 | 	}
 96 | 	conv := tohtml.NewConverter(page)
 97 | 	// change https://www.notion.so/Advanced-web-spidering-with-Puppeteer-ea07db1b9bff415ab180b0525f3898f6
 98 | 	// =>
 99 | 	// /previewhtml/${pageID}
100 | 	rewriteURL := func(uri string) string {
101 | 		logf("rewriteURL: '%s'", uri)
102 | 		// ExtractNoDashIDFromNotionURL() only checks if last part of the url
103 | 		// is a valid id. We only want to
104 | 		parsedURL, _ := url.Parse(uri)
105 | 		if !strings.Contains(uri, "notion.so") {
106 | 			logf("\n")
107 | 			return uri
108 | 		}
109 | 		//idStr := notionapi.ExtractNoDashIDFromNotionURL(uri)
110 | 		id := extractNotionIDFromURL(uri)
111 | 		if id == "" {
112 | 			if parsedURL != nil {
113 | 				//idStr = notionapi.ExtractNoDashIDFromNotionURL(parsedURL.Path)
114 | 				id = extractNotionIDFromURL(uri)
115 | 			}
116 | 			if id == "" {
117 | 				logf("\n")
118 | 				return uri
119 | 			}
120 | 		}
121 | 
122 | 		res := "/previewhtml/" + id
123 | 		logf("=> '%s'\n", res)
124 | 		// TODO: maybe preserve ?queryargs
125 | 		return res
126 | 	}
127 | 
128 | 	conv.RewriteURL = rewriteURL
129 | 	return conv.ToHTML()
130 | }
131 | 
132 | func serveError(w http.ResponseWriter, r *http.Request, format string, args ...interface{}) {
133 | 	s := format
134 | 	if len(args) > 0 {
135 | 		s = fmt.Sprintf(format, args...)
136 | 	}
137 | 	w.Header().Set("Content-Type", mimeTypeText)
138 | 	code := http.StatusInternalServerError
139 | 	w.WriteHeader(code)
140 | 	_, _ = w.Write([]byte(s))
141 | }
142 | 
143 | func serveHTMLTemplate(w http.ResponseWriter, r *http.Request, tmplName string, d interface{}) {
144 | 	var buf bytes.Buffer
145 | 	err := templates.ExecuteTemplate(&buf, tmplName, d)
146 | 	if err != nil {
147 | 		logf("tmpl.Execute failed with '%s'\n", err)
148 | 		return
149 | 	}
150 | 	w.Header().Set("Content-Type", mimeTypeHTML)
151 | 	code := http.StatusOK
152 | 	w.WriteHeader(code)
153 | 	_, _ = w.Write(buf.Bytes())
154 | }
155 | 
156 | func handlePreviewHTML(w http.ResponseWriter, r *http.Request) {
157 | 	logf("handlePreviewHTML\n")
158 | 	reloadTemplates()
159 | 
160 | 	pageID := extractNotionIDFromURL(r.URL.Path)
161 | 	if pageID == "" {
162 | 		logf("url '%s' has no valid notion id\n", r.URL)
163 | 		return
164 | 	}
165 | 	html, err := previewToHTML(pageID)
166 | 	if err != nil {
167 | 		logf("previewToHTML('%s') failed with '%s'\n", pageID, err)
168 | 		return
169 | 	}
170 | 	d := map[string]interface{}{
171 | 		"HTML": template.HTML(html),
172 | 	}
173 | 	serveHTMLTemplate(w, r, "preview.html.tmpl.html", d)
174 | }
175 | 
176 | func handlePreviewMarkdown(w http.ResponseWriter, r *http.Request) {
177 | 	logf("handlePreviewMarkdown url: %s\n", r.URL)
178 | 	reloadTemplates()
179 | 
180 | 	pageID := extractNotionIDFromURL(r.URL.Path)
181 | 	if pageID == "" {
182 | 		logf("url '%s' has no valid notion id\n", r.URL)
183 | 		return
184 | 	}
185 | 	md, err := previewToMD(pageID)
186 | 	if err != nil {
187 | 		logf("previewToMD('%s') failed with '%s'\n", pageID, err)
188 | 		return
189 | 	}
190 | 
191 | 	// TODO: convert to HTML using some markdown library
192 | 	d := map[string]interface{}{
193 | 		"Markdown": string(md),
194 | 		"HTML":     template.HTML("HTML preview"),
195 | 	}
196 | 	serveHTMLTemplate(w, r, "preview.md.tmpl.html", d)
197 | }
198 | 
199 | // https://blog.gopheracademy.com/advent-2016/exposing-go-on-the-internet/
200 | func makeHTTPServer() *http.Server {
201 | 	mux := &http.ServeMux{}
202 | 	mux.HandleFunc("/previewhtml/", handlePreviewHTML)
203 | 	mux.HandleFunc("/previewmd/", handlePreviewMarkdown)
204 | 	var handler http.Handler = mux
205 | 
206 | 	srv := &http.Server{
207 | 		ReadTimeout:  120 * time.Second,
208 | 		WriteTimeout: 120 * time.Second,
209 | 		IdleTimeout:  120 * time.Second, // introduced in Go 1.8
210 | 		Handler:      handler,
211 | 	}
212 | 	return srv
213 | }
214 | 


--------------------------------------------------------------------------------
/do/test_to_html.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"fmt"
  6 | 	"os"
  7 | 	"os/exec"
  8 | 	"path/filepath"
  9 | 	"strings"
 10 | 
 11 | 	"github.com/kjk/fmthtml"
 12 | 	"github.com/kjk/notionapi"
 13 | 	"github.com/kjk/notionapi/tohtml"
 14 | 	"github.com/kjk/u"
 15 | )
 16 | 
 17 | // detect location of https://winmerge.org/
 18 | // if present, we can do directory diffs
 19 | // only works on windows
 20 | func getDiffToolPath() string {
 21 | 	path, err := exec.LookPath("WinMergeU")
 22 | 	if err == nil {
 23 | 		return path
 24 | 	}
 25 | 	dir, err := os.UserHomeDir()
 26 | 	if err == nil {
 27 | 		path := filepath.Join(dir, "AppData", "Local", "Programs", "WinMerge", "WinMergeU.exe")
 28 | 		if _, err := os.Stat(path); err == nil {
 29 | 			return path
 30 | 		}
 31 | 	}
 32 | 	path, err = exec.LookPath("opendiff")
 33 | 	if err == nil {
 34 | 		return path
 35 | 	}
 36 | 	return ""
 37 | }
 38 | 
 39 | func dirDiff(dir1, dir2 string) {
 40 | 	diffTool := getDiffToolPath()
 41 | 	// assume opendiff
 42 | 	cmd := exec.Command(diffTool, dir1, dir2)
 43 | 	if strings.Contains(diffTool, "WinMergeU") {
 44 | 		cmd = exec.Command(diffTool, "/r", dir1, dir2)
 45 | 	}
 46 | 	err := cmd.Start()
 47 | 	must(err)
 48 | }
 49 | 
 50 | func shouldFormat() bool {
 51 | 	return !flgNoFormat
 52 | }
 53 | 
 54 | func toHTML2(page *notionapi.Page) (string, []byte) {
 55 | 	name := tohtml.HTMLFileNameForPage(page)
 56 | 	c := tohtml.NewConverter(page)
 57 | 	c.FullHTML = true
 58 | 	d, _ := c.ToHTML()
 59 | 	return name, d
 60 | }
 61 | 
 62 | func toHTML2NotionCompat(page *notionapi.Page) (string, []byte) {
 63 | 	name := tohtml.HTMLFileNameForPage(page)
 64 | 	c := tohtml.NewConverter(page)
 65 | 	c.FullHTML = true
 66 | 	c.NotionCompat = true
 67 | 	d, err := c.ToHTML()
 68 | 	must(err)
 69 | 	return name, d
 70 | }
 71 | 
 72 | func idsEqual(id1, id2 string) bool {
 73 | 	id1 = notionapi.ToDashID(id1)
 74 | 	id2 = notionapi.ToDashID(id2)
 75 | 	return id1 == id2
 76 | }
 77 | 
 78 | func printLastEvent(msg, id string) {
 79 | 	id = notionapi.ToDashID(id)
 80 | 	s := eventsPerID[id]
 81 | 	if s != "" {
 82 | 		s = ", " + s
 83 | 	}
 84 | 	logf("%s%s\n", msg, s)
 85 | }
 86 | 
 87 | // compare HTML conversion generated by us with the one we get
 88 | // from HTML export from Notion
 89 | func testToHTML(startPageID string) {
 90 | 	startPageIDTmp := notionapi.ToNoDashID(startPageID)
 91 | 	if startPageIDTmp == "" {
 92 | 		logf("testToHTML: '%s' is not a valid page id\n", startPageID)
 93 | 		os.Exit(1)
 94 | 	}
 95 | 
 96 | 	startPageID = startPageIDTmp
 97 | 	knownBad := findKnownBadHTML(startPageID)
 98 | 
 99 | 	referenceFiles := exportPages(startPageID, notionapi.ExportTypeHTML)
100 | 	logf("There are %d files in zip file\n", len(referenceFiles))
101 | 
102 | 	client := newClient()
103 | 
104 | 	seenPages := map[string]bool{}
105 | 	pages := []*notionapi.NotionID{notionapi.NewNotionID(startPageID)}
106 | 	nPage := 0
107 | 
108 | 	hasDirDiff := getDiffToolPath() != ""
109 | 	logf("Diff tool: '%s'\n", getDiffToolPath())
110 | 	diffDir := filepath.Join(dataDir, "diff")
111 | 	expDiffDir := filepath.Join(diffDir, "exp")
112 | 	gotDiffDir := filepath.Join(diffDir, "got")
113 | 	must(os.MkdirAll(expDiffDir, 0755))
114 | 	must(os.MkdirAll(gotDiffDir, 0755))
115 | 	u.RemoveFilesInDirMust(expDiffDir)
116 | 	u.RemoveFilesInDirMust(gotDiffDir)
117 | 
118 | 	nDifferent := 0
119 | 
120 | 	didPrintRererenceFiles := false
121 | 	for len(pages) > 0 {
122 | 		pageID := pages[0]
123 | 		pages = pages[1:]
124 | 
125 | 		pageIDNormalized := pageID.NoDashID
126 | 		if seenPages[pageIDNormalized] {
127 | 			continue
128 | 		}
129 | 		seenPages[pageIDNormalized] = true
130 | 		nPage++
131 | 
132 | 		page, err := downloadPage(client, pageID.NoDashID)
133 | 		must(err)
134 | 		pages = append(pages, page.GetSubPages()...)
135 | 		name, pageHTML := toHTML2NotionCompat(page)
136 | 		logf("%02d: %s '%s'", nPage, pageID, name)
137 | 
138 | 		var expData []byte
139 | 		for refName, d := range referenceFiles {
140 | 			if strings.HasSuffix(refName, name) {
141 | 				expData = d
142 | 				break
143 | 			}
144 | 		}
145 | 
146 | 		if len(expData) == 0 {
147 | 			logf("\n'%s' from '%s' doesn't seem correct as it's not present in referenceFiles\n", name, page.Root().Title)
148 | 			logf("Names in referenceFiles:\n")
149 | 			if !didPrintRererenceFiles {
150 | 				for s := range referenceFiles {
151 | 					logf("  %s\n", s)
152 | 				}
153 | 				didPrintRererenceFiles = true
154 | 			}
155 | 			continue
156 | 		}
157 | 
158 | 		if bytes.Equal(pageHTML, expData) {
159 | 			if isPageIDInArray(knownBad, pageID.NoDashID) {
160 | 				printLastEvent(" ok (AND ALSO WHITELISTED)", pageID.NoDashID)
161 | 				continue
162 | 			}
163 | 			printLastEvent(" ok", pageID.NoDashID)
164 | 			continue
165 | 		}
166 | 
167 | 		{
168 | 			{
169 | 				fileName := fmt.Sprintf("%s.1-from-notion.html", pageID.NoDashID)
170 | 				path := filepath.Join(diffDir, fileName)
171 | 				writeFileMust(path, expData)
172 | 			}
173 | 			{
174 | 				fileName := fmt.Sprintf("%s.2-mine.html", pageID.NoDashID)
175 | 				path := filepath.Join(diffDir, fileName)
176 | 				writeFileMust(path, pageHTML)
177 | 			}
178 | 		}
179 | 
180 | 		expDataFormatted := ppHTML(expData)
181 | 		gotDataFormatted := ppHTML(pageHTML)
182 | 
183 | 		if bytes.Equal(expDataFormatted, gotDataFormatted) {
184 | 			if isPageIDInArray(knownBad, pageID.NoDashID) {
185 | 				logf(" ok after formatting (AND ALSO WHITELISTED)")
186 | 				continue
187 | 			}
188 | 			printLastEvent(", same formatted", pageID.NoDashID)
189 | 			continue
190 | 		}
191 | 
192 | 		// if we can diff dirs, run through all files and save files that are
193 | 		// differetn in in dirs
194 | 		fileName := fmt.Sprintf("%s.html", pageID.NoDashID)
195 | 		expPath := filepath.Join(expDiffDir, fileName)
196 | 		writeFileMust(expPath, expDataFormatted)
197 | 		gotPath := filepath.Join(gotDiffDir, fileName)
198 | 		writeFileMust(gotPath, gotDataFormatted)
199 | 		logf("\nHTML in https://notion.so/%s doesn't match\n", pageID.NoDashID)
200 | 
201 | 		// if has diff tool capable of comparing directories, save files to
202 | 		// directory and invoke difftools
203 | 		if hasDirDiff {
204 | 			nDifferent++
205 | 			continue
206 | 		}
207 | 
208 | 		if isPageIDInArray(knownBad, pageID.NoDashID) {
209 | 			printLastEvent(" doesn't match but whitelisted", pageID.NoDashID)
210 | 			continue
211 | 		}
212 | 
213 | 		// don't have diff tool capable of diffing directories so
214 | 		// display the diff for first failed comparison
215 | 		openCodeDiff(expPath, gotPath)
216 | 		os.Exit(1)
217 | 	}
218 | 
219 | 	if nDifferent > 0 {
220 | 		dirDiff(expDiffDir, gotDiffDir)
221 | 	}
222 | }
223 | 
224 | func ppHTML(d []byte) []byte {
225 | 	s := fmthtml.Format(d)
226 | 	return s
227 | }
228 | 


--------------------------------------------------------------------------------
/tracenotion/trace.js:
--------------------------------------------------------------------------------
  1 | /*
  2 | This program helps reverse-engineering notionapi.
  3 | 
  4 | You give it the id of the Notion page and it'll download it
  5 | while recording requests and responses.
  6 | 
  7 | Summary of all requests is printed to stdout.
  8 | 
  9 | Api calls (/api/v3/) are logged to notion_api_trace.txt file
 10 | (pretty-printed body of POST data and pretty-printed JSON responses).
 11 | 
 12 | You need node.js. One time setup:
 13 | - cd tracenotion
 14 | - yarn (or: npm install)
 15 | 
 16 | To run manually:
 17 | - node ./tracenotion/trace.js 
 18 | 
 19 | Or you can do:
 20 | - ./do/do.sh -trace 
 21 | 
 22 | To access your private pages, set NOTION_TOKEN to the value
 23 | of token_v2 cookie on www.notion.so domain.
 24 | */
 25 | 
 26 | const fs = require("fs");
 27 | const puppeteer = require("puppeteer");
 28 | 
 29 | const traceFilePath = "notion_api_trace.txt";
 30 | 
 31 | function trimStr(s, n) {
 32 |   if (s.length > n) {
 33 |     return s.substring(0, n) + "...";
 34 |   }
 35 |   return s;
 36 | }
 37 | 
 38 | function isApiRequest(url) {
 39 |   return url.includes("/api/v3/");
 40 | }
 41 | 
 42 | function shouldLogApiRequest(url) {
 43 |   // this returns too much data
 44 |   if (url.includes("/api/v3/getClientExperimentsV2")) {
 45 |     return false;
 46 |   }
 47 |   if (url.includes("/api/v3/getAssetsJsonV2")) {
 48 |     return false;
 49 |   }
 50 |   if (url.includes("/api/v3/trackSegmentEvent")) {
 51 |     return false;
 52 |   }
 53 |   if (url.includes("/api/v3/teV1")) {
 54 |     return false;
 55 |   }
 56 |   if (url.includes("/api/v3/getExternalIntegrations")) {
 57 |     return false;
 58 |   }
 59 |   return true;
 60 | }
 61 | 
 62 | function ppjson(s) {
 63 |   try {
 64 |     js = JSON.parse(s);
 65 |     s = JSON.stringify(js, null, 2);
 66 |     return s;
 67 |   } catch {
 68 |     return s;
 69 |   }
 70 | }
 71 | 
 72 | let apiLog = [];
 73 | 
 74 | function logApiRR(method, url, status, reqBody, rspBody) {
 75 |   let s = `${method} ${status} ${url}`;
 76 |   if (!isApiRequest(url)) {
 77 |     apiLog.push(s);
 78 |     return;
 79 |   }
 80 |   if (!shouldLogApiRequest(url)) {
 81 |     apiLog.push(s);
 82 |     return;
 83 |   }
 84 |   apiLog.push(s);
 85 |   s = ppjson(reqBody);
 86 |   apiLog.push(s);
 87 |   s = ppjson(rspBody);
 88 |   apiLog.push(s);
 89 |   apiLog.push("-------------------------------");
 90 | }
 91 | 
 92 | function saveApiLog() {
 93 |   const s = apiLog.join("\n");
 94 |   fs.writeFileSync(traceFilePath, s);
 95 |   console.log(`Wrote api trace to ${traceFilePath}`);
 96 | }
 97 | 
 98 | let waitTime = 5 * 1000;
 99 | async function traceNotion(url) {
100 |   const browser = await puppeteer.launch();
101 |   const page = await browser.newPage();
102 |   const token = process.env.NOTION_TOKEN || "";
103 |   if (token !== "") {
104 |     console.log("NOTION_TOKEN set, can access private pages");
105 |     const c = {
106 |       domain: "www.notion.so",
107 |       name: "token_v2",
108 |       value: token,
109 |     };
110 |     await page.setCookie(c);
111 |   } else {
112 |     console.log("only public pages, NOTION_TOKEN env var not set");
113 |   }
114 |   await page.setRequestInterception(true);
115 | 
116 |   // those we don't want to log because they are not important
117 |   function skipLogging(url) {
118 |     const silenced = [
119 |       "/api/v3/ping",
120 |       "/appcache.html",
121 |       "/loading-spinner.svg",
122 |       "/api/v3/getUserAnalyticsSettings",
123 |       "//analytics.pgncs.notion.so/analytics.js",
124 |       "//api.pgncs.notion.so/",
125 |       "//msgstore.www.notion.so/",
126 |       "//www.notion.so/inter-ui-",
127 |       "//www.notion.so/print.",
128 |       "//www.notion.so/app-",
129 |       "//www.notion.so/vendors~main-",
130 |       "//www.notion.so/postRender-",
131 |     ];
132 |     for (let s of silenced) {
133 |       if (url.includes(s)) {
134 |         return true;
135 |       }
136 |     }
137 |     return false;
138 |   }
139 | 
140 |   function isBlacklisted(url) {
141 |     const blacklisted = [
142 |       "//amplitude.com/",
143 |       "//fullstory.com/",
144 |       ".intercom.io/",
145 |       "//segment.io/",
146 |       "//segment.com/",
147 |       ".loggly.com/",
148 |       "//js.intercomcdn.com",
149 |       //"//analytics.pgncs.notion.so/analytics.js",
150 |     ];
151 |     for (let s of blacklisted) {
152 |       if (url.includes(s)) {
153 |         return true;
154 |       }
155 |     }
156 |     return false;
157 |   }
158 | 
159 |   page.on("request", (request) => {
160 |     const url = request.url();
161 |     if (isBlacklisted(url)) {
162 |       request.abort();
163 |       return;
164 |     }
165 |     request.continue();
166 |   });
167 | 
168 |   page.on("requestfailed", (request) => {
169 |     const url = request.url();
170 |     if (isBlacklisted(url)) {
171 |       // it was us who failed this request
172 |       return;
173 |     }
174 |     console.log("request failed url:", url);
175 |   });
176 | 
177 |   async function onResponse(response) {
178 |     const request = response.request();
179 |     let url = request.url();
180 |     if (skipLogging(url)) {
181 |       return;
182 |     }
183 |     let method = request.method();
184 |     const postData = request.postData();
185 | 
186 |     // some urls are data urls and very long
187 |     if (url.includes("data:")) {
188 |       url = trimStr(url, 72);
189 |     } else if (url.includes("msgstore.www.notion.so/")) {
190 |       url = trimStr(url, 72);
191 |     } else {
192 |       // don't trim other urls, especially notion.so/image
193 |     }
194 |     const status = response.status();
195 |     try {
196 |       const d = await response.text();
197 |       const dataLen = d.length;
198 |       if (method === "GET") {
199 |         // make the length same as POST
200 |         method = "GET ";
201 |       }
202 |       console.log(`${method} ${status} ${url} size: ${dataLen}`);
203 |       logApiRR(method, url, status, postData, d);
204 |     } catch (ex) {
205 |       console.log(`${method} ${status} ${url} ex: ${ex} FAIL !!!`);
206 |     }
207 |   }
208 | 
209 |   page.on("response", onResponse);
210 | 
211 |   await page.goto(url, { waitUntil: "networkidle2" });
212 |   await page.waitFor(waitTime);
213 | 
214 |   await browser.close();
215 | }
216 | 
217 | // a sample private url: https://www.notion.so/Things-15c47fa60c274ca2820629fb32c2be97
218 | // a sample public url: https://www.notion.so/Test-text-4c6a54c68b3e4ea2af9cfaabcc88d58d
219 | 
220 | // first arg is "node"
221 | // second arg is name of this script
222 | // third is the first user argument
223 | if (process.argv.length != 3) {
224 |   console.log("Cell me as:");
225 |   console.log("node ./tracenotion/trace.js ");
226 |   console.log("e.g.:");
227 |   console.log(
228 |     "node ./tracenotion/trace.js https://www.notion.so/Test-text-4c6a54c68b3e4ea2af9cfaabcc88d58d"
229 |   );
230 | } else {
231 |   async function doit() {
232 |     const url = process.argv[2];
233 |     await traceNotion(url);
234 |     saveApiLog();
235 |   }
236 |   doit();
237 | }
238 | 


--------------------------------------------------------------------------------
/inline_block.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | )
  6 | 
  7 | const (
  8 | 	// TextSpanSpecial is what Notion uses for text to represent @user and @date blocks
  9 | 	TextSpanSpecial = "‣"
 10 | )
 11 | 
 12 | const (
 13 | 	// AttrBold represents bold block
 14 | 	AttrBold = "b"
 15 | 	// AttrCode represents code block
 16 | 	AttrCode = "c"
 17 | 	// AttrItalic represents italic block
 18 | 	AttrItalic = "i"
 19 | 	// AttrStrikeThrought represents strikethrough block
 20 | 	AttrStrikeThrought = "s"
 21 | 	// AttrComment represents a comment block
 22 | 	AttrComment = "m"
 23 | 	// AttrLink represnts a link (url)
 24 | 	AttrLink = "a"
 25 | 	// AttrUser represents an id of a user
 26 | 	AttrUser = "u"
 27 | 	// AttrHighlight represents text high-light
 28 | 	AttrHighlight = "h"
 29 | 	// AttrDate represents a date
 30 | 	AttrDate = "d"
 31 | 	// AtttrPage represents a link to a Notion page
 32 | 	AttrPage = "p"
 33 | )
 34 | 
 35 | // TextAttr describes attributes of a span of text
 36 | // First element is name of the attribute (e.g. AttrLink)
 37 | // The rest are optional information about attribute (e.g.
 38 | // for AttrLink it's URL, for AttrUser it's user id etc.)
 39 | type TextAttr = []string
 40 | 
 41 | // TextSpan describes a text with attributes
 42 | type TextSpan struct {
 43 | 	Text  string     `json:"Text"`
 44 | 	Attrs []TextAttr `json:"Attrs"`
 45 | }
 46 | 
 47 | // IsPlain returns true if this InlineBlock is plain text i.e. has no attributes
 48 | func (t *TextSpan) IsPlain() bool {
 49 | 	return len(t.Attrs) == 0
 50 | }
 51 | 
 52 | func AttrGetType(attr TextAttr) string {
 53 | 	return attr[0]
 54 | }
 55 | 
 56 | func panicIfAttrNot(attr TextAttr, fnName string, expectedType string) {
 57 | 	if AttrGetType(attr) != expectedType {
 58 | 		panic(fmt.Sprintf("don't call %s on attribute of type %s", fnName, AttrGetType(attr)))
 59 | 	}
 60 | }
 61 | 
 62 | func AttrGetLink(attr TextAttr) string {
 63 | 	panicIfAttrNot(attr, "AttrGetLink", AttrLink)
 64 | 	// there are links with
 65 | 	if len(attr) == 1 {
 66 | 		return ""
 67 | 	}
 68 | 	return attr[1]
 69 | }
 70 | 
 71 | func AttrGetUserID(attr TextAttr) string {
 72 | 	panicIfAttrNot(attr, "AttrGetUserID", AttrUser)
 73 | 	return attr[1]
 74 | }
 75 | 
 76 | func AttrGetPageID(attr TextAttr) string {
 77 | 	panicIfAttrNot(attr, "AttrGetPageID", AttrPage)
 78 | 	return attr[1]
 79 | }
 80 | 
 81 | func AttrGetComment(attr TextAttr) string {
 82 | 	panicIfAttrNot(attr, "AttrGetComment", AttrComment)
 83 | 	return attr[1]
 84 | }
 85 | 
 86 | func AttrGetHighlight(attr TextAttr) string {
 87 | 	panicIfAttrNot(attr, "AttrGetHighlight", AttrHighlight)
 88 | 	return attr[1]
 89 | }
 90 | 
 91 | func AttrGetDate(attr TextAttr) *Date {
 92 | 	panicIfAttrNot(attr, "AttrGetDate", AttrDate)
 93 | 	js := []byte(attr[1])
 94 | 	var d *Date
 95 | 	err := jsonit.Unmarshal(js, &d)
 96 | 	if err != nil {
 97 | 		panic(err.Error())
 98 | 	}
 99 | 	return d
100 | }
101 | 
102 | func parseTextSpanAttribute(b *TextSpan, a []interface{}) error {
103 | 	if len(a) == 0 {
104 | 		return fmt.Errorf("attribute array is empty")
105 | 	}
106 | 	s, ok := a[0].(string)
107 | 	if !ok {
108 | 		return fmt.Errorf("a[0] is not string. a[0] is of type %T and value %#v", a[0], a)
109 | 	}
110 | 	attr := TextAttr{s}
111 | 	if s == AttrDate {
112 | 		// date is a special case in that second value is
113 | 		if len(a) != 2 {
114 | 			return fmt.Errorf("unexpected date attribute. Expected 2 values, got: %#v", a)
115 | 		}
116 | 		v, ok := a[1].(map[string]interface{})
117 | 		if !ok {
118 | 			return fmt.Errorf("got unexpected type %T (expected map[string]interface{}", a[1])
119 | 		}
120 | 		js, err := jsonit.MarshalIndent(v, "", "  ")
121 | 		if err != nil {
122 | 			return err
123 | 		}
124 | 		attr = append(attr, string(js))
125 | 		b.Attrs = append(b.Attrs, attr)
126 | 		return nil
127 | 	}
128 | 	for _, v := range a[1:] {
129 | 		s, ok := v.(string)
130 | 		if !ok {
131 | 			return fmt.Errorf("got unexpected type %T (expected string)", v)
132 | 		}
133 | 		attr = append(attr, s)
134 | 	}
135 | 	b.Attrs = append(b.Attrs, attr)
136 | 	return nil
137 | }
138 | 
139 | func parseTextSpanAttributes(b *TextSpan, a []interface{}) error {
140 | 	for _, rawAttr := range a {
141 | 		attrList, ok := rawAttr.([]interface{})
142 | 		if !ok {
143 | 			return fmt.Errorf("rawAttr is not []interface{} but %T of value %#v", rawAttr, rawAttr)
144 | 		}
145 | 		err := parseTextSpanAttribute(b, attrList)
146 | 		if err != nil {
147 | 			return err
148 | 		}
149 | 	}
150 | 	return nil
151 | }
152 | 
153 | func parseTextSpan(a []interface{}) (*TextSpan, error) {
154 | 	if len(a) == 0 {
155 | 		return nil, fmt.Errorf("a is empty")
156 | 	}
157 | 
158 | 	if len(a) == 1 {
159 | 		s, ok := a[0].(string)
160 | 		if !ok {
161 | 			return nil, fmt.Errorf("a is of length 1 but not string. a[0] el type: %T, el value: '%#v'", a[0], a[0])
162 | 		}
163 | 		return &TextSpan{
164 | 			Text: s,
165 | 		}, nil
166 | 	}
167 | 	if len(a) != 2 {
168 | 		return nil, fmt.Errorf("a is of length != 2. a value: '%#v'", a)
169 | 	}
170 | 
171 | 	s, ok := a[0].(string)
172 | 	if !ok {
173 | 		return nil, fmt.Errorf("a[0] is not string. a[0] type: %T, value: '%#v'", a[0], a[0])
174 | 	}
175 | 	res := &TextSpan{
176 | 		Text: s,
177 | 	}
178 | 	a, ok = a[1].([]interface{})
179 | 	if !ok {
180 | 		return nil, fmt.Errorf("a[1] is not []interface{}. a[1] type: %T, value: '%#v'", a[1], a[1])
181 | 	}
182 | 	err := parseTextSpanAttributes(res, a)
183 | 	if err != nil {
184 | 		return nil, err
185 | 	}
186 | 	return res, nil
187 | }
188 | 
189 | // ParseTextSpans parses content from JSON into an easier to use form
190 | func ParseTextSpans(raw interface{}) ([]*TextSpan, error) {
191 | 	if raw == nil {
192 | 		return nil, nil
193 | 	}
194 | 	var res []*TextSpan
195 | 	a, ok := raw.([]interface{})
196 | 	if !ok {
197 | 		return nil, fmt.Errorf("raw is not of []interface{}. raw type: %T, value: '%#v'", raw, raw)
198 | 	}
199 | 	if len(a) == 0 {
200 | 		return nil, fmt.Errorf("raw is empty")
201 | 	}
202 | 	for _, v := range a {
203 | 		a2, ok := v.([]interface{})
204 | 		if !ok {
205 | 			return nil, fmt.Errorf("v is not []interface{}. v type: %T, value: '%#v'", v, v)
206 | 		}
207 | 		span, err := parseTextSpan(a2)
208 | 		if err != nil {
209 | 			return nil, err
210 | 		}
211 | 		res = append(res, span)
212 | 	}
213 | 	return res, nil
214 | }
215 | 
216 | // TextSpansToString returns flattened content of inline blocks, without formatting
217 | func TextSpansToString(blocks []*TextSpan) string {
218 | 	s := ""
219 | 	for _, block := range blocks {
220 | 		if block.Text == TextSpanSpecial {
221 | 			// TODO: how to handle dates, users etc.?
222 | 			continue
223 | 		}
224 | 		s += block.Text
225 | 	}
226 | 	return s
227 | }
228 | 
229 | func getFirstInline(inline []*TextSpan) string {
230 | 	if len(inline) == 0 {
231 | 		return ""
232 | 	}
233 | 	return inline[0].Text
234 | }
235 | 
236 | func getFirstInlineBlock(v interface{}) (string, error) {
237 | 	inline, err := ParseTextSpans(v)
238 | 	if err != nil {
239 | 		return "", err
240 | 	}
241 | 	return getFirstInline(inline), nil
242 | }
243 | 
244 | func getInlineText(v interface{}) (string, error) {
245 | 	inline, err := ParseTextSpans(v)
246 | 	if err != nil {
247 | 		return "", err
248 | 	}
249 | 	return TextSpansToString(inline), nil
250 | }
251 | 


--------------------------------------------------------------------------------
/api_getUploadFileUrl.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"io/ioutil"
  6 | 	"mime"
  7 | 	"net/http"
  8 | 	"os"
  9 | 	"path"
 10 | 	"strings"
 11 | 
 12 | 	"github.com/google/uuid"
 13 | )
 14 | 
 15 | // POST /api/v3/getUploadFileUrl request
 16 | type getUploadFileUrlRequest struct {
 17 | 	Bucket      string `json:"bucket"`
 18 | 	ContentType string `json:"contentType"`
 19 | 	Name        string `json:"name"`
 20 | }
 21 | 
 22 | // GetUploadFileUrlResponse is a response to POST /api/v3/getUploadFileUrl
 23 | type GetUploadFileUrlResponse struct {
 24 | 	URL          string `json:"url"`
 25 | 	SignedGetURL string `json:"signedGetUrl"`
 26 | 	SignedPutURL string `json:"signedPutUrl"`
 27 | 
 28 | 	FileID string `json:"-"`
 29 | 
 30 | 	RawJSON map[string]interface{} `json:"-"`
 31 | }
 32 | 
 33 | func (r *GetUploadFileUrlResponse) Parse() {
 34 | 	r.FileID = strings.Split(r.URL[len(s3FileURLPrefix):], "/")[0]
 35 | }
 36 | 
 37 | // getUploadFileURL executes a raw API call: POST /api/v3/getUploadFileUrl
 38 | func (c *Client) getUploadFileURL(name, contentType string) (*GetUploadFileUrlResponse, error) {
 39 | 
 40 | 	req := &getUploadFileUrlRequest{
 41 | 		Bucket:      "secure",
 42 | 		ContentType: contentType,
 43 | 		Name:        name,
 44 | 	}
 45 | 
 46 | 	var rsp GetUploadFileUrlResponse
 47 | 	var err error
 48 | 	const apiURL = "/api/v3/getUploadFileUrl"
 49 | 	err = c.doNotionAPI(apiURL, req, &rsp, &rsp.RawJSON)
 50 | 	if err != nil {
 51 | 		return nil, err
 52 | 	}
 53 | 
 54 | 	rsp.Parse()
 55 | 
 56 | 	return &rsp, nil
 57 | }
 58 | 
 59 | // GetFileContentType tries to figure out the content type of the file using http detection
 60 | func GetFileContentType(file *os.File) (contentType string, err error) {
 61 | 	// Try using the extension to figure out the file's type
 62 | 	ext := path.Ext(file.Name())
 63 | 	contentType = mime.TypeByExtension(ext)
 64 | 	if contentType != "" {
 65 | 		return
 66 | 	}
 67 | 
 68 | 	// Seek the file to the start once done
 69 | 	defer func() {
 70 | 		_, err2 := file.Seek(0, 0)
 71 | 		if err == nil && err2 != nil {
 72 | 			err = fmt.Errorf("error seeking start of file: %s", err2)
 73 | 		}
 74 | 	}()
 75 | 
 76 | 	// Only the first 512 bytes are used to sniff the content type.
 77 | 	buffer := make([]byte, 512)
 78 | 
 79 | 	_, err = file.Read(buffer)
 80 | 	if err != nil {
 81 | 		return
 82 | 	}
 83 | 
 84 | 	// Use the net/http package's handy DetectContentType function. Always returns a valid
 85 | 	// content-type by returning "application/octet-stream" if no others seemed to match.
 86 | 	contentType = http.DetectContentType(buffer)
 87 | 	return
 88 | }
 89 | 
 90 | // TODO: Support adding new records to collections and other non-block parent tables
 91 | // SetNewRecordOp creates an operation to create a new record
 92 | func (c *Client) SetNewRecordOp(userID string, parent *Block, recordType string) (newBlock *Block, operation *Operation) {
 93 | 	newID := uuid.New().String()
 94 | 	now := Now()
 95 | 
 96 | 	newBlock = &Block{
 97 | 		ID:          newID,
 98 | 		Version:     1,
 99 | 		Alive:       true,
100 | 		Type:        recordType,
101 | 		CreatedBy:   userID,
102 | 		CreatedTime: now,
103 | 		ParentID:    parent.ID,
104 | 		ParentTable: "block",
105 | 	}
106 | 
107 | 	operation = newBlock.buildOp(CommandSet, []string{}, map[string]interface{}{
108 | 		"id":           newBlock.ID,
109 | 		"version":      newBlock.Version,
110 | 		"alive":        newBlock.Alive,
111 | 		"type":         newBlock.Type,
112 | 		"created_by":   newBlock.CreatedBy,
113 | 		"created_time": newBlock.CreatedTime,
114 | 		"parent_id":    newBlock.ParentID,
115 | 		"parent_table": newBlock.ParentTable,
116 | 	})
117 | 
118 | 	return
119 | }
120 | 
121 | // UploadFile Uploads a file to notion's asset hosting(aws s3)
122 | func (c *Client) UploadFile(file *os.File) (fileID, fileURL string, err error) {
123 | 	contentType, err := GetFileContentType(file)
124 | 	c.logf("contentType: %s", contentType)
125 | 
126 | 	if err != nil {
127 | 		err = fmt.Errorf("couldn't figure out the content-type of the file: %s", err)
128 | 		return
129 | 	}
130 | 
131 | 	fi, err := file.Stat()
132 | 	if err != nil {
133 | 		err = fmt.Errorf("error getting file's stats: %s", err)
134 | 		return
135 | 	}
136 | 
137 | 	fileSize := fi.Size()
138 | 
139 | 	// 1. getUploadFileURL
140 | 	uploadFileURLResp, err := c.getUploadFileURL(file.Name(), contentType)
141 | 	if err != nil {
142 | 		err = fmt.Errorf("get upload file URL error: %s", err)
143 | 		return
144 | 	}
145 | 
146 | 	// 2. Upload file to amazon - PUT
147 | 	httpClient := c.getHTTPClient()
148 | 
149 | 	req, err := http.NewRequest(http.MethodPut, uploadFileURLResp.SignedPutURL, file)
150 | 	if err != nil {
151 | 		return
152 | 	}
153 | 	req.ContentLength = fileSize
154 | 	req.TransferEncoding = []string{"identity"} // disable chunked (unsupported by aws)
155 | 	req.Header.Set("Content-Type", contentType)
156 | 	req.Header.Set("User-Agent", userAgent)
157 | 
158 | 	resp, err := httpClient.Do(req)
159 | 	if err != nil {
160 | 		return
161 | 	}
162 | 
163 | 	defer resp.Body.Close()
164 | 	if resp.StatusCode != 200 {
165 | 		var contents []byte
166 | 		contents, err = ioutil.ReadAll(resp.Body)
167 | 		if err != nil {
168 | 			contents = []byte(fmt.Sprintf("Error from ReadAll: %s", err))
169 | 		}
170 | 
171 | 		err = fmt.Errorf("http PUT '%s' failed with status %s: %s", req.URL, resp.Status, string(contents))
172 | 		return
173 | 	}
174 | 
175 | 	return uploadFileURLResp.FileID, uploadFileURLResp.URL, nil
176 | }
177 | 
178 | // EmbedFile creates a set of operations to embed a file into a block
179 | func (b *Block) EmbedUploadedFileOps(client *Client, userID, fileID, fileURL string) (*Block, []*Operation) {
180 | 	newBlock, newBlockOp := client.SetNewRecordOp(userID, b, BlockEmbed)
181 | 	ops := []*Operation{
182 | 		newBlockOp,
183 | 		b.UpdateOp(&Block{LastEditedTime: Now(), LastEditedBy: userID}),
184 | 	}
185 | 	ops = append(ops, newBlock.embeddedFileOps(fileID, fileURL)...)
186 | 
187 | 	/* TODO: Set size of image/video embeds
188 | 	newBlock.UpdateFormatOp(&FormatImage{
189 | 		BlockWidth: width,
190 | 		BlockHeight: height,
191 | 		BlockPreserveScale: true,
192 | 		BlockFullWidth: true,
193 | 		BlockPageWidth: false,
194 | 		BlockAspectRatio: float64(width) / float64(height),
195 | 	}),
196 | 	*/
197 | 
198 | 	return newBlock, ops
199 | }
200 | 
201 | // embeddedFileOps creates a set of operations to update the embedded file
202 | func (b *Block) embeddedFileOps(fileID, fileURL string) []*Operation {
203 | 	if !b.IsEmbeddedType() {
204 | 		return nil
205 | 	}
206 | 
207 | 	return []*Operation{
208 | 		b.UpdatePropertiesOp(fileURL),
209 | 		b.UpdateFormatOp(&FormatEmbed{DisplaySource: fileURL}),
210 | 		// TODO: Update block type based on upload
211 | 		//b.UpdateOp(&Block{Type: BlockImage}),
212 | 		b.ListAfterFileIDsOp(fileID),
213 | 	}
214 | }
215 | 
216 | // UpdateEmbeddedFileOps creates a set of operations to update an existing embedded file
217 | func (b *Block) UpdateEmbeddedFileOps(userID, fileID, fileURL string) []*Operation {
218 | 	if !b.IsEmbeddedType() {
219 | 		return nil
220 | 	}
221 | 
222 | 	lastEditedData := &Block{
223 | 		LastEditedTime: Now(),
224 | 		LastEditedBy:   userID,
225 | 	}
226 | 	ops := b.embeddedFileOps(fileID, fileURL)
227 | 	ops = append(ops, b.UpdateOp(lastEditedData))
228 | 	if b.Parent != nil {
229 | 		ops = append(ops, b.Parent.UpdateOp(lastEditedData))
230 | 	}
231 | 	return ops
232 | }
233 | 


--------------------------------------------------------------------------------
/inline_block_test.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 
  6 | 	"github.com/kjk/common/assert"
  7 | )
  8 | 
  9 | const title1 = `{
 10 | 	"title": [
 11 | 	  [ "Test page text" ]
 12 | 	]
 13 | }`
 14 | 
 15 | const title2 = `{
 16 | 	"title": [
 17 | 	  [
 18 | 		"‣",
 19 | 		[
 20 | 		  [
 21 | 			"u",
 22 | 			"bb760e2d-d679-4b64-b2a9-03005b21870a"
 23 | 		  ]
 24 | 		]
 25 | 	  ]
 26 | 	]
 27 | }`
 28 | 
 29 | const title3 = `{
 30 | 	"title": [
 31 | 		["Text block with "],
 32 | 		[
 33 | 		  "bold ",
 34 | 		  [
 35 | 			["b"]
 36 | 		  ]
 37 | 		]
 38 | 	]
 39 | }`
 40 | 
 41 | const title4 = `{
 42 | 	"title": [
 43 | 		[
 44 | 			"link inside bold",
 45 | 			[
 46 | 			  ["b"],
 47 | 			  [
 48 | 				"a",
 49 | 				"https://www.google.com"
 50 | 			  ]
 51 | 			]
 52 | 		]
 53 | 	]
 54 | }`
 55 | 
 56 | const title5 = `{
 57 | 	"title": [
 58 | 		[
 59 | 			"‣",
 60 | 			[
 61 | 			  [
 62 | 				"d",
 63 | 				{
 64 | 				  "date_format": "relative",
 65 | 				  "start_date": "2018-07-17",
 66 | 				  "start_time": "15:00",
 67 | 				  "time_zone": "America/Los_Angeles",
 68 | 				  "type": "datetime"
 69 | 				}
 70 | 			  ]
 71 | 			]
 72 | 		]
 73 | 	]
 74 | }`
 75 | 
 76 | const titleBig = `{
 77 | 	"title": [
 78 | 	  ["Text block with "],
 79 | 	  [
 80 | 		"bold ",
 81 | 		[
 82 | 		  ["b"]
 83 | 		]
 84 | 	  ],
 85 | 	  [
 86 | 		"link inside bold",
 87 | 		[
 88 | 		  ["b"],
 89 | 		  [
 90 | 			"a",
 91 | 			"https://www.google.com"
 92 | 		  ]
 93 | 		]
 94 | 	  ],
 95 | 	  [
 96 | 		" text",
 97 | 		[
 98 | 		  ["b"]
 99 | 		]
100 | 	  ],
101 | 	  [", "],
102 | 	  [
103 | 		"italic text",
104 | 		[
105 | 		  ["i"]
106 | 		]
107 | 	  ],
108 | 	  [", "],
109 | 	  [
110 | 		"strikethrough text",
111 | 		[
112 | 		  ["s"]
113 | 		]
114 | 	  ],
115 | 	  [", "],
116 | 	  [
117 | 		"code part",
118 | 		[
119 | 		  ["c"]
120 | 		]
121 | 	  ],
122 | 	  [", "],
123 | 	  [
124 | 		"link part",
125 | 		[
126 | 		  [
127 | 			"a",
128 | 			"http://blog.kowalczyk.info"
129 | 		  ]
130 | 		]
131 | 	  ],
132 | 	  [" , "],
133 | 	  [
134 | 		"‣",
135 | 		[
136 | 		  [
137 | 			"u",
138 | 			"bb760e2d-d679-4b64-b2a9-03005b21870a"
139 | 		  ]
140 | 		]
141 | 	  ],
142 | 	  [" and "],
143 | 	  [
144 | 		"‣",
145 | 		[
146 | 		  [
147 | 			"d",
148 | 			{
149 | 			  "date_format": "relative",
150 | 			  "start_date": "2018-07-17",
151 | 			  "start_time": "15:00",
152 | 			  "time_zone": "America/Los_Angeles",
153 | 			  "type": "datetime"
154 | 			}
155 | 		  ]
156 | 		]
157 | 	  ],
158 | 	  [" and that's it."]
159 | 	]
160 | }`
161 | 
162 | const titleWithComment = `{
163 | 	"title": [
164 | 	[
165 | 		"Just"
166 | 	],
167 | 	[
168 | 		"comment",
169 | 		[
170 | 			[
171 | 				"m",
172 | 				"4a1cc3be-03cf-489a-9542-69d9a02f3534"
173 | 			]
174 | 		]
175 | 	],
176 | 	[
177 | 		"inline."
178 | 	]
179 | ]
180 | }
181 | `
182 | 
183 | const title6 = `{
184 | 	"title": [
185 | 		[
186 | 			"colored",
187 | 			[
188 | 				[
189 | 					"h",
190 | 					"teal_background"
191 | 				]
192 | 			]
193 | 		],
194 | 		[
195 | 			"text",
196 | 			[
197 | 				[
198 | 					"h",
199 | 					"blue"
200 | 				]
201 | 			]
202 | 		]
203 | 	]
204 | }`
205 | 
206 | const title7 = `{
207 | 	"title": [
208 | 	  [
209 | 		"You can log in at: "
210 | 	  ],
211 | 	  [
212 | 		"http",
213 | 		[
214 | 		  [
215 | 			"a",
216 | 			"https://www.google.com/a/blendle.com"
217 | 		  ]
218 | 		]
219 | 	  ],
220 | 	  [
221 | 		"s",
222 | 		[
223 | 		  [
224 | 			"a"
225 | 		  ]
226 | 		]
227 | 	  ],
228 | 	  [
229 | 		"://www.google.com/a/blendle.com",
230 | 		[
231 | 		  [
232 | 			"a",
233 | 			"https://www.google.com/a/blendle.com"
234 | 		  ]
235 | 		]
236 | 	  ]
237 | 	]
238 | }`
239 | 
240 | func parseTextSpans(t *testing.T, s string) []*TextSpan {
241 | 	var m map[string]interface{}
242 | 	err := jsonit.Unmarshal([]byte(s), &m)
243 | 	assert.NoError(t, err)
244 | 	blocks, err := ParseTextSpans(m["title"])
245 | 	assert.NoError(t, err)
246 | 	return blocks
247 | }
248 | 
249 | func TestParseTextSpans1(t *testing.T) {
250 | 	spans := parseTextSpans(t, title1)
251 | 	assert.Equal(t, 1, len(spans))
252 | 	ts := spans[0]
253 | 	assert.Equal(t, "Test page text", ts.Text)
254 | 	assert.True(t, ts.IsPlain())
255 | }
256 | 
257 | func TestParseTextSpans2(t *testing.T) {
258 | 	spans := parseTextSpans(t, title2)
259 | 	assert.Equal(t, 1, len(spans))
260 | 	ts := spans[0]
261 | 	assert.Equal(t, TextSpanSpecial, ts.Text)
262 | 	assert.Equal(t, 1, len(ts.Attrs))
263 | 	attr := ts.Attrs[0]
264 | 	assert.Equal(t, AttrUser, attr[0])
265 | 	assert.Equal(t, "bb760e2d-d679-4b64-b2a9-03005b21870a", attr[1])
266 | }
267 | 
268 | func TestParseTextSpans3(t *testing.T) {
269 | 	blocks := parseTextSpans(t, title3)
270 | 	assert.Equal(t, 2, len(blocks))
271 | 	{
272 | 		b := blocks[0]
273 | 		assert.Equal(t, "Text block with ", b.Text)
274 | 		assert.Equal(t, 0, len(b.Attrs))
275 | 	}
276 | 
277 | 	{
278 | 		b := blocks[1]
279 | 		assert.Equal(t, "bold ", b.Text)
280 | 		attr := b.Attrs[0]
281 | 		assert.Equal(t, AttrBold, attr[0])
282 | 	}
283 | }
284 | 
285 | func TestParseTextSpans4(t *testing.T) {
286 | 	blocks := parseTextSpans(t, title4)
287 | 	assert.Equal(t, 1, len(blocks))
288 | 	{
289 | 		b := blocks[0]
290 | 		assert.Equal(t, "link inside bold", b.Text)
291 | 		assert.Equal(t, 2, len(b.Attrs))
292 | 		attr := b.Attrs[0]
293 | 		assert.Equal(t, AttrBold, AttrGetType(attr))
294 | 		attr = b.Attrs[1]
295 | 		assert.Equal(t, AttrLink, AttrGetType(attr))
296 | 		assert.Equal(t, "https://www.google.com", AttrGetLink(attr))
297 | 	}
298 | }
299 | 
300 | func TestParseTextSpans5(t *testing.T) {
301 | 	blocks := parseTextSpans(t, title5)
302 | 	assert.Equal(t, 1, len(blocks))
303 | 	b := blocks[0]
304 | 	assert.Equal(t, TextSpanSpecial, b.Text)
305 | 	assert.Equal(t, 1, len(b.Attrs))
306 | 	attr := b.Attrs[0]
307 | 	assert.Equal(t, AttrDate, AttrGetType(attr))
308 | 	date := AttrGetDate(attr)
309 | 	assert.Equal(t, date.DateFormat, "relative")
310 | 	assert.Equal(t, date.StartDate, "2018-07-17")
311 | 	assert.Equal(t, date.Type, "datetime")
312 | }
313 | 
314 | func TestParseTextSpansBig(t *testing.T) {
315 | 	blocks := parseTextSpans(t, titleBig)
316 | 	assert.Equal(t, 17, len(blocks))
317 | }
318 | 
319 | func TestParseTextSpansComment(t *testing.T) {
320 | 	blocks := parseTextSpans(t, titleWithComment)
321 | 	assert.Equal(t, 3, len(blocks))
322 | 
323 | 	{
324 | 		// "Just"
325 | 		b := blocks[0]
326 | 		assert.Equal(t, b.Text, "Just")
327 | 		assert.Equal(t, 0, len(b.Attrs))
328 | 	}
329 | 	{
330 | 		// "comment"
331 | 		b := blocks[1]
332 | 		assert.Equal(t, b.Text, "comment")
333 | 		attr := b.Attrs[0]
334 | 		assert.Equal(t, AttrComment, AttrGetType(attr))
335 | 		assert.Equal(t, "4a1cc3be-03cf-489a-9542-69d9a02f3534", AttrGetComment(attr))
336 | 	}
337 | }
338 | 
339 | func TestParseTextSpans6(t *testing.T) {
340 | 	blocks := parseTextSpans(t, title6)
341 | 	assert.Equal(t, 2, len(blocks))
342 | 
343 | 	{
344 | 		b := blocks[0]
345 | 		assert.Equal(t, b.Text, "colored")
346 | 		attr := b.Attrs[0]
347 | 		assert.Equal(t, AttrHighlight, AttrGetType(attr))
348 | 		assert.Equal(t, "teal_background", AttrGetHighlight(attr))
349 | 	}
350 | 	{
351 | 		b := blocks[1]
352 | 		assert.Equal(t, b.Text, "text")
353 | 		attr := b.Attrs[0]
354 | 		assert.Equal(t, AttrHighlight, AttrGetType(attr))
355 | 		assert.Equal(t, "blue", AttrGetHighlight(attr))
356 | 	}
357 | }
358 | 
359 | func TestParseTextSpan7(t *testing.T) {
360 | 	blocks := parseTextSpans(t, title7)
361 | 	assert.Equal(t, 4, len(blocks))
362 | }
363 | 


--------------------------------------------------------------------------------
/do/test_to_md.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"fmt"
  6 | 	"os"
  7 | 	"path/filepath"
  8 | 	"strings"
  9 | 
 10 | 	"github.com/kjk/notionapi"
 11 | 	"github.com/kjk/notionapi/tomarkdown"
 12 | 	"github.com/kjk/u"
 13 | )
 14 | 
 15 | var knownBadMarkdown = [][]string{
 16 | 	{
 17 | 		"3b617da409454a52bc3a920ba8832bf7",
 18 | 
 19 | 		// bad rendering of inlines in Notion
 20 | 		"36430bf6-1c2a-4dec-8621-a10f220155b5",
 21 | 		// mine misses one newline, neither mine nor notion is correct
 22 | 		"4f5ee5cf-4850-4846-8db8-dfbf5924409c",
 23 | 		// difference in inlines rendering and I have extra space
 24 | 		"5fea966407204d9080a5b989360b205f",
 25 | 		// bad link & bold render in Notion
 26 | 		"619286e4fb4f4198957341b66c98cfb9",
 27 | 		// can't resolve user name. Not sure why, the users are in cached
 28 | 		// .json of the file but not in the Page object
 29 | 		"7a5df17b32e84686ae33bf01fa367da9",
 30 | 		// different bold/link render (Notion seems to try to merge adjacent links)
 31 | 		"7afdcc4fbede49bc9582469ad6e86fd3",
 32 | 		// difference in bold rendering, both valid
 33 | 		"7e0814fa4a7f415db820acbbb0112aca",
 34 | 		// missing newline in numbered list. Maybe the rule should be
 35 | 		// to put newline if there are non-empty children
 36 | 		// or: supress newline before lists if previous is also list
 37 | 		// without children
 38 | 		"949f33cdba814fc4a288d81c6e7c810d",
 39 | 		// bold/url, newline
 40 | 		"94c94534e403472f80baeef87ae3efcf",
 41 | 		// bold (Notion collapses multiple)
 42 | 		"d0464f97636448fd8dab5497f68394c2",
 43 | 		// bold
 44 | 		"d1fe3bd9514a4543ae43194333f3cbd2",
 45 | 		// bold
 46 | 		"d82df6d6fafe47d590cd40f33a06e263",
 47 | 		// bold, newline
 48 | 		"f2d97c9cba804583838acf5d571313f5",
 49 | 		// italic, bold
 50 | 		"f495439c3d54409ca714fc3c7cc5711f",
 51 | 		// bold
 52 | 		"bf5d1c1f793a443ca4085cc99186d32f",
 53 | 		// newline
 54 | 		"b2a41db3032049f6a5e2ff66642268b7",
 55 | 		// Notion has a bug in (undefined), bold
 56 | 		"13b8fb98f56848c2814eaf453c2da1e7",
 57 | 		// missing newline in mine
 58 | 		"143d0aef49d54e7ca19eac7b912b5b40",
 59 | 		// bold, newline
 60 | 		"473db4b892c942648d3e3e041c2945d9",
 61 | 		// "undefined"
 62 | 		"c29a8c69877442278c04ce8cdd49a0a0",
 63 | 	},
 64 | }
 65 | 
 66 | func normalizeID(s string) string {
 67 | 	return notionapi.ToNoDashID(s)
 68 | }
 69 | 
 70 | func getKnownBadMarkdown(pageID string) []string {
 71 | 	for _, a := range knownBadMarkdown {
 72 | 		if a[0] == pageID {
 73 | 			return a[1:]
 74 | 		}
 75 | 	}
 76 | 	return nil
 77 | }
 78 | 
 79 | func isPageIDInArray(a []string, pageID string) bool {
 80 | 	pageID = notionapi.ToNoDashID(pageID)
 81 | 	for _, s := range a {
 82 | 		if notionapi.ToNoDashID(s) == pageID {
 83 | 			return true
 84 | 		}
 85 | 	}
 86 | 	return false
 87 | }
 88 | 
 89 | func toMarkdown(page *notionapi.Page) (string, []byte) {
 90 | 	name := tomarkdown.MarkdownFileNameForPage(page)
 91 | 	r := tomarkdown.NewConverter(page)
 92 | 	d := r.ToMarkdown()
 93 | 	return name, d
 94 | }
 95 | 
 96 | func isReferenceMarkdownName(referenceName string, name string, id string) bool {
 97 | 	id = notionapi.ToDashID(id)
 98 | 	if strings.Contains(referenceName, id) {
 99 | 		return true
100 | 	}
101 | 	return false
102 | }
103 | 
104 | func findReferenceMarkdownData(referenceFiles map[string][]byte, name string, id string) ([]byte, bool) {
105 | 	for referenceName, d := range referenceFiles {
106 | 		if isReferenceMarkdownName(referenceName, name, id) {
107 | 			return d, true
108 | 		}
109 | 	}
110 | 	return nil, false
111 | }
112 | 
113 | func exportPages(pageID string, exportType string) map[string][]byte {
114 | 	var ext string
115 | 	switch exportType {
116 | 	case notionapi.ExportTypeMarkdown:
117 | 		ext = "md"
118 | 	case notionapi.ExportTypeHTML:
119 | 		ext = "html"
120 | 	}
121 | 	name := pageID + "-" + ext + ".zip"
122 | 	zipPath := filepath.Join(dataDir, name)
123 | 	if flgReExport {
124 | 		os.Remove(zipPath)
125 | 	}
126 | 
127 | 	if _, err := os.Stat(zipPath); err != nil {
128 | 		if getToken() == "" {
129 | 			fmt.Printf("Must provide token with -token arg or by setting NOTION_TOKEN env variable\n")
130 | 			os.Exit(1)
131 | 		}
132 | 		fmt.Printf("Downloading %s\n", zipPath)
133 | 		must(exportPageToFile(pageID, exportType, true, zipPath))
134 | 	}
135 | 
136 | 	return u.ReadZipFileMust(zipPath)
137 | }
138 | 
139 | func testToMarkdown(startPageID string) {
140 | 	startPageID = notionapi.ToNoDashID(startPageID)
141 | 
142 | 	knownBad := getKnownBadMarkdown(startPageID)
143 | 
144 | 	referenceFiles := exportPages(startPageID, notionapi.ExportTypeMarkdown)
145 | 	fmt.Printf("There are %d files in zip file\n", len(referenceFiles))
146 | 
147 | 	client := newClient()
148 | 
149 | 	seenPages := map[string]bool{}
150 | 	pages := []*notionapi.NotionID{notionapi.NewNotionID(startPageID)}
151 | 	nPage := 0
152 | 
153 | 	hasDirDiff := getDiffToolPath() != ""
154 | 	diffDir := filepath.Join(dataDir, "diff")
155 | 	expDiffDir := filepath.Join(diffDir, "exp")
156 | 	gotDiffDir := filepath.Join(diffDir, "got")
157 | 	if hasDirDiff {
158 | 		must(os.MkdirAll(expDiffDir, 0755))
159 | 		must(os.MkdirAll(gotDiffDir, 0755))
160 | 		u.RemoveFilesInDirMust(expDiffDir)
161 | 		u.RemoveFilesInDirMust(gotDiffDir)
162 | 	}
163 | 	nDifferent := 0
164 | 
165 | 	for len(pages) > 0 {
166 | 		pageID := pages[0]
167 | 		pages = pages[1:]
168 | 
169 | 		pageIDNormalized := pageID.NoDashID
170 | 		if seenPages[pageIDNormalized] {
171 | 			continue
172 | 		}
173 | 		seenPages[pageIDNormalized] = true
174 | 		nPage++
175 | 
176 | 		page, err := downloadPage(client, pageID.NoDashID)
177 | 		must(err)
178 | 		pages = append(pages, page.GetSubPages()...)
179 | 		name, pageMd := toMarkdown(page)
180 | 		fmt.Printf("%02d: '%s'", nPage, name)
181 | 
182 | 		expData, ok := findReferenceMarkdownData(referenceFiles, name, pageID.NoDashID)
183 | 		if !ok {
184 | 			fmt.Printf("\n'%s' from '%s' doesn't seem correct as it's not present in referenceFiles\n", name, page.Root().Title)
185 | 			fmt.Printf("Names in referenceFiles:\n")
186 | 			for s := range referenceFiles {
187 | 				fmt.Printf("  %s\n", s)
188 | 			}
189 | 			os.Exit(1)
190 | 		}
191 | 
192 | 		if bytes.Equal(pageMd, expData) {
193 | 			if isPageIDInArray(knownBad, pageID.NoDashID) {
194 | 				fmt.Printf(" ok (AND ALSO WHITELISTED)\n")
195 | 				continue
196 | 			}
197 | 			fmt.Printf(" ok\n")
198 | 			continue
199 | 		}
200 | 
201 | 		// if we can diff dirs, run through all files and save files that are
202 | 		// differetn in in dirs
203 | 		if hasDirDiff {
204 | 			fileName := fmt.Sprintf("%s.md", pageID.NoDashID)
205 | 			expPath := filepath.Join(expDiffDir, fileName)
206 | 			writeFileMust(expPath, expData)
207 | 			gotPath := filepath.Join(gotDiffDir, fileName)
208 | 			writeFileMust(gotPath, pageMd)
209 | 			fmt.Printf(" https://notion.so/%s doesn't match\n", pageID.NoDashID)
210 | 			if nDifferent == 0 {
211 | 				dirDiff(expDiffDir, gotDiffDir)
212 | 			}
213 | 			nDifferent++
214 | 			continue
215 | 		}
216 | 
217 | 		if isPageIDInArray(knownBad, pageID.NoDashID) {
218 | 			fmt.Printf(" doesn't match but whitelisted\n")
219 | 			continue
220 | 		}
221 | 
222 | 		fmt.Printf("\nMarkdown in https://notion.so/%s doesn't match\n", pageID.NoDashID)
223 | 
224 | 		fileName := fmt.Sprintf("%s.md", pageID.NoDashID)
225 | 		expPath := "exp-" + fileName
226 | 		gotPath := "got-" + fileName
227 | 		writeFileMust(expPath, expData)
228 | 		writeFileMust(gotPath, pageMd)
229 | 		openCodeDiff(expPath, gotPath)
230 | 		os.Exit(1)
231 | 	}
232 | }
233 | 


--------------------------------------------------------------------------------
/do/tests_adhoc.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 
  6 | 	"github.com/kjk/notionapi"
  7 | )
  8 | 
  9 | func assert(ok bool, format string, args ...interface{}) {
 10 | 	if ok {
 11 | 		return
 12 | 	}
 13 | 	s := fmt.Sprintf(format, args...)
 14 | 	panic(s)
 15 | }
 16 | 
 17 | func pageURL(pageID string) string {
 18 | 	return "https://notion.so/" + pageID
 19 | }
 20 | 
 21 | func testDownloadFile() {
 22 | 	client := newClient()
 23 | 
 24 | 	// just enough data for DownloadFile
 25 | 	b := ¬ionapi.Block{
 26 | 		ID:          "5cc81055-1b81-4f31-9df3-390152d272cf",
 27 | 		ParentTable: "table",
 28 | 	}
 29 | 	uri := "https://s3-us-west-2.amazonaws.com/secure.notion-static.com/60550647-d8af-4321-b268-cbb1bab09210/SumatraPDF-dll_iITXbPm55F.png"
 30 | 	rsp, err := client.DownloadFile(uri, b)
 31 | 	if err != nil {
 32 | 		fmt.Printf("c.DownloadFile() failed with '%s'\n", err)
 33 | 		return
 34 | 	}
 35 | 	fmt.Printf("c.DownloadFile() downloaded %d bytes\n", len(rsp.Data))
 36 | }
 37 | 
 38 | func testDownloadImage() {
 39 | 	client := newClient()
 40 | 
 41 | 	// page with images
 42 | 	pageID := "8511412cbfde432ba226648e9bdfbec2"
 43 | 	fmt.Printf("testDownloadImage %s\n", pageURL(pageID))
 44 | 	page, err := downloadPage(client, pageID)
 45 | 	must(err)
 46 | 	block := page.Root()
 47 | 	assert(block.Title == "Test image", "unexpected title ''%s'", block.Title)
 48 | 	blocks := block.Content
 49 | 	assert(len(blocks) == 2, "expected 2 blockSS, got %d", len(blocks))
 50 | 
 51 | 	block = blocks[0]
 52 | 	if false {
 53 | 		fmt.Printf("block.Source: %s\n", block.Source)
 54 | 		exp := "https://i.imgur.com/NT9NcB6.png"
 55 | 		assert(block.Source == exp, "expected %s, got %s", exp, block.Source)
 56 | 		rsp, err := client.DownloadFile(block.Source, block)
 57 | 		assert(err == nil, "client.DownloadFile(%s) failed with %s", err, block.Source)
 58 | 		fmt.Printf("Downloaded image %s of size %d\n", block.Source, len(rsp.Data))
 59 | 		ct := rsp.Header.Get("Content-Type")
 60 | 		exp = "image/png"
 61 | 		assert(ct == exp, "unexpected Content-Type, wanted %s, got %s", exp, ct)
 62 | 		disp := rsp.Header.Get("Content-Disposition")
 63 | 		exp = "filename=\"NT9NcB6.png\""
 64 | 		assert(disp == exp, "unexpected Content-Disposition, got %s, wanted %s", disp, exp)
 65 | 	}
 66 | 
 67 | 	block = blocks[1]
 68 | 	if true {
 69 | 		fmt.Printf("block.Source: %s\n", block.Source)
 70 | 		exp := "https://s3-us-west-2.amazonaws.com/secure.notion-static.com/e5661303-82e1-43e4-be8e-662d1598cd53/untitled"
 71 | 		assert(block.Source == exp, "expected '%s', got '%s'", exp, block.Source)
 72 | 		rsp, err := client.DownloadFile(block.Source, block)
 73 | 		assert(err == nil, "client.DownloadFile(%s) failed with %s", err, block.Source)
 74 | 		fmt.Printf("Downloaded image %s of size %d\n", block.Source, len(rsp.Data))
 75 | 		ct := rsp.Header.Get("Content-Type")
 76 | 		exp = "image/png"
 77 | 		assert(ct == exp, "unexpected Content-Type, wanted %s, got %s", exp, ct)
 78 | 	}
 79 | }
 80 | 
 81 | func testGist() {
 82 | 	client := newClient()
 83 | 
 84 | 	// gist page
 85 | 	pageID := "7b9cdf3ab2cf405692e9810b0ac8322e"
 86 | 	fmt.Printf("testGist %s\n", pageURL(pageID))
 87 | 	page, err := downloadPage(client, pageID)
 88 | 	must(err)
 89 | 	title := page.Root().Title
 90 | 	assert(title == "Test Gist", "unexpected title ''%s'", title)
 91 | 	blocks := page.Root().Content
 92 | 	assert(len(blocks) == 1, "expected 1 block, got %d", len(blocks))
 93 | 	block := blocks[0]
 94 | 	src := block.Source
 95 | 	assert(src == "https://gist.github.com/kjk/7278df5c7b164fce3c949af197c961eb", "unexpected Source '%s'", src)
 96 | }
 97 | 
 98 | func testChangeFormat() {
 99 | 	authToken := getToken()
100 | 	if authToken == "" {
101 | 		fmt.Printf("Skipping testChangeFormat() because NOTION_TOKEN env variable not provided")
102 | 		return
103 | 	}
104 | 
105 | 	client := newClient()
106 | 
107 | 	// https://www.notion.so/Test-for-change-title-7e825831be07487e87e756e52914233b
108 | 	pageID := "7e825831be07487e87e756e52914233b"
109 | 	pageID = "0fc3a590ba5f4e128e7c750e8ecc961d"
110 | 	page, err := client.DownloadPage(pageID)
111 | 	if err != nil {
112 | 		fmt.Printf("testChangeFormat: client.DownloadPage() failed with '%s'\n", err)
113 | 		return
114 | 	}
115 | 	origFormat := page.Root().FormatPage()
116 | 	if origFormat == nil {
117 | 		origFormat = ¬ionapi.FormatPage{}
118 | 	}
119 | 	newSmallText := !origFormat.PageSmallText
120 | 	newFullWidth := !origFormat.PageFullWidth
121 | 
122 | 	args := map[string]interface{}{
123 | 		"page_small_text": newSmallText,
124 | 		"page_full_width": newFullWidth,
125 | 	}
126 | 	fmt.Printf("Setting format to: page_small_text: %v, page_full_width: %v\n", newSmallText, newFullWidth)
127 | 	err = page.SetFormat(args)
128 | 	if err != nil {
129 | 		fmt.Printf("testChangeFormat: page.SetFormat() failed with '%s'\n", err)
130 | 		return
131 | 	}
132 | 	page2, err := client.DownloadPage(pageID)
133 | 	if err != nil {
134 | 		fmt.Printf("testChangeFormat: client.DownloadPage() failed with '%s'\n", err)
135 | 		return
136 | 	}
137 | 	format := page2.Root().FormatPage()
138 | 	assert(newSmallText == format.PageSmallText, "'%v' != '%v' (newSmallText != format.PageSmallText)", newSmallText, format.PageSmallText)
139 | 	assert(newFullWidth == format.PageFullWidth, "'%v' != '%v' (newFullWidth != format.PageFullWidth)", newFullWidth, format.PageFullWidth)
140 | }
141 | 
142 | func testChangeTitle() {
143 | 	authToken := getToken()
144 | 	if authToken == "" {
145 | 		fmt.Printf("Skipping testChangeTitle() because NOTION_TOKEN env variable not provided")
146 | 		return
147 | 	}
148 | 	client := newClient()
149 | 
150 | 	// https://www.notion.so/Test-for-change-title-7e825831be07487e87e756e52914233b
151 | 	pageID := "7e825831be07487e87e756e52914233b"
152 | 	page, err := client.DownloadPage(pageID)
153 | 	if err != nil {
154 | 		fmt.Printf("testChangeTitle: client.DownloadPage() failed with '%s'\n", err)
155 | 		return
156 | 	}
157 | 	origTitle := page.Root().Title
158 | 	newTitle := origTitle + " changed"
159 | 	fmt.Printf("Changing title from '%s' to '%s'\n", origTitle, newTitle)
160 | 	err = page.SetTitle(newTitle)
161 | 	if err != nil {
162 | 		fmt.Printf("testChangeTitle: page.SetTitle(newTitle) failed with '%s'\n", err)
163 | 	}
164 | 
165 | 	page2, err := client.DownloadPage(pageID)
166 | 	if err != nil {
167 | 		fmt.Printf("testChangeTitle: client.DownloadPage() failed with '%s'\n", err)
168 | 		return
169 | 	}
170 | 	title := page2.Root().Title
171 | 	assert(title == newTitle, "'%s' != '%s' (title != newTitle)", title, newTitle)
172 | 
173 | 	fmt.Printf("Changing title from '%s' to '%s'\n", title, origTitle)
174 | 	err = page2.SetTitle(origTitle)
175 | 	if err != nil {
176 | 		fmt.Printf("testChangeTitle: page2.SetTitle(origTitle) failed with '%s'\n", err)
177 | 	}
178 | }
179 | 
180 | func testDownloadBig() {
181 | 	// this tests downloading a page that has (hopefully) all kinds of elements
182 | 	// for notion, for testing that we handle everything
183 | 	// page is c969c9455d7c4dd79c7f860f3ace6429 https://www.notion.so/Test-page-all-not-c969c9455d7c4dd79c7f860f3ace6429
184 | 	client := newClient()
185 | 
186 | 	// page with images
187 | 	pageID := "c969c9455d7c4dd79c7f860f3ace6429"
188 | 	fmt.Printf("testDownloadImage %s\n", pageURL(pageID))
189 | 	page, err := downloadPage(client, pageID)
190 | 	must(err)
191 | 	s := notionapi.DumpToString(page)
192 | 	fmt.Printf("Downloaded page %s, %s\n%s\n", page.ID, pageURL(pageID), s)
193 | }
194 | 
195 | func adhocTests() {
196 | 	fmt.Printf("Running page tests\n")
197 | 	recreateDir(cacheDir)
198 | 
199 | 	//testDownloadBig()
200 | 	testDownloadImage()
201 | 	//testGist()
202 | 	//testChangeTitle()
203 | 	//testChangeFormat()
204 | 
205 | 	fmt.Printf("Finished tests ok!\n")
206 | }
207 | 


--------------------------------------------------------------------------------
/page.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"fmt"
  6 | 	"sort"
  7 | )
  8 | 
  9 | var (
 10 | 	// TODO: add more values, see FormatPage struct
 11 | 	validFormatValues = map[string]struct{}{
 12 | 		"page_full_width": {},
 13 | 		"page_small_text": {},
 14 | 	}
 15 | )
 16 | 
 17 | // Page describes a single Notion page
 18 | type Page struct {
 19 | 	ID       string
 20 | 	NotionID *NotionID
 21 | 
 22 | 	// expose raw records for all data associated with this page
 23 | 	BlockRecords          []*Record
 24 | 	UserRecords           []*Record
 25 | 	CollectionRecords     []*Record
 26 | 	CollectionViewRecords []*Record
 27 | 	DiscussionRecords     []*Record
 28 | 	CommentRecords        []*Record
 29 | 	SpaceRecords          []*Record
 30 | 
 31 | 	// for every block of type collection_view and its view_ids
 32 | 	// we } TableView representing that collection view_id
 33 | 	TableViews []*TableView
 34 | 
 35 | 	idToBlock          map[string]*Block
 36 | 	idToNotionUser     map[string]*NotionUser
 37 | 	idToUserRoot       map[string]*UserRoot
 38 | 	idToUserSettings   map[string]*UserSettings
 39 | 	idToCollection     map[string]*Collection
 40 | 	idToCollectionView map[string]*CollectionView
 41 | 	idToComment        map[string]*Comment
 42 | 	idToDiscussion     map[string]*Discussion
 43 | 	idToSpace          map[string]*Space
 44 | 
 45 | 	blocksToSkip map[string]struct{} // not alive or when server doesn't return "value" for this block id
 46 | 
 47 | 	client   *Client
 48 | 	subPages []*NotionID
 49 | }
 50 | 
 51 | func (p *Page) GetNotionID() *NotionID {
 52 | 	if p.NotionID == nil {
 53 | 		p.NotionID = NewNotionID(p.ID)
 54 | 	}
 55 | 	return p.NotionID
 56 | }
 57 | 
 58 | // SpaceByID returns a space by its id
 59 | func (p *Page) SpaceByID(nid *NotionID) *Space {
 60 | 	return p.idToSpace[nid.DashID]
 61 | }
 62 | 
 63 | // BlockByID returns a block by its id
 64 | func (p *Page) BlockByID(nid *NotionID) *Block {
 65 | 	return p.idToBlock[nid.DashID]
 66 | }
 67 | 
 68 | // UserByID returns a user by its id
 69 | func (p *Page) NotionUserByID(nid *NotionID) *NotionUser {
 70 | 	return p.idToNotionUser[nid.DashID]
 71 | }
 72 | 
 73 | // CollectionByID returns a collection by its id
 74 | func (p *Page) CollectionByID(nid *NotionID) *Collection {
 75 | 	return p.idToCollection[nid.DashID]
 76 | }
 77 | 
 78 | // CollectionViewByID returns a collection view by its id
 79 | func (p *Page) CollectionViewByID(nid *NotionID) *CollectionView {
 80 | 	return p.idToCollectionView[nid.DashID]
 81 | }
 82 | 
 83 | // DiscussionByID returns a discussion by its id
 84 | func (p *Page) DiscussionByID(nid *NotionID) *Discussion {
 85 | 	return p.idToDiscussion[nid.DashID]
 86 | }
 87 | 
 88 | // CommentByID returns a comment by its id
 89 | func (p *Page) CommentByID(nid *NotionID) *Comment {
 90 | 	return p.idToComment[nid.DashID]
 91 | }
 92 | 
 93 | // Root returns a root block representing a page
 94 | func (p *Page) Root() *Block {
 95 | 	return p.BlockByID(p.GetNotionID())
 96 | }
 97 | 
 98 | // SetTitle changes page title
 99 | func (p *Page) SetTitle(s string) error {
100 | 	op := p.Root().SetTitleOp(s)
101 | 	ops := []*Operation{op}
102 | 	return p.client.SubmitTransaction(ops)
103 | }
104 | 
105 | // SetFormat changes format properties of a page. Valid values are:
106 | // page_full_width (bool), page_small_text (bool)
107 | func (p *Page) SetFormat(args map[string]interface{}) error {
108 | 	if len(args) == 0 {
109 | 		return errors.New("args can't be empty")
110 | 	}
111 | 	for k := range args {
112 | 		if _, ok := validFormatValues[k]; !ok {
113 | 			return fmt.Errorf("'%s' is not a valid page format property", k)
114 | 		}
115 | 	}
116 | 	op := p.Root().UpdateFormatOp(args)
117 | 	ops := []*Operation{op}
118 | 	return p.client.SubmitTransaction(ops)
119 | }
120 | 
121 | // NotionURL returns url of this page on notion.so
122 | func (p *Page) NotionURL() string {
123 | 	if p == nil {
124 | 		return ""
125 | 	}
126 | 	id := ToNoDashID(p.ID)
127 | 	// TODO: maybe add title?
128 | 	return "https://www.notion.so/" + id
129 | }
130 | 
131 | func forEachBlockWithParent(seen map[string]bool, blocks []*Block, parent *Block, cb func(*Block)) {
132 | 	for _, block := range blocks {
133 | 		id := block.ID
134 | 		if seen[id] {
135 | 			// crash rather than have infinite recursion
136 | 			panic("seen the same page again")
137 | 		}
138 | 		if parent != nil && (block.Type == BlockPage || block.Type == BlockCollectionViewPage) {
139 | 			// skip sub-pages to avoid infnite recursion
140 | 			continue
141 | 		}
142 | 		seen[id] = true
143 | 		block.Parent = parent
144 | 		cb(block)
145 | 		forEachBlockWithParent(seen, block.Content, block, cb)
146 | 	}
147 | }
148 | 
149 | // ForEachBlock traverses the tree of blocks and calls cb on every block
150 | // in depth-first order. To traverse every blocks in a Page, do:
151 | // ForEachBlock([]*notionapi.Block{page.Root}, cb)
152 | func ForEachBlock(blocks []*Block, cb func(*Block)) {
153 | 	seen := map[string]bool{}
154 | 	forEachBlockWithParent(seen, blocks, nil, cb)
155 | }
156 | 
157 | // ForEachBlock recursively calls cb for each block in the page
158 | func (p *Page) ForEachBlock(cb func(*Block)) {
159 | 	seen := map[string]bool{}
160 | 	blocks := []*Block{p.Root()}
161 | 	forEachBlockWithParent(seen, blocks, nil, cb)
162 | }
163 | 
164 | func panicIf(cond bool, args ...interface{}) {
165 | 	if !cond {
166 | 		return
167 | 	}
168 | 	if len(args) == 0 {
169 | 		panic("condition failed")
170 | 	}
171 | 	format := args[0].(string)
172 | 	if len(args) == 1 {
173 | 		panic(format)
174 | 	}
175 | 	panic(fmt.Sprintf(format, args[1:]))
176 | }
177 | 
178 | // IsSubPage returns true if a given block is BlockPage and
179 | // a direct child of this page (as opposed to a link to
180 | // arbitrary page)
181 | func (p *Page) IsSubPage(block *Block) bool {
182 | 	if block == nil || !isPageBlock(block) {
183 | 		return false
184 | 	}
185 | 
186 | 	for {
187 | 		parentID := block.ParentID
188 | 		if parentID == p.ID {
189 | 			return true
190 | 		}
191 | 		parent := p.BlockByID(block.GetParentNotionID())
192 | 		if parent == nil {
193 | 			return false
194 | 		}
195 | 		// parent is page but not our page, so it can't be sub-page
196 | 		if parent.Type == BlockPage {
197 | 			return false
198 | 		}
199 | 		block = parent
200 | 	}
201 | }
202 | 
203 | // IsRoot returns true if this block is root block of the page
204 | // i.e. of type BlockPage and very first block
205 | func (p *Page) IsRoot(block *Block) bool {
206 | 	if block == nil || block.Type != BlockPage {
207 | 		return false
208 | 	}
209 | 	// a block can be a link to its parent, causing infinite loop
210 | 	// https://github.com/kjk/notionapi/issues/21
211 | 	// TODO: why block.ID == block.ParentID doesn't work?
212 | 	if block == block.Parent {
213 | 		return false
214 | 	}
215 | 	return block.ID == p.ID
216 | }
217 | 
218 | func isPageBlock(block *Block) bool {
219 | 	switch block.Type {
220 | 	case BlockPage, BlockCollectionViewPage:
221 | 		return true
222 | 	}
223 | 	return false
224 | }
225 | 
226 | // GetSubPages return list of ids for direct sub-pages of this page
227 | func (p *Page) GetSubPages() []*NotionID {
228 | 	if len(p.subPages) > 0 {
229 | 		return p.subPages
230 | 	}
231 | 	root := p.Root()
232 | 	panicIf(!isPageBlock(root))
233 | 	subPages := map[*NotionID]struct{}{}
234 | 	seenBlocks := map[string]struct{}{}
235 | 	var blocksToVisit []*NotionID
236 | 	for _, id := range root.ContentIDs {
237 | 		nid := NewNotionID(id)
238 | 		blocksToVisit = append(blocksToVisit, nid)
239 | 	}
240 | 	for len(blocksToVisit) > 0 {
241 | 		nid := blocksToVisit[0]
242 | 		id := nid.DashID
243 | 		blocksToVisit = blocksToVisit[1:]
244 | 		if _, ok := seenBlocks[id]; ok {
245 | 			continue
246 | 		}
247 | 		seenBlocks[id] = struct{}{}
248 | 		block := p.BlockByID(nid)
249 | 		if p.IsSubPage(block) {
250 | 			subPages[nid] = struct{}{}
251 | 		}
252 | 		// need to recursively scan blocks with children
253 | 		for _, id := range block.ContentIDs {
254 | 			nid := NewNotionID(id)
255 | 			blocksToVisit = append(blocksToVisit, nid)
256 | 		}
257 | 	}
258 | 	res := []*NotionID{}
259 | 	for id := range subPages {
260 | 		res = append(res, id)
261 | 	}
262 | 	sort.Slice(res, func(i, j int) bool {
263 | 		return res[i].DashID < res[j].DashID
264 | 	})
265 | 	p.subPages = res
266 | 	return res
267 | }
268 | 
269 | func makeUserName(user *NotionUser) string {
270 | 	s := user.GivenName
271 | 	if len(s) > 0 {
272 | 		s += " "
273 | 	}
274 | 	s += user.FamilyName
275 | 	if len(s) > 0 {
276 | 		return s
277 | 	}
278 | 	return user.ID
279 | }
280 | 
281 | // GetUserNameByID returns a full user name given user id
282 | // it's a helper function
283 | func GetUserNameByID(page *Page, userID string) string {
284 | 	for _, r := range page.UserRecords {
285 | 		user := r.NotionUser
286 | 		if user.ID == userID {
287 | 			return makeUserName(user)
288 | 		}
289 | 	}
290 | 	return userID
291 | }
292 | 
293 | func (p *Page) resolveBlocks() error {
294 | 	for _, block := range p.idToBlock {
295 | 		err := resolveBlock(p, block)
296 | 		if err != nil {
297 | 			return err
298 | 		}
299 | 	}
300 | 	return nil
301 | }
302 | 
303 | func resolveBlock(p *Page, block *Block) error {
304 | 	if block.isResolved {
305 | 		return nil
306 | 	}
307 | 	block.isResolved = true
308 | 	err := parseProperties(block)
309 | 	if err != nil {
310 | 		return err
311 | 	}
312 | 
313 | 	var contentIDs []string
314 | 	var content []*Block
315 | 	for _, id := range block.ContentIDs {
316 | 		b := p.idToBlock[id]
317 | 		if b == nil {
318 | 			continue
319 | 		}
320 | 		contentIDs = append(contentIDs, id)
321 | 		content = append(content, b)
322 | 	}
323 | 	block.ContentIDs = contentIDs
324 | 	block.Content = content
325 | 	return nil
326 | }
327 | 


--------------------------------------------------------------------------------
/collection.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | )
  6 | 
  7 | const (
  8 | 	// TODO: those are probably CollectionViewType
  9 | 	// CollectionViewTypeTable is a table block
 10 | 	CollectionViewTypeTable = "table"
 11 | 	// CollectionViewTypeTable is a lists block
 12 | 	CollectionViewTypeList = "list"
 13 | )
 14 | 
 15 | // CollectionColumnOption describes options for ColumnTypeMultiSelect
 16 | // collection column
 17 | type CollectionColumnOption struct {
 18 | 	Color string `json:"color"`
 19 | 	ID    string `json:"id"`
 20 | 	Value string `json:"value"`
 21 | }
 22 | 
 23 | type FormulaArg struct {
 24 | 	Name       *string `json:"name,omitempty"`
 25 | 	ResultType string  `json:"result_type"`
 26 | 	Type       string  `json:"type"`
 27 | 	Value      *string `json:"value,omitempty"`
 28 | 	ValueType  *string `json:"value_type,omitempty"`
 29 | }
 30 | 
 31 | type ColumnFormula struct {
 32 | 	Args       []FormulaArg `json:"args"`
 33 | 	Name       string       `json:"name"`
 34 | 	Operator   string       `json:"operator"`
 35 | 	ResultType string       `json:"result_type"`
 36 | 	Type       string       `json:"type"`
 37 | }
 38 | 
 39 | // ColumnSchema describes a info of a collection column
 40 | type ColumnSchema struct {
 41 | 	Name string `json:"name"`
 42 | 	// ColumnTypeTitle etc.
 43 | 	Type string `json:"type"`
 44 | 
 45 | 	// for Type == ColumnTypeNumber, e.g. "dollar", "number"
 46 | 	NumberFormat string `json:"number_format"`
 47 | 
 48 | 	// For Type == ColumnTypeRollup
 49 | 	Aggregation        string `json:"aggregation"` // e.g. "unique"
 50 | 	TargetProperty     string `json:"target_property"`
 51 | 	RelationProperty   string `json:"relation_property"`
 52 | 	TargetPropertyType string `json:"target_property_type"`
 53 | 
 54 | 	// for Type == ColumnTypeRelation
 55 | 	CollectionID string `json:"collection_id"`
 56 | 	Property     string `json:"property"`
 57 | 
 58 | 	// for Type == ColumnTypeFormula
 59 | 	Formula *ColumnFormula
 60 | 
 61 | 	Options []*CollectionColumnOption `json:"options"`
 62 | 
 63 | 	// TODO: would have to set it up from Collection.RawJSON
 64 | 	//RawJSON map[string]interface{} `json:"-"`
 65 | }
 66 | 
 67 | // CollectionPageProperty describes properties of a collection
 68 | type CollectionPageProperty struct {
 69 | 	Property string `json:"property"`
 70 | 	Visible  bool   `json:"visible"`
 71 | }
 72 | 
 73 | // CollectionFormat describes format of a collection
 74 | type CollectionFormat struct {
 75 | 	CoverPosition  float64                   `json:"collection_cover_position"`
 76 | 	PageProperties []*CollectionPageProperty `json:"collection_page_properties"`
 77 | }
 78 | 
 79 | // Collection describes a collection
 80 | type Collection struct {
 81 | 	ID          string                   `json:"id"`
 82 | 	Version     int                      `json:"version"`
 83 | 	Name        interface{}              `json:"name"`
 84 | 	Schema      map[string]*ColumnSchema `json:"schema"`
 85 | 	Format      *CollectionFormat        `json:"format"`
 86 | 	ParentID    string                   `json:"parent_id"`
 87 | 	ParentTable string                   `json:"parent_table"`
 88 | 	Alive       bool                     `json:"alive"`
 89 | 	CopiedFrom  string                   `json:"copied_from"`
 90 | 	Cover       string                   `json:"cover"`
 91 | 	Description []interface{}            `json:"description"`
 92 | 
 93 | 	// TODO: are those ever present?
 94 | 	Type          string   `json:"type"`
 95 | 	FileIDs       []string `json:"file_ids"`
 96 | 	Icon          string   `json:"icon"`
 97 | 	TemplatePages []string `json:"template_pages"`
 98 | 
 99 | 	// calculated by us
100 | 	name    []*TextSpan
101 | 	RawJSON map[string]interface{} `json:"-"`
102 | }
103 | 
104 | // GetName parses Name and returns as a string
105 | func (c *Collection) GetName() string {
106 | 	if len(c.name) == 0 {
107 | 		if c.Name == nil {
108 | 			return ""
109 | 		}
110 | 		c.name, _ = ParseTextSpans(c.Name)
111 | 	}
112 | 	return TextSpansToString(c.name)
113 | }
114 | 
115 | // TableProperty describes property of a table
116 | type TableProperty struct {
117 | 	Width    int    `json:"width"`
118 | 	Visible  bool   `json:"visible"`
119 | 	Property string `json:"property"`
120 | }
121 | 
122 | type QuerySort struct {
123 | 	ID        string `json:"id"`
124 | 	Type      string `json:"type"`
125 | 	Property  string `json:"property"`
126 | 	Direction string `json:"direction"`
127 | }
128 | 
129 | type QueryAggregate struct {
130 | 	ID              string `json:"id"`
131 | 	Type            string `json:"type"`
132 | 	Property        string `json:"property"`
133 | 	ViewType        string `json:"view_type"`
134 | 	AggregationType string `json:"aggregation_type"`
135 | }
136 | 
137 | type QueryAggregation struct {
138 | 	Property   string `json:"property"`
139 | 	Aggregator string `json:"aggregator"`
140 | }
141 | 
142 | type Query struct {
143 | 	Sort         []QuerySort            `json:"sort"`
144 | 	Aggregate    []QueryAggregate       `json:"aggregate"`
145 | 	Aggregations []QueryAggregation     `json:"aggregations"`
146 | 	Filter       map[string]interface{} `json:"filter"`
147 | }
148 | 
149 | // FormatTable describes format for BlockTable
150 | type FormatTable struct {
151 | 	PageSort        []string         `json:"page_sort"`
152 | 	TableWrap       bool             `json:"table_wrap"`
153 | 	TableProperties []*TableProperty `json:"table_properties"`
154 | }
155 | 
156 | // CollectionView represents a collection view
157 | type CollectionView struct {
158 | 	ID          string       `json:"id"`
159 | 	Version     int64        `json:"version"`
160 | 	Type        string       `json:"type"` // "table"
161 | 	Format      *FormatTable `json:"format"`
162 | 	Name        string       `json:"name"`
163 | 	ParentID    string       `json:"parent_id"`
164 | 	ParentTable string       `json:"parent_table"`
165 | 	Query       *Query       `json:"query2"`
166 | 	Alive       bool         `json:"alive"`
167 | 	PageSort    []string     `json:"page_sort"`
168 | 	SpaceID     string       `json:"space_id"`
169 | 
170 | 	// set by us
171 | 	RawJSON map[string]interface{} `json:"-"`
172 | }
173 | 
174 | type TableRow struct {
175 | 	// TableView that owns this row
176 | 	TableView *TableView
177 | 
178 | 	// data for row is stored as properties of a page
179 | 	Page *Block
180 | 
181 | 	// values extracted from Page for each column
182 | 	Columns [][]*TextSpan
183 | }
184 | 
185 | // ColumnInfo describes a schema for a given cell (column)
186 | type ColumnInfo struct {
187 | 	// TableView that owns this column
188 | 	TableView *TableView
189 | 
190 | 	// so that we can access TableRow.Columns[Index]
191 | 	Index    int
192 | 	Schema   *ColumnSchema
193 | 	Property *TableProperty
194 | }
195 | 
196 | func (c *ColumnInfo) ID() string {
197 | 	return c.Property.Property
198 | }
199 | 
200 | func (c *ColumnInfo) Type() string {
201 | 	return c.Schema.Type
202 | }
203 | 
204 | func (c *ColumnInfo) Name() string {
205 | 	if c.Schema == nil {
206 | 		return ""
207 | 	}
208 | 	return c.Schema.Name
209 | }
210 | 
211 | // TableView represents a view of a table (Notion calls it a Collection View)
212 | // Meant to be a representation that is easier to work with
213 | type TableView struct {
214 | 	// original data
215 | 	Page           *Page
216 | 	CollectionView *CollectionView
217 | 	Collection     *Collection
218 | 
219 | 	// easier to work representation we calculate
220 | 	Columns []*ColumnInfo
221 | 	Rows    []*TableRow
222 | }
223 | 
224 | func (t *TableView) RowCount() int {
225 | 	return len(t.Rows)
226 | }
227 | 
228 | func (t *TableView) ColumnCount() int {
229 | 	return len(t.Columns)
230 | }
231 | 
232 | func (t *TableView) CellContent(row, col int) []*TextSpan {
233 | 	return t.Rows[row].Columns[col]
234 | }
235 | 
236 | // TODO: some tables miss title column in TableProperties
237 | // maybe synthesize it if doesn't exist as a first column
238 | func (c *Client) buildTableView(tv *TableView, res *QueryCollectionResponse) error {
239 | 	cv := tv.CollectionView
240 | 	collection := tv.Collection
241 | 
242 | 	if cv.Format == nil {
243 | 		c.logf("buildTableView: page: '%s', missing CollectionView.Format in collection view with id '%s'\n", ToNoDashID(tv.Page.ID), cv.ID)
244 | 		return nil
245 | 	}
246 | 
247 | 	if collection == nil {
248 | 		c.logf("buildTableView: page: '%s', colleciton is nil, collection view id: '%s'\n", ToNoDashID(tv.Page.ID), cv.ID)
249 | 		// TODO: maybe should return nil if this is missing in data returned
250 | 		// by Notion. If it's a bug in our interpretation, we should fix
251 | 		// that instead
252 | 		return fmt.Errorf("buildTableView: page: '%s', colleciton is nil, collection view id: '%s'", ToNoDashID(tv.Page.ID), cv.ID)
253 | 	}
254 | 
255 | 	if collection.Schema == nil {
256 | 		c.logf("buildTableView: page: '%s', missing collection.Schema, collection view id: '%s', collection id: '%s'\n", ToNoDashID(tv.Page.ID), cv.ID, collection.ID)
257 | 		// TODO: maybe should return nil if this is missing in data returned
258 | 		// by Notion. If it's a bug in our interpretation, we should fix
259 | 		// that instead
260 | 		return fmt.Errorf("buildTableView: page: '%s', missing collection.Schema, collection view id: '%s', collection id: '%s'", ToNoDashID(tv.Page.ID), cv.ID, collection.ID)
261 | 	}
262 | 
263 | 	idx := 0
264 | 	for _, prop := range cv.Format.TableProperties {
265 | 		if !prop.Visible {
266 | 			continue
267 | 		}
268 | 		propName := prop.Property
269 | 		schema := collection.Schema[propName]
270 | 		ci := &ColumnInfo{
271 | 			TableView: tv,
272 | 
273 | 			Index:    idx,
274 | 			Property: prop,
275 | 			Schema:   schema,
276 | 		}
277 | 		idx++
278 | 		tv.Columns = append(tv.Columns, ci)
279 | 	}
280 | 
281 | 	// blockIDs are IDs of page blocks
282 | 	// each page represents one table row
283 | 	var blockIds []string
284 | 	if res.Result.ReducerResults != nil && res.Result.ReducerResults.CollectionGroupResults != nil {
285 | 		blockIds = res.Result.ReducerResults.CollectionGroupResults.BlockIds
286 | 	}
287 | 	for _, id := range blockIds {
288 | 		rec, ok := res.RecordMap.Blocks[id]
289 | 		if !ok {
290 | 			cvID := tv.CollectionView.ID
291 | 			return fmt.Errorf("didn't find block with id '%s' for collection view with id '%s'", id, cvID)
292 | 		}
293 | 		b := rec.Block
294 | 		if b != nil {
295 | 			tr := &TableRow{
296 | 				TableView: tv,
297 | 				Page:      b,
298 | 			}
299 | 			tv.Rows = append(tv.Rows, tr)
300 | 		}
301 | 	}
302 | 
303 | 	// pre-calculate cell content
304 | 	for _, tr := range tv.Rows {
305 | 		for _, ci := range tv.Columns {
306 | 			propName := ci.Property.Property
307 | 			v := tr.Page.GetProperty(propName)
308 | 			tr.Columns = append(tr.Columns, v)
309 | 		}
310 | 	}
311 | 	return nil
312 | }
313 | 


--------------------------------------------------------------------------------
/api_loadCachedPageChunk_test.go:
--------------------------------------------------------------------------------
  1 | package notionapi
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 
  6 | 	"github.com/kjk/common/require"
  7 | )
  8 | 
  9 | const (
 10 | 	loadPageJSON1 = `
 11 | {
 12 | 	"cursor": {
 13 | 		"stack": []
 14 | 	},
 15 | 	"recordMap": {
 16 | 		"block": {
 17 | 			"0367c2db-381a-4f8b-9ce3-60f388a6b2e3": {
 18 | 				"role": "reader",
 19 | 				"value": {
 20 | 					"alive": true,
 21 | 					"content": [
 22 | 						"4c6a54c6-8b3e-4ea2-af9c-faabcc88d58d",
 23 | 						"f97ffca9-1f89-49b4-8004-999df34ab1f7",
 24 | 						"6682351e-44bb-4f9c-a0e1-49b703265bdb",
 25 | 						"42c92ede-8ba2-4c1e-8533-cbfe9d92d98f",
 26 | 						"97c24351-93d2-4568-8bb5-da7f84edfe45"
 27 | 					],
 28 | 					"created_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
 29 | 					"created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
 30 | 					"created_by_table": "notion_user",
 31 | 					"created_time": 1531120060950,
 32 | 					"discussions": ["3342f507-0d13-4f24-9a42-b7951f6fa5f5"],
 33 | 					"format": {
 34 | 						"page_cover": "/images/page-cover/rijksmuseum_claesz_1628.jpg",
 35 | 						"page_cover_position": 0.352,
 36 | 						"page_full_width": true,
 37 | 						"page_icon": "🏕",
 38 | 						"page_small_text": true
 39 | 					},
 40 | 					"id": "0367c2db-381a-4f8b-9ce3-60f388a6b2e3",
 41 | 					"last_edited_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
 42 | 					"last_edited_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
 43 | 					"last_edited_by_table": "notion_user",
 44 | 					"last_edited_time": 1633890600000,
 45 | 					"parent_id": "525cd68a-31f3-4e98-a8c1-cb9c39849399",
 46 | 					"parent_table": "block",
 47 | 					"permissions": [
 48 | 						{
 49 | 							"role": "editor",
 50 | 							"type": "user_permission",
 51 | 							"user_id": "bb760e2d-d679-4b64-b2a9-03005b21870a"
 52 | 						},
 53 | 						{
 54 | 							"added_timestamp": 0,
 55 | 							"allow_duplicate": false,
 56 | 							"allow_search_engine_indexing": false,
 57 | 							"role": "reader",
 58 | 							"type": "public_permission"
 59 | 						}
 60 | 					],
 61 | 					"properties": {
 62 | 						"title": [["Test pages for notionapi"]]
 63 | 					},
 64 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
 65 | 					"type": "page",
 66 | 					"version": 366
 67 | 				}
 68 | 			},
 69 | 			"1790dc30-5a1a-4623-bb87-c080de46d02d": {
 70 | 				"role": "reader",
 71 | 				"value": {
 72 | 					"alive": true,
 73 | 					"created_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
 74 | 					"created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
 75 | 					"created_by_table": "notion_user",
 76 | 					"created_time": 1554788400000,
 77 | 					"id": "1790dc30-5a1a-4623-bb87-c080de46d02d",
 78 | 					"last_edited_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
 79 | 					"last_edited_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
 80 | 					"last_edited_by_table": "notion_user",
 81 | 					"last_edited_time": 1554788400000,
 82 | 					"parent_id": "4c6a54c6-8b3e-4ea2-af9c-faabcc88d58d",
 83 | 					"parent_table": "block",
 84 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
 85 | 					"type": "text",
 86 | 					"version": 4
 87 | 				}
 88 | 			},
 89 | 			"4c6a54c6-8b3e-4ea2-af9c-faabcc88d58d": {
 90 | 				"role": "reader",
 91 | 				"value": {
 92 | 					"alive": true,
 93 | 					"content": [
 94 | 						"c76d351e-e836-4a04-8f09-85c893660b4e",
 95 | 						"7bc42f07-b6e9-406a-bb4f-9d50d68eedb4",
 96 | 						"6fe7a003-2af0-4c18-bad7-1a3f99caf665",
 97 | 						"1790dc30-5a1a-4623-bb87-c080de46d02d"
 98 | 					],
 99 | 					"created_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
100 | 					"created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
101 | 					"created_by_table": "notion_user",
102 | 					"created_time": 1531024380041,
103 | 					"id": "4c6a54c6-8b3e-4ea2-af9c-faabcc88d58d",
104 | 					"last_edited_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
105 | 					"last_edited_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
106 | 					"last_edited_by_table": "notion_user",
107 | 					"last_edited_time": 1554788400000,
108 | 					"parent_id": "0367c2db-381a-4f8b-9ce3-60f388a6b2e3",
109 | 					"parent_table": "block",
110 | 					"properties": {
111 | 						"title": [["Test text"]]
112 | 					},
113 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
114 | 					"type": "page",
115 | 					"version": 61
116 | 				}
117 | 			},
118 | 			"525cd68a-31f3-4e98-a8c1-cb9c39849399": {
119 | 				"role": "reader",
120 | 				"value": {
121 | 					"alive": true,
122 | 					"content": [
123 | 						"045c9995-11cf-4eb7-9de5-745d8fc21a3e",
124 | 						"0367c2db-381a-4f8b-9ce3-60f388a6b2e3",
125 | 						"3b617da4-0945-4a52-bc3a-920ba8832bf7",
126 | 						"d6eb49cf-c68f-4028-81af-3aef391443e6",
127 | 						"da0b358c-21ab-4ac6-b5c0-f7154b2ecadc"
128 | 					],
129 | 					"created_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
130 | 					"created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
131 | 					"created_by_table": "notion_user",
132 | 					"created_time": 1564868100000,
133 | 					"id": "525cd68a-31f3-4e98-a8c1-cb9c39849399",
134 | 					"last_edited_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
135 | 					"last_edited_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
136 | 					"last_edited_by_table": "notion_user",
137 | 					"last_edited_time": 1624230720000,
138 | 					"parent_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
139 | 					"parent_table": "space",
140 | 					"permissions": [
141 | 						{
142 | 							"role": "editor",
143 | 							"type": "user_permission",
144 | 							"user_id": "bb760e2d-d679-4b64-b2a9-03005b21870a"
145 | 						},
146 | 						{
147 | 							"added_timestamp": 1624230732521,
148 | 							"allow_duplicate": false,
149 | 							"role": "reader",
150 | 							"type": "public_permission"
151 | 						},
152 | 						{
153 | 							"bot_id": "c9cebcd2-9fc0-4092-aa6e-c2b505c57021",
154 | 							"role": {
155 | 								"insert_content": true,
156 | 								"read_content": true,
157 | 								"update_content": true
158 | 							},
159 | 							"type": "bot_permission"
160 | 						}
161 | 					],
162 | 					"properties": {
163 | 						"title": [["Notion testing"]]
164 | 					},
165 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
166 | 					"type": "page",
167 | 					"version": 41
168 | 				}
169 | 			},
170 | 			"6fe7a003-2af0-4c18-bad7-1a3f99caf665": {
171 | 				"role": "reader",
172 | 				"value": {
173 | 					"alive": true,
174 | 					"created_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
175 | 					"created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
176 | 					"created_by_table": "notion_user",
177 | 					"created_time": 1554788402406,
178 | 					"id": "6fe7a003-2af0-4c18-bad7-1a3f99caf665",
179 | 					"last_edited_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
180 | 					"last_edited_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
181 | 					"last_edited_by_table": "notion_user",
182 | 					"last_edited_time": 1554788400000,
183 | 					"parent_id": "4c6a54c6-8b3e-4ea2-af9c-faabcc88d58d",
184 | 					"parent_table": "block",
185 | 					"properties": {
186 | 						"title": [["another test"]]
187 | 					},
188 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
189 | 					"type": "text",
190 | 					"version": 16
191 | 				}
192 | 			},
193 | 			"7bc42f07-b6e9-406a-bb4f-9d50d68eedb4": {
194 | 				"role": "reader",
195 | 				"value": {
196 | 					"alive": true,
197 | 					"created_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
198 | 					"created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
199 | 					"created_by_table": "notion_user",
200 | 					"created_time": 1531033696846,
201 | 					"id": "7bc42f07-b6e9-406a-bb4f-9d50d68eedb4",
202 | 					"last_edited_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
203 | 					"last_edited_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
204 | 					"last_edited_by_table": "notion_user",
205 | 					"last_edited_time": 1554788400000,
206 | 					"parent_id": "4c6a54c6-8b3e-4ea2-af9c-faabcc88d58d",
207 | 					"parent_table": "block",
208 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
209 | 					"type": "divider",
210 | 					"version": 13
211 | 				}
212 | 			},
213 | 			"c76d351e-e836-4a04-8f09-85c893660b4e": {
214 | 				"role": "reader",
215 | 				"value": {
216 | 					"alive": true,
217 | 					"created_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
218 | 					"created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
219 | 					"created_by_table": "notion_user",
220 | 					"created_time": 1531024387094,
221 | 					"id": "c76d351e-e836-4a04-8f09-85c893660b4e",
222 | 					"last_edited_by": "bb760e2d-d679-4b64-b2a9-03005b21870a",
223 | 					"last_edited_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
224 | 					"last_edited_by_table": "notion_user",
225 | 					"last_edited_time": 1531024393188,
226 | 					"parent_id": "4c6a54c6-8b3e-4ea2-af9c-faabcc88d58d",
227 | 					"parent_table": "block",
228 | 					"properties": {
229 | 						"title": [["This is a simple text."]]
230 | 					},
231 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
232 | 					"type": "text",
233 | 					"version": 66
234 | 				}
235 | 			}
236 | 		},
237 | 		"comment": {
238 | 			"8866ebf3-4a2d-4549-92ab-928c2354e8fc": {
239 | 				"role": "reader",
240 | 				"value": {
241 | 					"alive": true,
242 | 					"created_by_id": "bb760e2d-d679-4b64-b2a9-03005b21870a",
243 | 					"created_by_table": "notion_user",
244 | 					"created_time": 1566024240000,
245 | 					"id": "8866ebf3-4a2d-4549-92ab-928c2354e8fc",
246 | 					"last_edited_time": 1566024240000,
247 | 					"parent_id": "3342f507-0d13-4f24-9a42-b7951f6fa5f5",
248 | 					"parent_table": "discussion",
249 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
250 | 					"text": [["a discussion for page\nanother comment about the page"]],
251 | 					"version": 6
252 | 				}
253 | 			}
254 | 		},
255 | 		"discussion": {
256 | 			"3342f507-0d13-4f24-9a42-b7951f6fa5f5": {
257 | 				"role": "reader",
258 | 				"value": {
259 | 					"comments": ["8866ebf3-4a2d-4549-92ab-928c2354e8fc"],
260 | 					"id": "3342f507-0d13-4f24-9a42-b7951f6fa5f5",
261 | 					"parent_id": "0367c2db-381a-4f8b-9ce3-60f388a6b2e3",
262 | 					"parent_table": "block",
263 | 					"resolved": false,
264 | 					"space_id": "bc202e06-6caa-4e3f-81eb-f226ab5deef7",
265 | 					"version": 1
266 | 				}
267 | 			}
268 | 		},
269 | 		"space": {}
270 | 	}
271 | }
272 | `
273 | )
274 | 
275 | func TestLoadCachedPageChunk1(t *testing.T) {
276 | 	var rsp LoadCachedPageChunkResponse
277 | 	d := []byte(loadPageJSON1)
278 | 	err := jsonit.Unmarshal(d, &rsp)
279 | 	require.NoError(t, err)
280 | 	err = jsonit.Unmarshal(d, &rsp.RawJSON)
281 | 	require.NoError(t, err)
282 | 	err = ParseRecordMap(rsp.RecordMap)
283 | 	require.NoError(t, err)
284 | 	blocks := rsp.RecordMap.Blocks
285 | 	require.Equal(t, 7, len(blocks))
286 | 	for _, rec := range blocks {
287 | 		err = parseRecord(TableBlock, rec)
288 | 		require.NoError(t, err)
289 | 	}
290 | 	{
291 | 		block := blocks["0367c2db-381a-4f8b-9ce3-60f388a6b2e3"].Block
292 | 		require.True(t, block.Alive)
293 | 		require.Equal(t, "0367c2db-381a-4f8b-9ce3-60f388a6b2e3", block.ID)
294 | 		require.Equal(t, "525cd68a-31f3-4e98-a8c1-cb9c39849399", block.ParentID)
295 | 		require.Equal(t, BlockPage, block.Type)
296 | 		require.Equal(t, true, block.FormatPage().PageFullWidth)
297 | 		require.Equal(t, 0.352, block.FormatPage().PageCoverPosition)
298 | 		require.Equal(t, int64(366), block.Version)
299 | 		require.Equal(t, 5, len(block.ContentIDs))
300 | 	}
301 | }
302 | 


--------------------------------------------------------------------------------
/do/main.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"flag"
  6 | 	"net/http"
  7 | 	"os"
  8 | 	"os/exec"
  9 | 	"os/signal"
 10 | 	"path/filepath"
 11 | 	"strings"
 12 | 	"syscall"
 13 | 	"time"
 14 | 
 15 | 	"github.com/kjk/notionapi"
 16 | 	"github.com/kjk/u"
 17 | )
 18 | 
 19 | var (
 20 | 	dataDir  = "tmpdata"
 21 | 	cacheDir = filepath.Join(dataDir, "cache")
 22 | 
 23 | 	flgToken   string
 24 | 	flgVerbose bool
 25 | 
 26 | 	// if true, will try to avoid downloading the page by using
 27 | 	// cached version saved in log/ directory
 28 | 	flgNoCache bool
 29 | 
 30 | 	// if true, will not automatically open a browser to display
 31 | 	// html generated for a page
 32 | 	flgNoOpen bool
 33 | 
 34 | 	flgNoFormat bool
 35 | 	flgReExport bool
 36 | )
 37 | 
 38 | func getToken() string {
 39 | 	if flgToken != "" {
 40 | 		return flgToken
 41 | 	}
 42 | 	return os.Getenv("NOTION_TOKEN")
 43 | }
 44 | 
 45 | func newClient() *notionapi.Client {
 46 | 	c := ¬ionapi.Client{
 47 | 		AuthToken: getToken(),
 48 | 	}
 49 | 	if flgVerbose {
 50 | 		c.DebugLog = flgVerbose
 51 | 		c.Logger = os.Stdout
 52 | 	}
 53 | 	return c
 54 | }
 55 | 
 56 | func exportPageToFile(id string, exportType string, recursive bool, path string) error {
 57 | 
 58 | 	if exportType == "" {
 59 | 		exportType = "html"
 60 | 	}
 61 | 	client := newClient()
 62 | 	d, err := client.ExportPages(id, exportType, recursive)
 63 | 	if err != nil {
 64 | 		logf("client.ExportPages() failed with '%s'\n", err)
 65 | 		return err
 66 | 	}
 67 | 
 68 | 	writeFileMust(path, d)
 69 | 	logf("Downloaded exported page of id %s as %s\n", id, path)
 70 | 	return nil
 71 | }
 72 | 
 73 | func exportPage(id string, exportType string, recursive bool) {
 74 | 	client := newClient()
 75 | 
 76 | 	if exportType == "" {
 77 | 		exportType = "html"
 78 | 	}
 79 | 	d, err := client.ExportPages(id, exportType, recursive)
 80 | 	if err != nil {
 81 | 		logf("client.ExportPages() failed with '%s'\n", err)
 82 | 		return
 83 | 	}
 84 | 	name := notionapi.ToNoDashID(id) + "-" + exportType + ".zip"
 85 | 	writeFileMust(name, d)
 86 | 	logf("Downloaded exported page of id %s as %s\n", id, name)
 87 | }
 88 | 
 89 | func runGoTests() {
 90 | 	cmd := exec.Command("go", "test", "-v", "./...")
 91 | 	logf("Running: %s\n", strings.Join(cmd.Args, " "))
 92 | 	cmd.Stdout = os.Stdout
 93 | 	cmd.Stderr = os.Stderr
 94 | 	must(cmd.Run())
 95 | }
 96 | 
 97 | func traceNotionAPI() {
 98 | 	nodeModulesDir := filepath.Join("tracenotion", "node_modules")
 99 | 	if !u.DirExists(nodeModulesDir) {
100 | 		cmd := exec.Command("yarn")
101 | 		cmd.Dir = "tracenotion"
102 | 		err := cmd.Run()
103 | 		must(err)
104 | 	}
105 | 	scriptPath := filepath.Join("tracenotion", "trace.js")
106 | 	cmd := exec.Command("node", scriptPath)
107 | 	cmd.Args = append(cmd.Args, flag.Args()...)
108 | 	cmd.Stdout = os.Stdout
109 | 	cmd.Stderr = os.Stderr
110 | 	err := cmd.Run()
111 | 	must(err)
112 | }
113 | 
114 | var toText = notionapi.TextSpansToString
115 | 
116 | func main() {
117 | 	u.CdUpDir("notionapi")
118 | 	logf("currDirAbs: '%s'\n", u.CurrDirAbsMust())
119 | 
120 | 	var (
121 | 		//flgToken string
122 | 		// id of notion page to download
123 | 		flgDownloadPage string
124 | 
125 | 		// id of notion page to download and convert to HTML
126 | 		flgToHTML     string
127 | 		flgToMarkdown string
128 | 
129 | 		flgPreviewHTML     string
130 | 		flgPreviewMarkdown string
131 | 
132 | 		flgWc bool
133 | 
134 | 		flgExportPage string
135 | 		flgExportType string
136 | 		flgRecursive  bool
137 | 		flgTrace      bool
138 | 
139 | 		// if true, remove cache directories (data/log, data/cache)
140 | 		flgCleanCache bool
141 | 
142 | 		flgSanityTest        bool
143 | 		flgSmokeTest         bool
144 | 		flgTestToMd          string
145 | 		flgTestToHTML        string
146 | 		flgTestDownloadCache string
147 | 		flgBench             bool
148 | 	)
149 | 
150 | 	{
151 | 		flag.BoolVar(&flgNoFormat, "no-format", false, "if true, doesn't try to reformat/prettify HTML files during HTML testing")
152 | 		flag.BoolVar(&flgCleanCache, "clean-cache", false, "if true, cleans cache directories (data/log, data/cache")
153 | 		flag.StringVar(&flgToken, "token", "", "auth token")
154 | 		flag.BoolVar(&flgRecursive, "recursive", false, "if true, recursive export")
155 | 		flag.BoolVar(&flgVerbose, "verbose", false, "if true, verbose logging")
156 | 		flag.StringVar(&flgExportPage, "export-page", "", "id of the page to export")
157 | 		flag.BoolVar(&flgTrace, "trace", false, "run node tracenotion/trace.js")
158 | 		flag.StringVar(&flgExportType, "export-type", "", "html or markdown")
159 | 		flag.StringVar(&flgTestToMd, "test-to-md", "", "test markdown generation")
160 | 		flag.StringVar(&flgTestToHTML, "test-to-html", "", "id of start page")
161 | 		flag.StringVar(&flgToHTML, "to-html", "", "id of notion page to download and convert to html")
162 | 		flag.StringVar(&flgToMarkdown, "to-md", "", "id of notion page to download and convert to markdown")
163 | 
164 | 		flag.StringVar(&flgPreviewHTML, "preview-html", "", "id of start page")
165 | 		flag.StringVar(&flgPreviewMarkdown, "preview-md", "", "id of start page")
166 | 
167 | 		flag.BoolVar(&flgSanityTest, "sanity", false, "runs a quick sanity tests (fast and basic)")
168 | 		flag.BoolVar(&flgSmokeTest, "smoke", false, "run a smoke test (not fast, run after non-trivial changes)")
169 | 		flag.StringVar(&flgTestDownloadCache, "test-download-cache", "", "page id to use to test download cache")
170 | 		flag.StringVar(&flgDownloadPage, "dlpage", "", "id of notion page to download")
171 | 		flag.BoolVar(&flgReExport, "re-export", false, "if true, will re-export from notion")
172 | 		flag.BoolVar(&flgNoCache, "no-cache", false, "if true, will not use a cached version in log/ directory")
173 | 		flag.BoolVar(&flgNoOpen, "no-open", false, "if true, will not automatically open the browser with html file generated with -tohtml")
174 | 		flag.BoolVar(&flgWc, "wc", false, "wc -l on source files")
175 | 		flag.BoolVar(&flgBench, "bench", false, "run benchmark")
176 | 		flag.Parse()
177 | 	}
178 | 
179 | 	must(os.MkdirAll(cacheDir, 0755))
180 | 
181 | 	if false {
182 | 		flgPreviewHTML = "da0b358c21ab4ac6b5c0f7154b2ecadc"
183 | 	}
184 | 
185 | 	if false {
186 | 		testDownloadFile()
187 | 		return
188 | 	}
189 | 
190 | 	if false {
191 | 		adhocTests()
192 | 		return
193 | 	}
194 | 
195 | 	if false {
196 | 		testGetBlockRecords()
197 | 		testLoadCachePageChunk()
198 | 		return
199 | 	}
200 | 
201 | 	if false {
202 | 		// simple page with an image
203 | 		pageID := "da0b358c21ab4ac6b5c0f7154b2ecadc"
204 | 		client := makeNotionClient()
205 | 		client.DebugLog = true
206 | 		if false {
207 | 			timeStart := time.Now()
208 | 			page, err := client.DownloadPage(pageID)
209 | 			if err != nil {
210 | 				logf("Client.DownloadPage('%s') failed with '%s'\n", pageID, err)
211 | 				return
212 | 			}
213 | 			logf("Client.DownloadPage('%s') downloaded page '%s' in %s\n", pageID, page.Root().GetTitle(), time.Since(timeStart))
214 | 		}
215 | 		// try with empty cache
216 | 		cacheDir, err := filepath.Abs("cached_notion")
217 | 		must(err)
218 | 		os.RemoveAll(cacheDir)
219 | 		logf("cache dir: '%s'\n", cacheDir)
220 | 		{
221 | 			client, err := notionapi.NewCachingClient(cacheDir, client)
222 | 			must(err)
223 | 			timeStart := time.Now()
224 | 			page, err := client.DownloadPage(pageID)
225 | 			if err != nil {
226 | 				logf("Client.DownloadPage('%s') failed with '%s'\n", pageID, err)
227 | 				return
228 | 			}
229 | 			logf("CachingClient.DownloadPage('%s') downloaded page '%s' in %s\n", pageID, toText(page.Root().GetTitle()), time.Since(timeStart))
230 | 			logf("Cached requests: %d, non-cached requests: %d, requests written to cache: %d\n", client.RequestsFromCache, client.RequestsFromServer, client.RequestsWrittenToCache)
231 | 		}
232 | 		// try with full cache
233 | 		{
234 | 			client, err := notionapi.NewCachingClient(cacheDir, client)
235 | 			must(err)
236 | 			timeStart := time.Now()
237 | 			page, err := client.DownloadPage(pageID)
238 | 			if err != nil {
239 | 				logf("Client.DownloadPage('%s') failed with '%s'\n", pageID, err)
240 | 				return
241 | 			}
242 | 			logf("CachingClient.DownloadPage('%s') downloaded page '%s' in %s\n", pageID, toText(page.Root().GetTitle()), time.Since(timeStart))
243 | 			logf("Cached requests: %d, non-cached requests: %d, requests written to cache: %d\n", client.RequestsFromCache, client.RequestsFromServer, client.RequestsWrittenToCache)
244 | 		}
245 | 		return
246 | 	}
247 | 
248 | 	if false {
249 | 		// simple page with an image
250 | 		//flgToHTML = "da0b358c21ab4ac6b5c0f7154b2ecadc"
251 | 		//flgToHTML = "35fbba015f344570af678d56827dd67c"
252 | 		flgToHTML = "638829dcc8f24475afcdfa245d411e50"
253 | 	}
254 | 
255 | 	if false {
256 | 		testSubPages()
257 | 		return
258 | 	}
259 | 
260 | 	// normalize ids early on
261 | 	flgDownloadPage = notionapi.ToNoDashID(flgDownloadPage)
262 | 	flgToHTML = notionapi.ToNoDashID(flgToHTML)
263 | 	flgToMarkdown = notionapi.ToNoDashID(flgToMarkdown)
264 | 
265 | 	if flgWc {
266 | 		doLineCount()
267 | 		return
268 | 	}
269 | 
270 | 	if flgCleanCache {
271 | 		{
272 | 			dir := filepath.Join(dataDir, "diff")
273 | 			os.RemoveAll(dir)
274 | 		}
275 | 		{
276 | 			dir := filepath.Join(dataDir, "smoke")
277 | 			os.RemoveAll(dir)
278 | 		}
279 | 		u.RemoveFilesInDirMust(cacheDir)
280 | 	}
281 | 
282 | 	if flgBench {
283 | 		cmd := exec.Command("go", "test", "-bench=.")
284 | 		u.RunCmdMust(cmd)
285 | 		return
286 | 	}
287 | 
288 | 	if flgSanityTest {
289 | 		sanityTests()
290 | 		return
291 | 	}
292 | 
293 | 	if flgSmokeTest {
294 | 		// smoke test includes sanity test
295 | 		sanityTests()
296 | 		smokeTest()
297 | 		return
298 | 	}
299 | 
300 | 	if flgTrace {
301 | 		traceNotionAPI()
302 | 		return
303 | 	}
304 | 
305 | 	if flgTestToMd != "" {
306 | 		testToMarkdown(flgTestToMd)
307 | 		return
308 | 	}
309 | 
310 | 	if flgExportPage != "" {
311 | 		exportPage(flgExportPage, flgExportType, flgRecursive)
312 | 		return
313 | 	}
314 | 
315 | 	if flgTestDownloadCache != "" {
316 | 		testCachingDownloads(flgTestDownloadCache)
317 | 		return
318 | 	}
319 | 
320 | 	if flgTestToHTML != "" {
321 | 		testToHTML(flgTestToHTML)
322 | 		return
323 | 	}
324 | 
325 | 	if flgDownloadPage != "" {
326 | 		client := makeNotionClient()
327 | 		downloadPage(client, flgDownloadPage)
328 | 		return
329 | 	}
330 | 
331 | 	if flgToHTML != "" {
332 | 		flgNoCache = true
333 | 		toHTML(flgToHTML)
334 | 		return
335 | 	}
336 | 
337 | 	if flgToMarkdown != "" {
338 | 		flgNoCache = true
339 | 		toMd(flgToMarkdown)
340 | 		return
341 | 	}
342 | 
343 | 	if flgPreviewHTML != "" {
344 | 		uri := "/previewhtml/" + flgPreviewHTML
345 | 		startHTTPServer(uri)
346 | 		return
347 | 	}
348 | 
349 | 	if flgPreviewMarkdown != "" {
350 | 		uri := "/previewmd/" + flgPreviewMarkdown
351 | 		startHTTPServer(uri)
352 | 		return
353 | 	}
354 | 
355 | 	flag.Usage()
356 | }
357 | 
358 | func startHTTPServer(uri string) {
359 | 	flgHTTPAddr := "localhost:8503"
360 | 	httpSrv := makeHTTPServer()
361 | 	httpSrv.Addr = flgHTTPAddr
362 | 
363 | 	logf("Starting on addr: %v\n", flgHTTPAddr)
364 | 
365 | 	chServerClosed := make(chan bool, 1)
366 | 	go func() {
367 | 		err := httpSrv.ListenAndServe()
368 | 		// mute error caused by Shutdown()
369 | 		if err == http.ErrServerClosed {
370 | 			err = nil
371 | 		}
372 | 		must(err)
373 | 		logf("HTTP server shutdown gracefully\n")
374 | 		chServerClosed <- true
375 | 	}()
376 | 
377 | 	c := make(chan os.Signal, 2)
378 | 	signal.Notify(c, os.Interrupt /* SIGINT */, syscall.SIGTERM)
379 | 
380 | 	openBrowser("http://" + flgHTTPAddr + uri)
381 | 	time.Sleep(time.Second * 2)
382 | 
383 | 	sig := <-c
384 | 	logf("Got signal %s\n", sig)
385 | 
386 | 	if httpSrv != nil {
387 | 		// Shutdown() needs a non-nil context
388 | 		_ = httpSrv.Shutdown(context.Background())
389 | 		select {
390 | 		case <-chServerClosed:
391 | 			// do nothing
392 | 		case <-time.After(time.Second * 5):
393 | 			// timeout
394 | 		}
395 | 	}
396 | 
397 | }
398 | 


--------------------------------------------------------------------------------
/tohtml/css_notion.go:
--------------------------------------------------------------------------------
  1 | package tohtml
  2 | 
  3 | // CSS we use. If Converter.FullHTML is true, it's included
  4 | // as part of generated HTML. Otherwise you have to provide
  5 | // HTML wrapper where you can either embed this CSS
  6 | // as  or reference it as
  7 | // 
  8 | const CSS = `
  9 | /* webkit printing magic: print all background colors */
 10 | html {
 11 | 	-webkit-print-color-adjust: exact;
 12 | }
 13 | * {
 14 | 	box-sizing: border-box;
 15 | 	-webkit-print-color-adjust: exact;
 16 | }
 17 | 
 18 | html,
 19 | body {
 20 | 	margin: 0;
 21 | 	padding: 0;
 22 | }
 23 | @media only screen {
 24 | 	body {
 25 | 		margin: 2em auto;
 26 | 		max-width: 900px;
 27 | 		color: rgb(55, 53, 47);
 28 | 	}
 29 | }
 30 | a,
 31 | a.visited {
 32 | 	color: inherit;
 33 | 	text-decoration: underline;
 34 | }
 35 | 
 36 | .pdf-relative-link-path {
 37 | 	font-size: 80%;
 38 | 	color: #444;
 39 | }
 40 | 
 41 | h1,
 42 | h2,
 43 | h3 {
 44 | 	letter-spacing: -0.01em;
 45 | 	line-height: 1.2;
 46 | 	font-weight: 600;
 47 | 	margin-bottom: 0;
 48 | }
 49 | 
 50 | .page-title {
 51 | 	font-size: 2.5rem;
 52 | 	font-weight: 700;
 53 | 	margin-top: 0;
 54 | 	margin-bottom: 0.75em;
 55 | }
 56 | 
 57 | h1 {
 58 | 	font-size: 1.875rem;
 59 | 	margin-top: 1.875rem;
 60 | }
 61 | 
 62 | h2 {
 63 | 	font-size: 1.5rem;
 64 | 	margin-top: 1.5rem;
 65 | }
 66 | 
 67 | h3 {
 68 | 	font-size: 1.25rem;
 69 | 	margin-top: 1.25rem;
 70 | }
 71 | 
 72 | .source {
 73 | 	border: 1px solid #ddd;
 74 | 	border-radius: 3px;
 75 | 	padding: 1.5em;
 76 | 	word-break: break-all;
 77 | }
 78 | 
 79 | .callout {
 80 | 	border-radius: 3px;
 81 | 	padding: 1rem;
 82 | }
 83 | 
 84 | figure {
 85 | 	margin: 1.25em 0;
 86 | 	page-break-inside: avoid;
 87 | }
 88 | 
 89 | figcaption {
 90 | 	opacity: 0.5;
 91 | 	font-size: 85%;
 92 | 	margin-top: 0.5em;
 93 | }
 94 | 
 95 | mark {
 96 | 	background-color: transparent;
 97 | }
 98 | 
 99 | .indented {
100 | 	padding-left: 1.5em;
101 | }
102 | 
103 | hr {
104 | 	background: transparent;
105 | 	display: block;
106 | 	width: 100%;
107 | 	height: 1px;
108 | 	visibility: visible;
109 | 	border: none;
110 | 	border-bottom: 1px solid rgba(55, 53, 47, 0.09);
111 | }
112 | 
113 | img {
114 | 	max-width: 100%;
115 | }
116 | 
117 | @media only print {
118 | 	img {
119 | 		max-height: 100vh;
120 | 		object-fit: contain;
121 | 	}
122 | }
123 | 
124 | @page {
125 | 	margin: 1in;
126 | }
127 | 
128 | .collection-content {
129 | 	font-size: 0.875rem;
130 | }
131 | 
132 | .column-list {
133 | 	display: flex;
134 | 	justify-content: space-between;
135 | }
136 | 
137 | .column {
138 | 	padding: 0 1em;
139 | }
140 | 
141 | .column:first-child {
142 | 	padding-left: 0;
143 | }
144 | 
145 | .column:last-child {
146 | 	padding-right: 0;
147 | }
148 | 
149 | .table_of_contents-item {
150 | 	display: block;
151 | 	font-size: 0.875rem;
152 | 	line-height: 1.3;
153 | 	padding: 0.125rem;
154 | }
155 | 
156 | .table_of_contents-indent-1 {
157 | 	margin-left: 1.5rem;
158 | }
159 | 
160 | .table_of_contents-indent-2 {
161 | 	margin-left: 3rem;
162 | }
163 | 
164 | .table_of_contents-indent-3 {
165 | 	margin-left: 4.5rem;
166 | }
167 | 
168 | .table_of_contents-link {
169 | 	text-decoration: none;
170 | 	opacity: 0.7;
171 | 	border-bottom: 1px solid rgba(55, 53, 47, 0.18);
172 | }
173 | 
174 | table,
175 | th,
176 | td {
177 | 	border: 1px solid rgba(55, 53, 47, 0.09);
178 | 	border-collapse: collapse;
179 | }
180 | 
181 | table {
182 | 	border-left: none;
183 | 	border-right: none;
184 | }
185 | 
186 | th,
187 | td {
188 | 	font-weight: normal;
189 | 	padding: 0.25em 0.5em;
190 | 	line-height: 1.5;
191 | 	min-height: 1.5em;
192 | 	text-align: left;
193 | }
194 | 
195 | th {
196 | 	color: rgba(55, 53, 47, 0.6);
197 | }
198 | 
199 | ol,
200 | ul {
201 | 	margin: 0;
202 | 	margin-block-start: 0.6em;
203 | 	margin-block-end: 0.6em;
204 | }
205 | 
206 | li > ol:first-child,
207 | li > ul:first-child {
208 | 	margin-block-start: 0.6em;
209 | }
210 | 
211 | ul > li {
212 | 	list-style: disc;
213 | }
214 | 
215 | ul.to-do-list {
216 | 	text-indent: -1.7em;
217 | }
218 | 
219 | ul.to-do-list > li {
220 | 	list-style: none;
221 | }
222 | 
223 | .to-do-children-checked {
224 | 	text-decoration: line-through;
225 | 	opacity: 0.375;
226 | }
227 | 
228 | ul.toggle > li {
229 | 	list-style: none;
230 | }
231 | 
232 | ul {
233 | 	padding-inline-start: 1.7em;
234 | }
235 | 
236 | ul > li {
237 | 	padding-left: 0.1em;
238 | }
239 | 
240 | ol {
241 | 	padding-inline-start: 1.6em;
242 | }
243 | 
244 | ol > li {
245 | 	padding-left: 0.2em;
246 | }
247 | 
248 | .mono ol {
249 | 	padding-inline-start: 2em;
250 | }
251 | 
252 | .mono ol > li {
253 | 	text-indent: -0.4em;
254 | }
255 | 
256 | .toggle {
257 | 	padding-inline-start: 0em;
258 | 	list-style-type: none;
259 | }
260 | 
261 | /* Indent toggle children */
262 | .toggle > li > details {
263 | 	padding-left: 1.7em;
264 | }
265 | 
266 | .toggle > li > details > summary {
267 | 	margin-left: -1.1em;
268 | }
269 | 
270 | .selected-value {
271 | 	display: inline-block;
272 | 	padding: 0 0.5em;
273 | 	background: rgba(206, 205, 202, 0.5);
274 | 	border-radius: 3px;
275 | 	margin-right: 0.5em;
276 | 	margin-top: 0.3em;
277 | 	margin-bottom: 0.3em;
278 | 	white-space: nowrap;
279 | }
280 | 
281 | .collection-title {
282 | 	display: inline-block;
283 | 	margin-right: 1em;
284 | }
285 | 
286 | time {
287 | 	opacity: 0.5;
288 | }
289 | 
290 | .icon {
291 | 	display: inline-block;
292 | 	max-width: 1.2em;
293 | 	max-height: 1.2em;
294 | 	text-decoration: none;
295 | 	vertical-align: text-bottom;
296 | 	margin-right: 0.5em;
297 | }
298 | 
299 | img.icon {
300 | 	border-radius: 3px;
301 | }
302 | 
303 | .user-icon {
304 | 	width: 1.5em;
305 | 	height: 1.5em;
306 | 	border-radius: 100%;
307 | 	margin-right: 0.5rem;
308 | }
309 | 
310 | .user-icon-inner {
311 | 	font-size: 0.8em;
312 | }
313 | 
314 | .text-icon {
315 | 	border: 1px solid #000;
316 | 	text-align: center;
317 | }
318 | 
319 | .page-cover-image {
320 | 	display: block;
321 | 	object-fit: cover;
322 | 	width: 100%;
323 | 	height: 30vh;
324 | }
325 | 
326 | .page-header-icon {
327 | 	font-size: 3rem;
328 | 	margin-bottom: 1rem;
329 | }
330 | 
331 | .page-header-icon-with-cover {
332 | 	margin-top: -0.72em;
333 | 	margin-left: 0.07em;
334 | }
335 | 
336 | .page-header-icon img {
337 | 	border-radius: 3px;
338 | }
339 | 
340 | .link-to-page {
341 | 	margin: 1em 0;
342 | 	padding: 0;
343 | 	border: none;
344 | 	font-weight: 500;
345 | }
346 | 
347 | p > .user {
348 | 	opacity: 0.5;
349 | }
350 | 
351 | td > .user,
352 | td > time {
353 | 	white-space: nowrap;
354 | }
355 | 
356 | input[type="checkbox"] {
357 | 	transform: scale(1.5);
358 | 	margin-right: 0.6em;
359 | 	vertical-align: middle;
360 | }
361 | 
362 | p {
363 | 	margin-top: 0.5em;
364 | 	margin-bottom: 0.5em;
365 | }
366 | 
367 | .image {
368 | 	border: none;
369 | 	margin: 1.5em 0;
370 | 	padding: 0;
371 | 	border-radius: 0;
372 | 	text-align: center;
373 | }
374 | 
375 | .code,
376 | code {
377 | 	background: rgba(135, 131, 120, 0.15);
378 | 	border-radius: 3px;
379 | 	padding: 0.2em 0.4em;
380 | 	border-radius: 3px;
381 | 	font-size: 85%;
382 | 	tab-size: 2;
383 | }
384 | 
385 | code {
386 | 	color: #eb5757;
387 | }
388 | 
389 | .code {
390 | 	padding: 1.5em 1em;
391 | }
392 | 
393 | .code > code {
394 | 	background: none;
395 | 	padding: 0;
396 | 	font-size: 100%;
397 | 	color: inherit;
398 | }
399 | 
400 | blockquote {
401 | 	font-size: 1.25em;
402 | 	margin: 1em 0;
403 | 	padding-left: 1em;
404 | 	border-left: 3px solid rgb(55, 53, 47);
405 | }
406 | 
407 | .bookmark-href {
408 | 	font-size: 0.75em;
409 | 	opacity: 0.5;
410 | }
411 | 
412 | .sans { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"; }
413 | .code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; }
414 | .serif { font-family: Lyon-Text, Georgia, KaiTi, STKaiTi, '华文楷体', KaiTi_GB2312, '楷体_GB2312', serif; }
415 | .mono { font-family: Nitti, 'Microsoft YaHei', '微软雅黑', monospace; }
416 | .pdf .sans { font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol", 'Twemoji', 'Noto Color Emoji', 'Noto Sans CJK SC', 'Noto Sans CJK KR'; }
417 | 
418 | .pdf .code { font-family: Source Code Pro, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace, 'Twemoji', 'Noto Color Emoji', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK KR'; }
419 | 
420 | .pdf .serif { font-family: PT Serif, Lyon-Text, Georgia, KaiTi, STKaiTi, '华文楷体', KaiTi_GB2312, '楷体_GB2312', serif, 'Twemoji', 'Noto Color Emoji', 'Noto Sans CJK SC', 'Noto Sans CJK KR'; }
421 | 
422 | .pdf .mono { font-family: PT Mono, Nitti, 'Microsoft YaHei', '微软雅黑', monospace, 'Twemoji', 'Noto Color Emoji', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK KR'; }
423 | 
424 | .highlight-default {
425 | }
426 | .highlight-gray {
427 | 	color: rgb(155,154,151);
428 | }
429 | .highlight-brown {
430 | 	color: rgb(100,71,58);
431 | }
432 | .highlight-orange {
433 | 	color: rgb(217,115,13);
434 | }
435 | .highlight-yellow {
436 | 	color: rgb(223,171,1);
437 | }
438 | .highlight-teal {
439 | 	color: rgb(15,123,108);
440 | }
441 | .highlight-blue {
442 | 	color: rgb(11,110,153);
443 | }
444 | .highlight-purple {
445 | 	color: rgb(105,64,165);
446 | }
447 | .highlight-pink {
448 | 	color: rgb(173,26,114);
449 | }
450 | .highlight-red {
451 | 	color: rgb(224,62,62);
452 | }
453 | .highlight-gray_background {
454 | 	background: rgb(235,236,237);
455 | }
456 | .highlight-brown_background {
457 | 	background: rgb(233,229,227);
458 | }
459 | .highlight-orange_background {
460 | 	background: rgb(250,235,221);
461 | }
462 | .highlight-yellow_background {
463 | 	background: rgb(251,243,219);
464 | }
465 | .highlight-teal_background {
466 | 	background: rgb(221,237,234);
467 | }
468 | .highlight-blue_background {
469 | 	background: rgb(221,235,241);
470 | }
471 | .highlight-purple_background {
472 | 	background: rgb(234,228,242);
473 | }
474 | .highlight-pink_background {
475 | 	background: rgb(244,223,235);
476 | }
477 | .highlight-red_background {
478 | 	background: rgb(251,228,228);
479 | }
480 | .block-color-default {
481 | 	color: inherit;
482 | 	fill: inherit;
483 | }
484 | .block-color-gray {
485 | 	color: rgba(55, 53, 47, 0.6);
486 | 	fill: rgba(55, 53, 47, 0.6);
487 | }
488 | .block-color-brown {
489 | 	color: rgb(100,71,58);
490 | 	fill: rgb(100,71,58);
491 | }
492 | .block-color-orange {
493 | 	color: rgb(217,115,13);
494 | 	fill: rgb(217,115,13);
495 | }
496 | .block-color-yellow {
497 | 	color: rgb(223,171,1);
498 | 	fill: rgb(223,171,1);
499 | }
500 | .block-color-teal {
501 | 	color: rgb(15,123,108);
502 | 	fill: rgb(15,123,108);
503 | }
504 | .block-color-blue {
505 | 	color: rgb(11,110,153);
506 | 	fill: rgb(11,110,153);
507 | }
508 | .block-color-purple {
509 | 	color: rgb(105,64,165);
510 | 	fill: rgb(105,64,165);
511 | }
512 | .block-color-pink {
513 | 	color: rgb(173,26,114);
514 | 	fill: rgb(173,26,114);
515 | }
516 | .block-color-red {
517 | 	color: rgb(224,62,62);
518 | 	fill: rgb(224,62,62);
519 | }
520 | .block-color-gray_background {
521 | 	background: rgb(235,236,237);
522 | }
523 | .block-color-brown_background {
524 | 	background: rgb(233,229,227);
525 | }
526 | .block-color-orange_background {
527 | 	background: rgb(250,235,221);
528 | }
529 | .block-color-yellow_background {
530 | 	background: rgb(251,243,219);
531 | }
532 | .block-color-teal_background {
533 | 	background: rgb(221,237,234);
534 | }
535 | .block-color-blue_background {
536 | 	background: rgb(221,235,241);
537 | }
538 | .block-color-purple_background {
539 | 	background: rgb(234,228,242);
540 | }
541 | .block-color-pink_background {
542 | 	background: rgb(244,223,235);
543 | }
544 | .block-color-red_background {
545 | 	background: rgb(251,228,228);
546 | }
547 | 
548 | .checkbox {
549 | 	display: inline-flex;
550 | 	vertical-align: text-bottom;
551 | 	width: 16;
552 | 	height: 16;
553 | 	background-size: 16px;
554 | 	margin-left: 2px;
555 | 	margin-right: 5px;
556 | }
557 | 
558 | .checkbox-on {
559 | 	background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Crect%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%2358A9D7%22%2F%3E%0A%3Cpath%20d%3D%22M6.71429%2012.2852L14%204.9995L12.7143%203.71436L6.71429%209.71378L3.28571%206.2831L2%207.57092L6.71429%2012.2852Z%22%20fill%3D%22white%22%2F%3E%0A%3C%2Fsvg%3E");
560 | }
561 | 
562 | .checkbox-off {
563 | 	background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Crect%20x%3D%220.75%22%20y%3D%220.75%22%20width%3D%2214.5%22%20height%3D%2214.5%22%20fill%3D%22white%22%20stroke%3D%22%2336352F%22%20stroke-width%3D%221.5%22%2F%3E%0A%3C%2Fsvg%3E");
564 | }
565 | `
566 | 
567 | // CSSPlus is CSS additional to what Notion CSS has
568 | const CSSPlus = `
569 | .breadcrumbs {
570 | 
571 | }
572 | `
573 | 


--------------------------------------------------------------------------------