├── 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 |
{{ .Markdown }}
59 | 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: "element insequence */ 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 imagetag: 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 | --------------------------------------------------------------------------------